diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 98f94444..3c5c0042 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -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([]); const [currentSubtitle, setCurrentSubtitle] = useState(''); + const [selectedExternalSubtitleId, setSelectedExternalSubtitleId] = useState(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} /> { 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} /> { const [currentFormattedSegments, setCurrentFormattedSegments] = useState([]); const [availableSubtitles, setAvailableSubtitles] = useState([]); const [useCustomSubtitles, setUseCustomSubtitles] = useState(false); + const [selectedExternalSubtitleId, setSelectedExternalSubtitleId] = useState(null); // Loading State const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false); @@ -91,6 +92,7 @@ export const useCustomSubtitles = () => { currentFormattedSegments, setCurrentFormattedSegments, availableSubtitles, setAvailableSubtitles, useCustomSubtitles, setUseCustomSubtitles, + selectedExternalSubtitleId, setSelectedExternalSubtitleId, isLoadingSubtitles, setIsLoadingSubtitles, isLoadingSubtitleList, setIsLoadingSubtitleList, subtitleSize, setSubtitleSize, diff --git a/src/components/player/modals/SubtitleModals.tsx b/src/components/player/modals/SubtitleModals.tsx index f7dc4d78..5ebe6a3e 100644 --- a/src/components/player/modals/SubtitleModals.tsx +++ b/src/components/player/modals/SubtitleModals.tsx @@ -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 = ({ 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(null); + // Use prop value if provided (for auto-selected subtitles), otherwise use local state + const [localSelectedId, setLocalSelectedId] = React.useState(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; diff --git a/src/components/player/utils/trackSelectionUtils.ts b/src/components/player/utils/trackSelectionUtils.ts new file mode 100644 index 00000000..125bd004 --- /dev/null +++ b/src/components/player/utils/trackSelectionUtils.ts @@ -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; +}; diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 0b89113f..0e2faad2 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -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'; diff --git a/src/screens/settings/PlaybackSettingsScreen.tsx b/src/screens/settings/PlaybackSettingsScreen.tsx index db02bc71..90694cf9 100644 --- a/src/screens/settings/PlaybackSettingsScreen.tsx +++ b/src/screens/settings/PlaybackSettingsScreen.tsx @@ -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 = ({ + 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 ( + { + onSelectLanguage(item.code); + onClose(); + }} + > + + {item.name} + + + {item.code.toUpperCase()} + + {isSelected && ( + + )} + + ); + }; + + return ( + + + + + {title} + + + + + item.code} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.languageList} + /> + + + + ); +}; + +interface SubtitleSourceModalProps { + visible: boolean; + onClose: () => void; + selectedSource: string; + onSelectSource: (value: 'internal' | 'external' | 'any') => void; +} + +const SubtitleSourceModal: React.FC = ({ + visible, + onClose, + selectedSource, + onSelectSource, +}) => { + const { currentTheme } = useTheme(); + const insets = useSafeAreaInsets(); + + return ( + + + + + Subtitle Source Priority + + + + + + {SUBTITLE_SOURCE_OPTIONS.map((option) => { + const isSelected = option.value === selectedSource; + return ( + { + onSelectSource(option.value as 'internal' | 'external' | 'any'); + onClose(); + }} + > + + + {option.label} + + + {option.description} + + + {isSelected && ( + + )} + + ); + })} + + + + + ); +}; const PlaybackSettingsScreen: React.FC = () => { const navigation = useNavigation>(); @@ -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 ( @@ -56,6 +253,43 @@ const PlaybackSettingsScreen: React.FC = () => { )} + {/* Audio & Subtitle Preferences */} + + } + onPress={() => setShowAudioLanguageModal(true)} + /> + } + onPress={() => setShowSubtitleLanguageModal(true)} + /> + } + onPress={() => setShowSubtitleSourceModal(true)} + /> + ( + updateSetting('enableSubtitleAutoSelect', value)} + /> + )} + isLast + /> + + {hasVisibleItems(['show_trailers', 'enable_downloads']) && ( {isItemVisible('show_trailers') && ( @@ -103,6 +337,28 @@ const PlaybackSettingsScreen: React.FC = () => { )} + + {/* Language Picker Modals */} + setShowAudioLanguageModal(false)} + selectedLanguage={settings?.preferredAudioLanguage || 'en'} + onSelectLanguage={(code) => updateSetting('preferredAudioLanguage', code)} + title="Preferred Audio Language" + /> + setShowSubtitleLanguageModal(false)} + selectedLanguage={settings?.preferredSubtitleLanguage || 'en'} + onSelectLanguage={(code) => updateSetting('preferredSubtitleLanguage', code)} + title="Preferred Subtitle Language" + /> + setShowSubtitleSourceModal(false)} + selectedSource={settings?.subtitleSourcePreference || 'internal'} + onSelectSource={(value) => updateSetting('subtitleSourcePreference', value)} + /> ); }; @@ -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;