added subtitle/audio track selection menu

This commit is contained in:
tapframe 2025-12-31 01:05:09 +05:30
parent ec28f73df9
commit be9473adf7
7 changed files with 760 additions and 15 deletions

View file

@ -56,6 +56,7 @@ import { formatTime, isHlsStream, getHlsHeaders, defaultAndroidHeaders, parseSRT
import { storageService } from '../../services/storageService';
import stremioService from '../../services/stremioService';
import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes';
import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
import axios from 'axios';
const DEBUG_MODE = false;
@ -120,6 +121,7 @@ const AndroidVideoPlayer: React.FC = () => {
const [useCustomSubtitles, setUseCustomSubtitles] = useState(false);
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
const [selectedExternalSubtitleId, setSelectedExternalSubtitleId] = useState<string | null>(null);
// Subtitle customization state
const [subtitleSize, setSubtitleSize] = useState(28);
@ -136,6 +138,9 @@ const AndroidVideoPlayer: React.FC = () => {
const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState(1.2);
const [subtitleOffsetSec, setSubtitleOffsetSec] = useState(0);
// Track auto-selection ref to prevent duplicate selections
const hasAutoSelectedTracks = useRef(false);
const metadataResult = useMetadata({ id: id || 'placeholder', type: (type as any) });
const { metadata, cast } = Boolean(id && type) ? (metadataResult as any) : { metadata: null, cast: [] };
const hasLogo = metadata && metadata.logo;
@ -299,6 +304,52 @@ const AndroidVideoPlayer: React.FC = () => {
playerState.setIsVideoLoaded(true);
openingAnimation.completeOpeningAnimation();
// Auto-select audio track based on preferences
if (data.audioTracks && data.audioTracks.length > 0 && settings?.preferredAudioLanguage) {
const formatted = data.audioTracks.map((t: any, i: number) => ({
id: t.index !== undefined ? t.index : i,
name: t.title || t.name || `Track ${i + 1}`,
language: t.language
}));
const bestAudioTrack = findBestAudioTrack(formatted, settings.preferredAudioLanguage);
if (bestAudioTrack !== null) {
logger.debug(`[AndroidVideoPlayer] Auto-selecting audio track ${bestAudioTrack} for language: ${settings.preferredAudioLanguage}`);
tracksHook.setSelectedAudioTrack({ type: 'index', value: bestAudioTrack });
}
}
// Auto-select subtitle track based on preferences
// Only auto-select internal tracks here if preference is 'internal' or 'any'
// If preference is 'external', we wait for the useEffect to handle selection after external subs load
if (data.textTracks && data.textTracks.length > 0 && !hasAutoSelectedTracks.current && settings?.enableSubtitleAutoSelect) {
const sourcePreference = settings?.subtitleSourcePreference || 'internal';
// Only pre-select internal if preference is internal or any
if (sourcePreference === 'internal' || sourcePreference === 'any') {
const formatted = data.textTracks.map((t: any, i: number) => ({
id: t.index !== undefined ? t.index : i,
name: t.title || t.name || `Track ${i + 1}`,
language: t.language
}));
const subtitleSelection = findBestSubtitleTrack(
formatted,
[], // External subtitles not yet loaded
{
preferredSubtitleLanguage: settings?.preferredSubtitleLanguage || 'en',
subtitleSourcePreference: sourcePreference,
enableSubtitleAutoSelect: true
}
);
if (subtitleSelection.type === 'internal' && subtitleSelection.internalTrackId !== undefined) {
logger.debug(`[AndroidVideoPlayer] Auto-selecting internal subtitle track ${subtitleSelection.internalTrackId}`);
tracksHook.setSelectedTextTrack(subtitleSelection.internalTrackId);
hasAutoSelectedTracks.current = true;
}
}
// If preference is 'external', don't select anything here - useEffect will handle it
}
// Handle Resume - check both initialPosition and initialSeekTargetRef
const resumeTarget = watchProgress.initialPosition || watchProgress.initialSeekTargetRef?.current;
if (resumeTarget && resumeTarget > 0 && !watchProgress.showResumeOverlay && videoDuration > 0) {
@ -332,6 +383,45 @@ const AndroidVideoPlayer: React.FC = () => {
}
}, [playerState.currentTime, playerState.isDragging, playerState.isSeeking, setupHook.isAppBackgrounded]);
// Auto-select subtitles when both internal tracks and video are loaded
// This ensures we wait for internal tracks before falling back to external
useEffect(() => {
if (!playerState.isVideoLoaded || hasAutoSelectedTracks.current || !settings?.enableSubtitleAutoSelect) {
return;
}
const internalTracks = tracksHook.ksTextTracks;
const externalSubs = availableSubtitles;
// Wait a short delay to ensure tracks are fully populated
const timeoutId = setTimeout(() => {
if (hasAutoSelectedTracks.current) return;
const subtitleSelection = findBestSubtitleTrack(
internalTracks,
externalSubs,
{
preferredSubtitleLanguage: settings?.preferredSubtitleLanguage || 'en',
subtitleSourcePreference: settings?.subtitleSourcePreference || 'internal',
enableSubtitleAutoSelect: true
}
);
// Trust the findBestSubtitleTrack function's decision - it already implements priority logic
if (subtitleSelection.type === 'internal' && subtitleSelection.internalTrackId !== undefined) {
logger.debug(`[AndroidVideoPlayer] Auto-selecting internal subtitle track ${subtitleSelection.internalTrackId}`);
tracksHook.setSelectedTextTrack(subtitleSelection.internalTrackId);
hasAutoSelectedTracks.current = true;
} else if (subtitleSelection.type === 'external' && subtitleSelection.externalSubtitle) {
logger.debug(`[AndroidVideoPlayer] Auto-selecting external subtitle: ${subtitleSelection.externalSubtitle.display}`);
loadWyzieSubtitle(subtitleSelection.externalSubtitle);
hasAutoSelectedTracks.current = true;
}
}, 500); // Short delay to ensure tracks are populated
return () => clearTimeout(timeoutId);
}, [playerState.isVideoLoaded, tracksHook.ksTextTracks, availableSubtitles, settings]);
// Sync custom subtitle text with current playback time
useEffect(() => {
if (!useCustomSubtitles || customSubtitles.length === 0) return;
@ -496,6 +586,7 @@ const AndroidVideoPlayer: React.FC = () => {
setAvailableSubtitles(subs);
logger.info(`[AndroidVideoPlayer] Fetched ${subs.length} addon subtitles`);
// Auto-selection is now handled by useEffect that waits for internal tracks
} catch (e) {
logger.error('[AndroidVideoPlayer] Error fetching addon subtitles', e);
} finally {
@ -523,6 +614,7 @@ const AndroidVideoPlayer: React.FC = () => {
const parsedCues = parseSRT(srtContent);
setCustomSubtitles(parsedCues);
setUseCustomSubtitles(true);
setSelectedExternalSubtitleId(subtitle.id); // Track the selected external subtitle
// Disable MPV's built-in subtitle track when using custom subtitles
tracksHook.setSelectedTextTrack(-1);
@ -549,6 +641,7 @@ const AndroidVideoPlayer: React.FC = () => {
setUseCustomSubtitles(false);
setCustomSubtitles([]);
setCurrentSubtitle('');
setSelectedExternalSubtitleId(null); // Clear external selection
}, []);
const cycleResizeMode = useCallback(() => {
@ -893,6 +986,7 @@ const AndroidVideoPlayer: React.FC = () => {
setSubtitleLineHeightMultiplier={setSubtitleLineHeightMultiplier}
subtitleOffsetSec={subtitleOffsetSec}
setSubtitleOffsetSec={setSubtitleOffsetSec}
selectedExternalSubtitleId={selectedExternalSubtitleId}
/>
<SourcesModal

View file

@ -51,6 +51,8 @@ import { logger } from '../../utils/logger';
import { formatTime } from './utils/playerUtils';
import { WyzieSubtitle } from './utils/playerTypes';
import { parseSRT } from './utils/subtitleParser';
import { findBestSubtitleTrack, autoSelectAudioTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
import { useSettings } from '../../hooks/useSettings';
// Player route params interface
interface PlayerRouteParams {
@ -130,6 +132,10 @@ const KSPlayerCore: React.FC = () => {
const tracks = usePlayerTracks();
const { ksPlayerRef, seek } = useKSPlayer();
const customSubs = useCustomSubtitles();
const { settings } = useSettings();
// Track auto-selection refs to prevent duplicate selections
const hasAutoSelectedTracks = useRef(false);
// Next Episode Hook
const { nextEpisode, currentEpisodeDescription } = useNextEpisode({
@ -267,19 +273,8 @@ const KSPlayerCore: React.FC = () => {
}));
customSubs.setAvailableSubtitles(subs);
if (autoSelectEnglish) {
const englishSubtitle = subs.find(sub =>
sub.language.includes('en') || sub.display.toLowerCase().includes('english')
);
if (englishSubtitle) {
loadWyzieSubtitle(englishSubtitle);
return;
}
}
if (!autoSelectEnglish) {
modals.setShowSubtitleLanguageModal(true);
}
// Auto-selection is now handled by useEffect that waits for internal tracks
// This ensures internal tracks are considered before falling back to external
} catch (e) {
logger.error('[VideoPlayer] Error fetching subtitles', e);
} finally {
@ -302,6 +297,7 @@ const KSPlayerCore: React.FC = () => {
const parsedCues = parseSRT(srtContent);
customSubs.setCustomSubtitles(parsedCues);
customSubs.setUseCustomSubtitles(true);
customSubs.setSelectedExternalSubtitleId(subtitle.id); // Track the selected external subtitle
tracks.selectTextTrack(-1);
const adjustedTime = currentTime + (customSubs.subtitleOffsetSec || 0);
@ -322,6 +318,45 @@ const KSPlayerCore: React.FC = () => {
}
}, [imdbId]);
// Auto-select subtitles when both internal tracks and video are loaded
// This ensures we wait for internal tracks before falling back to external
useEffect(() => {
if (!isVideoLoaded || hasAutoSelectedTracks.current || !settings?.enableSubtitleAutoSelect) {
return;
}
const internalTracks = tracks.ksTextTracks;
const externalSubs = customSubs.availableSubtitles;
// Wait a short delay to ensure tracks are fully populated
const timeoutId = setTimeout(() => {
if (hasAutoSelectedTracks.current) return;
const subtitleSelection = findBestSubtitleTrack(
internalTracks,
externalSubs,
{
preferredSubtitleLanguage: settings?.preferredSubtitleLanguage || 'en',
subtitleSourcePreference: settings?.subtitleSourcePreference || 'internal',
enableSubtitleAutoSelect: true
}
);
// Trust the findBestSubtitleTrack function's decision - it already implements priority logic
if (subtitleSelection.type === 'internal' && subtitleSelection.internalTrackId !== undefined) {
logger.debug(`[KSPlayerCore] Auto-selecting internal subtitle track ${subtitleSelection.internalTrackId}`);
tracks.selectTextTrack(subtitleSelection.internalTrackId);
hasAutoSelectedTracks.current = true;
} else if (subtitleSelection.type === 'external' && subtitleSelection.externalSubtitle) {
logger.debug(`[KSPlayerCore] Auto-selecting external subtitle: ${subtitleSelection.externalSubtitle.display}`);
loadWyzieSubtitle(subtitleSelection.externalSubtitle);
hasAutoSelectedTracks.current = true;
}
}, 500); // Short delay to ensure tracks are populated
return () => clearTimeout(timeoutId);
}, [isVideoLoaded, tracks.ksTextTracks, customSubs.availableSubtitles, settings]);
// Sync custom subtitle text with current playback time
useEffect(() => {
if (!customSubs.useCustomSubtitles || customSubs.customSubtitles.length === 0) return;
@ -347,6 +382,45 @@ const KSPlayerCore: React.FC = () => {
setIsPlayerReady(true);
openingAnim.completeOpeningAnimation();
// Auto-select audio track based on preferences
if (data.audioTracks && data.audioTracks.length > 0 && settings?.preferredAudioLanguage) {
const bestAudioTrack = findBestAudioTrack(data.audioTracks, settings.preferredAudioLanguage);
if (bestAudioTrack !== null) {
logger.debug(`[KSPlayerCore] Auto-selecting audio track ${bestAudioTrack} for language: ${settings.preferredAudioLanguage}`);
tracks.selectAudioTrack(bestAudioTrack);
if (ksPlayerRef.current) {
ksPlayerRef.current.setAudioTrack(bestAudioTrack);
}
}
}
// Auto-select subtitle track based on preferences
// Only auto-select internal tracks here if preference is 'internal' or 'any'
// If preference is 'external', we wait for the useEffect to handle selection after external subs load
if (data.textTracks && data.textTracks.length > 0 && !hasAutoSelectedTracks.current && settings?.enableSubtitleAutoSelect) {
const sourcePreference = settings?.subtitleSourcePreference || 'internal';
// Only pre-select internal if preference is internal or any
if (sourcePreference === 'internal' || sourcePreference === 'any') {
const subtitleSelection = findBestSubtitleTrack(
data.textTracks,
[], // External subtitles not yet loaded
{
preferredSubtitleLanguage: settings?.preferredSubtitleLanguage || 'en',
subtitleSourcePreference: sourcePreference,
enableSubtitleAutoSelect: true
}
);
if (subtitleSelection.type === 'internal' && subtitleSelection.internalTrackId !== undefined) {
logger.debug(`[KSPlayerCore] Auto-selecting internal subtitle track ${subtitleSelection.internalTrackId} on load`);
tracks.selectTextTrack(subtitleSelection.internalTrackId);
hasAutoSelectedTracks.current = true;
}
}
// If preference is 'external', don't select anything here - useEffect will handle it
}
// Initial Seek
const resumeTarget = routeInitialPosition || watchProgress.initialPosition || watchProgress.initialSeekTargetRef?.current;
if (resumeTarget && resumeTarget > 0 && !watchProgress.showResumeOverlay && data.duration > 0) {
@ -800,8 +874,10 @@ const KSPlayerCore: React.FC = () => {
selectTextTrack={handleSelectTextTrack}
disableCustomSubtitles={() => {
customSubs.setUseCustomSubtitles(false);
customSubs.setSelectedExternalSubtitleId(null); // Clear external selection
handleSelectTextTrack(-1);
}}
selectedExternalSubtitleId={customSubs.selectedExternalSubtitleId}
/>
<SourcesModal

View file

@ -18,6 +18,7 @@ export const useCustomSubtitles = () => {
const [currentFormattedSegments, setCurrentFormattedSegments] = useState<SubtitleSegment[][]>([]);
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false);
const [selectedExternalSubtitleId, setSelectedExternalSubtitleId] = useState<string | null>(null);
// Loading State
const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false);
@ -91,6 +92,7 @@ export const useCustomSubtitles = () => {
currentFormattedSegments, setCurrentFormattedSegments,
availableSubtitles, setAvailableSubtitles,
useCustomSubtitles, setUseCustomSubtitles,
selectedExternalSubtitleId, setSelectedExternalSubtitleId,
isLoadingSubtitles, setIsLoadingSubtitles,
isLoadingSubtitleList, setIsLoadingSubtitleList,
subtitleSize, setSubtitleSize,

View file

@ -59,6 +59,7 @@ interface SubtitleModalsProps {
setSubtitleLineHeightMultiplier: (n: number) => void;
subtitleOffsetSec: number;
setSubtitleOffsetSec: (n: number) => void;
selectedExternalSubtitleId?: string | null; // ID of currently selected external/addon subtitle
}
const MorphingTab = ({ label, isSelected, onPress }: any) => {
@ -91,11 +92,15 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
subtitleBottomOffset, setSubtitleBottomOffset, subtitleLetterSpacing, setSubtitleLetterSpacing,
subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier, subtitleOffsetSec, setSubtitleOffsetSec,
setSubtitlesAutoSelect,
selectedExternalSubtitleId,
}) => {
const { width, height } = useWindowDimensions();
const isIos = Platform.OS === 'ios';
const isLandscape = width > height;
const [selectedOnlineSubtitleId, setSelectedOnlineSubtitleId] = React.useState<string | null>(null);
// Use prop value if provided (for auto-selected subtitles), otherwise use local state
const [localSelectedId, setLocalSelectedId] = React.useState<string | null>(null);
const selectedOnlineSubtitleId = selectedExternalSubtitleId ?? localSelectedId;
const setSelectedOnlineSubtitleId = setLocalSelectedId;
const [activeTab, setActiveTab] = React.useState<'built-in' | 'addon' | 'appearance'>('built-in');
const isCompact = width < 360 || height < 640;

View file

@ -0,0 +1,232 @@
/**
* Track Selection Utilities
* Logic for auto-selecting audio and subtitle tracks based on user preferences
*/
import { AppSettings } from '../../../hooks/useSettings';
import { languageMap } from './playerUtils';
import { WyzieSubtitle } from './playerTypes';
interface Track {
id: number;
name: string;
language?: string;
}
/**
* Normalizes a language code or name to a standard 2-letter ISO code
*/
export const normalizeLanguageCode = (langInput?: string): string => {
if (!langInput) return '';
const normalized = langInput.toLowerCase().trim();
// If it's already a short code that we know, return it
if (languageMap[normalized]) {
// Convert 3-letter codes to 2-letter codes
const twoLetterCodes: { [key: string]: string } = {
'eng': 'en', 'spa': 'es', 'fre': 'fr', 'ger': 'de', 'ita': 'it',
'jpn': 'ja', 'kor': 'ko', 'chi': 'zh', 'rus': 'ru', 'por': 'pt',
'hin': 'hi', 'ara': 'ar', 'dut': 'nl', 'swe': 'sv', 'nor': 'no',
'fin': 'fi', 'dan': 'da', 'pol': 'pl', 'tur': 'tr', 'cze': 'cs',
'hun': 'hu', 'gre': 'el', 'tha': 'th', 'vie': 'vi'
};
return twoLetterCodes[normalized] || normalized;
}
// Check if it's a full language name
for (const [code, name] of Object.entries(languageMap)) {
if (name.toLowerCase() === normalized) {
// Return the 2-letter code
const twoLetterCodes: { [key: string]: string } = {
'eng': 'en', 'spa': 'es', 'fre': 'fr', 'ger': 'de', 'ita': 'it',
'jpn': 'ja', 'kor': 'ko', 'chi': 'zh', 'rus': 'ru', 'por': 'pt',
'hin': 'hi', 'ara': 'ar', 'dut': 'nl', 'swe': 'sv', 'nor': 'no',
'fin': 'fi', 'dan': 'da', 'pol': 'pl', 'tur': 'tr', 'cze': 'cs',
'hun': 'hu', 'gre': 'el', 'tha': 'th', 'vie': 'vi'
};
return twoLetterCodes[code] || code;
}
}
return normalized;
};
/**
* Check if a track matches the preferred language
*/
export const trackMatchesLanguage = (track: Track, preferredLang: string): boolean => {
const trackLang = normalizeLanguageCode(track.language);
const trackNameLower = (track.name || '').toLowerCase();
const prefLang = normalizeLanguageCode(preferredLang);
if (!prefLang) return false;
// Direct language code match
if (trackLang === prefLang) return true;
// Check if the track name contains the language
const langName = languageMap[prefLang] || languageMap[prefLang + 'g']; // handle 'en' -> 'eng' mapping
if (langName && trackNameLower.includes(langName.toLowerCase())) return true;
// Check for common language indicators in track name
const languagePatterns: { [key: string]: RegExp } = {
'en': /\b(english|eng|en)\b/i,
'es': /\b(spanish|spa|es|español|espanol)\b/i,
'fr': /\b(french|fre|fr|français|francais)\b/i,
'de': /\b(german|ger|de|deutsch)\b/i,
'it': /\b(italian|ita|it|italiano)\b/i,
'ja': /\b(japanese|jpn|ja|日本語)\b/i,
'ko': /\b(korean|kor|ko|한국어)\b/i,
'zh': /\b(chinese|chi|zh|中文)\b/i,
'ru': /\b(russian|rus|ru|русский)\b/i,
'pt': /\b(portuguese|por|pt|português)\b/i,
'hi': /\b(hindi|hin|hi|हिन्दी)\b/i,
'ar': /\b(arabic|ara|ar|العربية)\b/i,
};
const pattern = languagePatterns[prefLang];
if (pattern && pattern.test(trackNameLower)) return true;
return false;
};
/**
* Find the best matching audio track based on user preferences
* Returns the track ID to select, or null if no preference match found
*/
export const findBestAudioTrack = (
tracks: Track[],
preferredLanguage: string
): number | null => {
if (!tracks || tracks.length === 0) return null;
// Try to find a track matching the preferred language
const matchingTrack = tracks.find(track => trackMatchesLanguage(track, preferredLanguage));
if (matchingTrack) {
return matchingTrack.id;
}
// No match found - return first track as fallback (or null to use system default)
return null;
};
/**
* Find the best matching subtitle track based on user preferences
* Implements the priority: internal first external fallback first available
*
* @param internalTracks - Embedded subtitle tracks from the video
* @param externalSubtitles - Available external/addon subtitles
* @param settings - User's subtitle preferences
* @returns Object with selected track info
*/
export const findBestSubtitleTrack = (
internalTracks: Track[],
externalSubtitles: WyzieSubtitle[],
settings: {
preferredSubtitleLanguage: string;
subtitleSourcePreference: 'internal' | 'external' | 'any';
enableSubtitleAutoSelect: boolean;
}
): {
type: 'internal' | 'external' | 'none';
internalTrackId?: number;
externalSubtitle?: WyzieSubtitle;
} => {
// If auto-select is disabled, don't select anything
if (!settings.enableSubtitleAutoSelect) {
return { type: 'none' };
}
const preferredLang = settings.preferredSubtitleLanguage || 'en';
const sourcePreference = settings.subtitleSourcePreference || 'internal';
// Find matching internal track
const matchingInternalTrack = internalTracks.find(track =>
trackMatchesLanguage(track, preferredLang)
);
// Find matching external subtitle
const matchingExternalSub = externalSubtitles.find(sub => {
const subLang = normalizeLanguageCode(sub.language);
const prefLang = normalizeLanguageCode(preferredLang);
return subLang === prefLang ||
sub.language.toLowerCase().includes(preferredLang.toLowerCase()) ||
sub.display.toLowerCase().includes(languageMap[preferredLang]?.toLowerCase() || preferredLang);
});
// Apply source preference priority
if (sourcePreference === 'internal') {
// 1. Try internal track matching preferred language
if (matchingInternalTrack) {
return { type: 'internal', internalTrackId: matchingInternalTrack.id };
}
// 2. Fallback to external subtitle matching preferred language
if (matchingExternalSub) {
return { type: 'external', externalSubtitle: matchingExternalSub };
}
// 3. Fallback to first internal track if any available
if (internalTracks.length > 0) {
return { type: 'internal', internalTrackId: internalTracks[0].id };
}
// 4. Fallback to first external subtitle if any available
if (externalSubtitles.length > 0) {
return { type: 'external', externalSubtitle: externalSubtitles[0] };
}
} else if (sourcePreference === 'external') {
// 1. Try external subtitle matching preferred language
if (matchingExternalSub) {
return { type: 'external', externalSubtitle: matchingExternalSub };
}
// 2. Fallback to internal track matching preferred language
if (matchingInternalTrack) {
return { type: 'internal', internalTrackId: matchingInternalTrack.id };
}
// 3. Fallback to first external subtitle if any available
if (externalSubtitles.length > 0) {
return { type: 'external', externalSubtitle: externalSubtitles[0] };
}
// 4. Fallback to first internal track if any available
if (internalTracks.length > 0) {
return { type: 'internal', internalTrackId: internalTracks[0].id };
}
} else {
// 'any' - prefer matching language regardless of source, internal first
if (matchingInternalTrack) {
return { type: 'internal', internalTrackId: matchingInternalTrack.id };
}
if (matchingExternalSub) {
return { type: 'external', externalSubtitle: matchingExternalSub };
}
// Fallback to first available
if (internalTracks.length > 0) {
return { type: 'internal', internalTrackId: internalTracks[0].id };
}
if (externalSubtitles.length > 0) {
return { type: 'external', externalSubtitle: externalSubtitles[0] };
}
}
return { type: 'none' };
};
/**
* Find best audio track from available tracks
*/
export const autoSelectAudioTrack = (
tracks: Track[],
preferredLanguage: string
): number | null => {
if (!tracks || tracks.length === 0) return null;
// Try to find a track matching the preferred language
const matchingTrack = tracks.find(track => trackMatchesLanguage(track, preferredLanguage));
if (matchingTrack) {
return matchingTrack.id;
}
// Return null to let the player use its default
return null;
};

View file

@ -105,6 +105,11 @@ export interface AppSettings {
decoderMode: 'auto' | 'sw' | 'hw' | 'hw+'; // Decoder mode: auto (auto-copy), sw (software), hw (mediacodec-copy), hw+ (mediacodec)
gpuMode: 'gpu' | 'gpu-next'; // GPU rendering mode: gpu (standard) or gpu-next (advanced HDR/color)
showDiscover: boolean;
// Audio/Subtitle Language Preferences
preferredSubtitleLanguage: string; // Preferred language for subtitles (ISO 639-1 code, e.g., 'en', 'es', 'fr')
preferredAudioLanguage: string; // Preferred language for audio tracks (ISO 639-1 code)
subtitleSourcePreference: 'internal' | 'external' | 'any'; // Prefer internal (embedded), external (addon), or any
enableSubtitleAutoSelect: boolean; // Auto-select subtitles based on preferences
}
export const DEFAULT_SETTINGS: AppSettings = {
@ -183,6 +188,11 @@ export const DEFAULT_SETTINGS: AppSettings = {
decoderMode: 'auto', // Default to auto (best compatibility and performance)
gpuMode: 'gpu', // Default to gpu (gpu-next for advanced HDR)
showDiscover: true, // Show Discover section in SearchScreen
// Audio/Subtitle Language Preferences
preferredSubtitleLanguage: 'en', // Default to English subtitles
preferredAudioLanguage: 'en', // Default to English audio
subtitleSourcePreference: 'internal', // Prefer internal/embedded subtitles first
enableSubtitleAutoSelect: true, // Auto-select subtitles by default
};
const SETTINGS_STORAGE_KEY = 'app_settings';

View file

@ -1,5 +1,5 @@
import React, { useState, useCallback } from 'react';
import { View, StyleSheet, ScrollView, StatusBar, Platform } from 'react-native';
import { View, StyleSheet, ScrollView, StatusBar, Platform, Text, TouchableOpacity, Modal, FlatList } from 'react-native';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@ -9,6 +9,188 @@ import { RootStackParamList } from '../../navigation/AppNavigator';
import ScreenHeader from '../../components/common/ScreenHeader';
import { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents';
import { useRealtimeConfig } from '../../hooks/useRealtimeConfig';
import { MaterialIcons } from '@expo/vector-icons';
// Available languages for audio/subtitle selection
const AVAILABLE_LANGUAGES = [
{ code: 'en', name: 'English' },
{ code: 'es', name: 'Spanish' },
{ code: 'fr', name: 'French' },
{ code: 'de', name: 'German' },
{ code: 'it', name: 'Italian' },
{ code: 'ja', name: 'Japanese' },
{ code: 'ko', name: 'Korean' },
{ code: 'zh', name: 'Chinese' },
{ code: 'ru', name: 'Russian' },
{ code: 'pt', name: 'Portuguese' },
{ code: 'hi', name: 'Hindi' },
{ code: 'ar', name: 'Arabic' },
{ code: 'nl', name: 'Dutch' },
{ code: 'sv', name: 'Swedish' },
{ code: 'no', name: 'Norwegian' },
{ code: 'fi', name: 'Finnish' },
{ code: 'da', name: 'Danish' },
{ code: 'pl', name: 'Polish' },
{ code: 'tr', name: 'Turkish' },
{ code: 'cs', name: 'Czech' },
{ code: 'hu', name: 'Hungarian' },
{ code: 'el', name: 'Greek' },
{ code: 'th', name: 'Thai' },
{ code: 'vi', name: 'Vietnamese' },
{ code: 'id', name: 'Indonesian' },
{ code: 'ms', name: 'Malay' },
{ code: 'ta', name: 'Tamil' },
{ code: 'te', name: 'Telugu' },
{ code: 'bn', name: 'Bengali' },
{ code: 'uk', name: 'Ukrainian' },
{ code: 'he', name: 'Hebrew' },
{ code: 'fa', name: 'Persian' },
];
const SUBTITLE_SOURCE_OPTIONS = [
{ value: 'internal', label: 'Internal First', description: 'Prefer embedded subtitles, then external' },
{ value: 'external', label: 'External First', description: 'Prefer addon subtitles, then embedded' },
{ value: 'any', label: 'Any Available', description: 'Use first available subtitle track' },
];
interface LanguagePickerModalProps {
visible: boolean;
onClose: () => void;
selectedLanguage: string;
onSelectLanguage: (code: string) => void;
title: string;
}
const LanguagePickerModal: React.FC<LanguagePickerModalProps> = ({
visible,
onClose,
selectedLanguage,
onSelectLanguage,
title,
}) => {
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
const renderItem = ({ item }: { item: { code: string; name: string } }) => {
const isSelected = item.code === selectedLanguage;
return (
<TouchableOpacity
style={[
styles.languageItem,
isSelected && { backgroundColor: currentTheme.colors.primary + '20' }
]}
onPress={() => {
onSelectLanguage(item.code);
onClose();
}}
>
<Text style={[styles.languageName, { color: isSelected ? currentTheme.colors.primary : '#fff' }]}>
{item.name}
</Text>
<Text style={[styles.languageCode, { color: 'rgba(255,255,255,0.5)' }]}>
{item.code.toUpperCase()}
</Text>
{isSelected && (
<MaterialIcons name="check" size={20} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
);
};
return (
<Modal
visible={visible}
animationType="slide"
transparent={true}
onRequestClose={onClose}
>
<View style={styles.modalOverlay}>
<View style={[styles.modalContent, { backgroundColor: '#1a1a1a', paddingBottom: insets.bottom }]}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>{title}</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<MaterialIcons name="close" size={24} color="#fff" />
</TouchableOpacity>
</View>
<FlatList
data={AVAILABLE_LANGUAGES}
renderItem={renderItem}
keyExtractor={(item) => item.code}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.languageList}
/>
</View>
</View>
</Modal>
);
};
interface SubtitleSourceModalProps {
visible: boolean;
onClose: () => void;
selectedSource: string;
onSelectSource: (value: 'internal' | 'external' | 'any') => void;
}
const SubtitleSourceModal: React.FC<SubtitleSourceModalProps> = ({
visible,
onClose,
selectedSource,
onSelectSource,
}) => {
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
return (
<Modal
visible={visible}
animationType="slide"
transparent={true}
onRequestClose={onClose}
>
<View style={styles.modalOverlay}>
<View style={[styles.modalContent, { backgroundColor: '#1a1a1a', paddingBottom: insets.bottom, maxHeight: 400 }]}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Subtitle Source Priority</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<MaterialIcons name="close" size={24} color="#fff" />
</TouchableOpacity>
</View>
<View style={styles.languageList}>
{SUBTITLE_SOURCE_OPTIONS.map((option) => {
const isSelected = option.value === selectedSource;
return (
<TouchableOpacity
key={option.value}
style={[
styles.sourceItem,
isSelected && { backgroundColor: currentTheme.colors.primary + '20', borderColor: currentTheme.colors.primary }
]}
onPress={() => {
onSelectSource(option.value as 'internal' | 'external' | 'any');
onClose();
}}
>
<View style={styles.sourceItemContent}>
<Text style={[styles.sourceLabel, { color: isSelected ? currentTheme.colors.primary : '#fff' }]}>
{option.label}
</Text>
<Text style={styles.sourceDescription}>
{option.description}
</Text>
</View>
{isSelected && (
<MaterialIcons name="check" size={20} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
);
})}
</View>
</View>
</View>
</Modal>
);
};
const PlaybackSettingsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -17,6 +199,11 @@ const PlaybackSettingsScreen: React.FC = () => {
const insets = useSafeAreaInsets();
const config = useRealtimeConfig();
// Modal states
const [showAudioLanguageModal, setShowAudioLanguageModal] = useState(false);
const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false);
const [showSubtitleSourceModal, setShowSubtitleSourceModal] = useState(false);
const isItemVisible = (itemId: string) => {
if (!config?.items) return true;
const item = config.items[itemId];
@ -28,6 +215,16 @@ const PlaybackSettingsScreen: React.FC = () => {
return itemIds.some(id => isItemVisible(id));
};
const getLanguageName = (code: string) => {
const lang = AVAILABLE_LANGUAGES.find(l => l.code === code);
return lang ? lang.name : code.toUpperCase();
};
const getSourceLabel = (value: string) => {
const option = SUBTITLE_SOURCE_OPTIONS.find(o => o.value === value);
return option ? option.label : 'Internal First';
};
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
@ -56,6 +253,43 @@ const PlaybackSettingsScreen: React.FC = () => {
</SettingsCard>
)}
{/* Audio & Subtitle Preferences */}
<SettingsCard title="AUDIO & SUBTITLES">
<SettingItem
title="Preferred Audio Language"
description={getLanguageName(settings?.preferredAudioLanguage || 'en')}
icon="volume-up"
renderControl={() => <ChevronRight />}
onPress={() => setShowAudioLanguageModal(true)}
/>
<SettingItem
title="Preferred Subtitle Language"
description={getLanguageName(settings?.preferredSubtitleLanguage || 'en')}
icon="subtitles"
renderControl={() => <ChevronRight />}
onPress={() => setShowSubtitleLanguageModal(true)}
/>
<SettingItem
title="Subtitle Source Priority"
description={getSourceLabel(settings?.subtitleSourcePreference || 'internal')}
icon="source"
renderControl={() => <ChevronRight />}
onPress={() => setShowSubtitleSourceModal(true)}
/>
<SettingItem
title="Auto-Select Subtitles"
description="Automatically select subtitles matching your preferences"
icon="auto-fix"
renderControl={() => (
<CustomSwitch
value={settings?.enableSubtitleAutoSelect ?? true}
onValueChange={(value) => updateSetting('enableSubtitleAutoSelect', value)}
/>
)}
isLast
/>
</SettingsCard>
{hasVisibleItems(['show_trailers', 'enable_downloads']) && (
<SettingsCard title="MEDIA">
{isItemVisible('show_trailers') && (
@ -103,6 +337,28 @@ const PlaybackSettingsScreen: React.FC = () => {
</SettingsCard>
)}
</ScrollView>
{/* Language Picker Modals */}
<LanguagePickerModal
visible={showAudioLanguageModal}
onClose={() => setShowAudioLanguageModal(false)}
selectedLanguage={settings?.preferredAudioLanguage || 'en'}
onSelectLanguage={(code) => updateSetting('preferredAudioLanguage', code)}
title="Preferred Audio Language"
/>
<LanguagePickerModal
visible={showSubtitleLanguageModal}
onClose={() => setShowSubtitleLanguageModal(false)}
selectedLanguage={settings?.preferredSubtitleLanguage || 'en'}
onSelectLanguage={(code) => updateSetting('preferredSubtitleLanguage', code)}
title="Preferred Subtitle Language"
/>
<SubtitleSourceModal
visible={showSubtitleSourceModal}
onClose={() => setShowSubtitleSourceModal(false)}
selectedSource={settings?.subtitleSourcePreference || 'internal'}
onSelectSource={(value) => updateSetting('subtitleSourcePreference', value)}
/>
</View>
);
};
@ -117,6 +373,76 @@ const styles = StyleSheet.create({
scrollContent: {
paddingTop: 16,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.7)',
justifyContent: 'flex-end',
},
modalContent: {
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
maxHeight: '70%',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 20,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.1)',
},
modalTitle: {
color: '#fff',
fontSize: 18,
fontWeight: '700',
},
closeButton: {
padding: 4,
},
languageList: {
paddingHorizontal: 16,
paddingBottom: 16,
},
languageItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 12,
marginVertical: 2,
},
languageName: {
flex: 1,
fontSize: 16,
fontWeight: '500',
},
languageCode: {
fontSize: 12,
marginRight: 12,
},
sourceItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 16,
paddingHorizontal: 16,
borderRadius: 12,
marginVertical: 6,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
backgroundColor: 'rgba(255,255,255,0.05)',
},
sourceItemContent: {
flex: 1,
},
sourceLabel: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
sourceDescription: {
fontSize: 13,
color: 'rgba(255,255,255,0.5)',
},
});
export default PlaybackSettingsScreen;