diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 92101e4a..82d52009 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -149,8 +149,10 @@ const AndroidVideoPlayer: React.FC = () => { const [customSubtitleVersion, setCustomSubtitleVersion] = useState(0); const [subtitleSize, setSubtitleSize] = useState(DEFAULT_SUBTITLE_SIZE); const [subtitleBackground, setSubtitleBackground] = useState(true); + // iOS seeking helpers + const iosWasPausedDuringSeekRef = useRef(null); + const wasPlayingBeforeDragRef = useRef(false); // External subtitle customization - const [subtitleFontFamily, setSubtitleFontFamily] = useState(undefined); const [subtitleTextColor, setSubtitleTextColor] = useState('#FFFFFF'); const [subtitleBgOpacity, setSubtitleBgOpacity] = useState(0.7); const [subtitleTextShadow, setSubtitleTextShadow] = useState(true); @@ -161,6 +163,7 @@ const AndroidVideoPlayer: React.FC = () => { const [subtitleBottomOffset, setSubtitleBottomOffset] = useState(20); const [subtitleLetterSpacing, setSubtitleLetterSpacing] = useState(0); const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState(1.2); + const [subtitleOffsetSec, setSubtitleOffsetSec] = useState(0); const [useCustomSubtitles, setUseCustomSubtitles] = useState(false); const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false); const [availableSubtitles, setAvailableSubtitles] = useState([]); @@ -445,17 +448,23 @@ const AndroidVideoPlayer: React.FC = () => { isSeeking.current = true; setSeekTime(timeInSeconds); + if (Platform.OS === 'ios') { + iosWasPausedDuringSeekRef.current = paused; + if (!paused) setPaused(true); + } - // Clear seek state after seek with longer timeout + // Clear seek state handled in onSeek; keep a fallback timeout setTimeout(() => { - if (isMounted.current) { + if (isMounted.current && isSeeking.current) { setSeekTime(null); isSeeking.current = false; - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Seek completed to ${timeInSeconds.toFixed(2)}s`); + if (DEBUG_MODE) logger.log('[AndroidVideoPlayer] Seek fallback timeout cleared seeking state'); + if (Platform.OS === 'ios' && iosWasPausedDuringSeekRef.current === false) { + setPaused(false); + iosWasPausedDuringSeekRef.current = null; + } } - } - }, 500); + }, 1200); } else { if (DEBUG_MODE) { logger.error(`[AndroidVideoPlayer] Seek failed: videoRef=${!!videoRef.current}, duration=${duration}, seeking=${isSeeking.current}`); @@ -466,10 +475,40 @@ const AndroidVideoPlayer: React.FC = () => { // Handle seeking when seekTime changes useEffect(() => { if (seekTime !== null && videoRef.current && duration > 0) { - videoRef.current.seek(seekTime); + // Use tolerance on iOS for more reliable seeks + if (Platform.OS === 'ios') { + try { + (videoRef.current as any).seek(seekTime, 1); + } catch { + videoRef.current.seek(seekTime); + } + } else { + videoRef.current.seek(seekTime); + } } }, [seekTime, duration]); + const onSeek = (data: any) => { + if (DEBUG_MODE) logger.log('[AndroidVideoPlayer] onSeek', data); + if (isMounted.current) { + setSeekTime(null); + isSeeking.current = false; + // Resume playback on iOS if we paused for seeking + if (Platform.OS === 'ios') { + const shouldResume = wasPlayingBeforeDragRef.current || iosWasPausedDuringSeekRef.current === false; + if (shouldResume) { + logger.log('[AndroidVideoPlayer] onSeek: resuming after drag/seek (iOS)'); + setPaused(false); + } else { + logger.log('[AndroidVideoPlayer] onSeek: staying paused (iOS)'); + } + // Reset flags + wasPlayingBeforeDragRef.current = false; + iosWasPausedDuringSeekRef.current = null; + } + } + }; + // Slider callback functions for React Native Community Slider const handleSliderValueChange = (value: number) => { if (isDragging && duration > 0) { @@ -481,6 +520,12 @@ const AndroidVideoPlayer: React.FC = () => { const handleSlidingStart = () => { setIsDragging(true); + // On iOS, pause during drag for more reliable seeks + if (Platform.OS === 'ios') { + wasPlayingBeforeDragRef.current = !paused; + if (!paused) setPaused(true); + logger.log('[AndroidVideoPlayer] handleSlidingStart: pausing for iOS drag'); + } }; const handleSlidingComplete = (value: number) => { @@ -891,7 +936,7 @@ const AndroidVideoPlayer: React.FC = () => { } }; - const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = false) => { + const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = true) => { const targetImdbId = imdbIdParam || imdbId; if (!targetImdbId) { logger.error('[AndroidVideoPlayer] No IMDb ID available for subtitle search'); @@ -926,7 +971,19 @@ const AndroidVideoPlayer: React.FC = () => { } return acc; }, [] as WyzieSubtitle[]); - uniqueSubtitles.sort((a, b) => a.display.localeCompare(b.display)); + // Sort with English languages first, then alphabetical + const isEnglish = (s: WyzieSubtitle) => { + const lang = (s.language || '').toLowerCase(); + const disp = (s.display || '').toLowerCase(); + return lang === 'en' || lang === 'eng' || /^en([-_]|$)/.test(lang) || disp.includes('english'); + }; + uniqueSubtitles.sort((a, b) => { + const aIsEn = isEnglish(a); + const bIsEn = isEnglish(b); + if (aIsEn && !bIsEn) return -1; + if (!aIsEn && bIsEn) return 1; + return (a.display || '').localeCompare(b.display || ''); + }); setAvailableSubtitles(uniqueSubtitles); if (autoSelectEnglish) { const englishSubtitle = uniqueSubtitles.find(sub => @@ -940,6 +997,7 @@ const AndroidVideoPlayer: React.FC = () => { } } if (!autoSelectEnglish) { + // If no English found and not auto-selecting, still open the modal setShowSubtitleLanguageModal(true); } } catch (error) { @@ -1017,7 +1075,8 @@ const AndroidVideoPlayer: React.FC = () => { // Immediately set current subtitle based on currentTime to avoid waiting for next onProgress try { - const cueNow = parsedCues.find(cue => currentTime >= cue.start && currentTime <= cue.end); + const adjustedTime = currentTime + (subtitleOffsetSec || 0); + const cueNow = parsedCues.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end); const textNow = cueNow ? cueNow.text : ''; setCurrentSubtitle(textNow); logger.log('[AndroidVideoPlayer] currentSubtitle set immediately after apply (iOS)'); @@ -1025,36 +1084,7 @@ const AndroidVideoPlayer: React.FC = () => { logger.error('[AndroidVideoPlayer] Error setting immediate subtitle', e); } - // Micro-seek nudge to force AVPlayer to refresh rendering - try { - if (videoRef.current && duration > 0) { - const wasPaused = paused; - const original = currentTime; - const forward = Math.min(original + 0.05, Math.max(duration - 0.1, 0)); - logger.log('[AndroidVideoPlayer] Performing micro-seek nudge (iOS)', { original, forward }); - if (wasPaused) { - setPaused(false); - } - // Give state a moment to apply before seeking - setTimeout(() => { - try { - videoRef.current?.seek(forward); - setTimeout(() => { - videoRef.current?.seek(original); - if (wasPaused) { - setPaused(true); - } - logger.log('[AndroidVideoPlayer] Micro-seek nudge complete (iOS)'); - }, 150); - } catch (e) { - logger.warn('[AndroidVideoPlayer] Inner micro-seek failed (iOS)', e); - if (wasPaused) setPaused(true); - } - }, 50); - } - } catch(e) { - logger.warn('[AndroidVideoPlayer] Outer micro-seek failed (iOS)', e); - } + // Removed micro-seek nudge on iOS } else { // Android works immediately setCustomSubtitles(parsedCues); @@ -1066,7 +1096,8 @@ const AndroidVideoPlayer: React.FC = () => { setIsLoadingSubtitles(false); logger.log('[AndroidVideoPlayer] (Android) isLoadingSubtitles -> false'); try { - const cueNow = parsedCues.find(cue => currentTime >= cue.start && currentTime <= cue.end); + const adjustedTime = currentTime + (subtitleOffsetSec || 0); + const cueNow = parsedCues.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end); const textNow = cueNow ? cueNow.text : ''; setCurrentSubtitle(textNow); logger.log('[AndroidVideoPlayer] currentSubtitle set immediately after apply (Android)'); @@ -1117,17 +1148,75 @@ const AndroidVideoPlayer: React.FC = () => { } return; } + const adjustedTime = currentTime + (subtitleOffsetSec || 0); const currentCue = customSubtitles.find(cue => - currentTime >= cue.start && currentTime <= cue.end + adjustedTime >= cue.start && adjustedTime <= cue.end ); const newSubtitle = currentCue ? currentCue.text : ''; setCurrentSubtitle(newSubtitle); - }, [currentTime, customSubtitles, useCustomSubtitles]); + }, [currentTime, customSubtitles, useCustomSubtitles, subtitleOffsetSec]); useEffect(() => { loadSubtitleSize(); }, []); + // Load global subtitle settings + useEffect(() => { + (async () => { + try { + const saved = await storageService.getSubtitleSettings(); + if (saved) { + if (typeof saved.subtitleSize === 'number') setSubtitleSize(saved.subtitleSize); + if (typeof saved.subtitleBackground === 'boolean') setSubtitleBackground(saved.subtitleBackground); + if (typeof saved.subtitleTextColor === 'string') setSubtitleTextColor(saved.subtitleTextColor); + if (typeof saved.subtitleBgOpacity === 'number') setSubtitleBgOpacity(saved.subtitleBgOpacity); + if (typeof saved.subtitleTextShadow === 'boolean') setSubtitleTextShadow(saved.subtitleTextShadow); + if (typeof saved.subtitleOutline === 'boolean') setSubtitleOutline(saved.subtitleOutline); + if (typeof saved.subtitleOutlineColor === 'string') setSubtitleOutlineColor(saved.subtitleOutlineColor); + if (typeof saved.subtitleOutlineWidth === 'number') setSubtitleOutlineWidth(saved.subtitleOutlineWidth); + if (typeof saved.subtitleAlign === 'string') setSubtitleAlign(saved.subtitleAlign as 'center' | 'left' | 'right'); + if (typeof saved.subtitleBottomOffset === 'number') setSubtitleBottomOffset(saved.subtitleBottomOffset); + if (typeof saved.subtitleLetterSpacing === 'number') setSubtitleLetterSpacing(saved.subtitleLetterSpacing); + if (typeof saved.subtitleLineHeightMultiplier === 'number') setSubtitleLineHeightMultiplier(saved.subtitleLineHeightMultiplier); + if (typeof saved.subtitleOffsetSec === 'number') setSubtitleOffsetSec(saved.subtitleOffsetSec); + } + } catch {} + })(); + }, []); + + // Persist global subtitle settings on change + useEffect(() => { + storageService.saveSubtitleSettings({ + subtitleSize, + subtitleBackground, + subtitleTextColor, + subtitleBgOpacity, + subtitleTextShadow, + subtitleOutline, + subtitleOutlineColor, + subtitleOutlineWidth, + subtitleAlign, + subtitleBottomOffset, + subtitleLetterSpacing, + subtitleLineHeightMultiplier, + subtitleOffsetSec, + }); + }, [ + subtitleSize, + subtitleBackground, + subtitleTextColor, + subtitleBgOpacity, + subtitleTextShadow, + subtitleOutline, + subtitleOutlineColor, + subtitleOutlineWidth, + subtitleAlign, + subtitleBottomOffset, + subtitleLetterSpacing, + subtitleLineHeightMultiplier, + subtitleOffsetSec, + ]); + const increaseSubtitleSize = () => { const newSize = Math.min(subtitleSize + 2, 32); saveSubtitleSize(newSize); @@ -1396,6 +1485,7 @@ const AndroidVideoPlayer: React.FC = () => { logger.log('[AndroidVideoPlayer] onLoad fired', { duration: e?.duration }); onLoad(e); }} + onSeek={onSeek} onEnd={onEnd} onError={(err) => { logger.error('[AndroidVideoPlayer] onError', err); @@ -1462,7 +1552,6 @@ const AndroidVideoPlayer: React.FC = () => { subtitleSize={subtitleSize} subtitleBackground={subtitleBackground} zoomScale={zoomScale} - fontFamily={subtitleFontFamily} textColor={subtitleTextColor} backgroundOpacity={subtitleBgOpacity} textShadow={subtitleTextShadow} @@ -1515,8 +1604,6 @@ const AndroidVideoPlayer: React.FC = () => { increaseSubtitleSize={increaseSubtitleSize} decreaseSubtitleSize={decreaseSubtitleSize} toggleSubtitleBackground={toggleSubtitleBackground} - subtitleFontFamily={subtitleFontFamily} - setSubtitleFontFamily={setSubtitleFontFamily} subtitleTextColor={subtitleTextColor} setSubtitleTextColor={setSubtitleTextColor} subtitleBgOpacity={subtitleBgOpacity} @@ -1537,6 +1624,8 @@ const AndroidVideoPlayer: React.FC = () => { setSubtitleLetterSpacing={setSubtitleLetterSpacing} subtitleLineHeightMultiplier={subtitleLineHeightMultiplier} setSubtitleLineHeightMultiplier={setSubtitleLineHeightMultiplier} + subtitleOffsetSec={subtitleOffsetSec} + setSubtitleOffsetSec={setSubtitleOffsetSec} /> { const route = useRoute>(); - const { streamProvider, uri, headers } = route.params; + const { streamProvider, uri, headers, forceVlc } = route.params as any; // Check if the stream is from Xprime const isXprimeStream = streamProvider === 'xprime' || streamProvider === 'Xprime'; // Check if the file format is MKV - const isMkvFile = uri && (uri.toLowerCase().includes('.mkv') || uri.toLowerCase().includes('mkv')); + const lowerUri = (uri || '').toLowerCase(); + const contentType = (headers && (headers['Content-Type'] || headers['content-type'])) || ''; + const isMkvByHeader = typeof contentType === 'string' && contentType.includes('matroska'); + const isMkvByPath = lowerUri.includes('.mkv') || /[?&]ext=mkv\b/.test(lowerUri) || /format=mkv\b/.test(lowerUri) || /container=mkv\b/.test(lowerUri); + const isMkvFile = Boolean(isMkvByHeader || isMkvByPath); // Use AndroidVideoPlayer for: // - Android devices // - Xprime streams on any platform // - Non-MKV files on iOS - if (Platform.OS === 'android' || isXprimeStream || (Platform.OS === 'ios' && !isMkvFile)) { + if (Platform.OS === 'android' || isXprimeStream || (Platform.OS === 'ios' && !isMkvFile && !forceVlc)) { return ; } @@ -161,7 +165,6 @@ const VideoPlayer: React.FC = () => { const [subtitleSize, setSubtitleSize] = useState(DEFAULT_SUBTITLE_SIZE); const [subtitleBackground, setSubtitleBackground] = useState(true); // External subtitle customization - const [subtitleFontFamily, setSubtitleFontFamily] = useState(undefined); const [subtitleTextColor, setSubtitleTextColor] = useState('#FFFFFF'); const [subtitleBgOpacity, setSubtitleBgOpacity] = useState(0.7); const [subtitleTextShadow, setSubtitleTextShadow] = useState(true); @@ -172,6 +175,7 @@ const VideoPlayer: React.FC = () => { const [subtitleBottomOffset, setSubtitleBottomOffset] = useState(20); const [subtitleLetterSpacing, setSubtitleLetterSpacing] = useState(0); const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState(1.2); + const [subtitleOffsetSec, setSubtitleOffsetSec] = useState(0); const [useCustomSubtitles, setUseCustomSubtitles] = useState(false); const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false); const [availableSubtitles, setAvailableSubtitles] = useState([]); @@ -907,7 +911,7 @@ const VideoPlayer: React.FC = () => { } }; - const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = false) => { + const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = true) => { const targetImdbId = imdbIdParam || imdbId; if (!targetImdbId) { logger.error('[VideoPlayer] No IMDb ID available for subtitle search'); @@ -942,7 +946,19 @@ const VideoPlayer: React.FC = () => { } return acc; }, [] as WyzieSubtitle[]); - uniqueSubtitles.sort((a, b) => a.display.localeCompare(b.display)); + // Sort with English languages first, then alphabetical + const isEnglish = (s: WyzieSubtitle) => { + const lang = (s.language || '').toLowerCase(); + const disp = (s.display || '').toLowerCase(); + return lang === 'en' || lang === 'eng' || /^en([-_]|$)/.test(lang) || disp.includes('english'); + }; + uniqueSubtitles.sort((a, b) => { + const aIsEn = isEnglish(a); + const bIsEn = isEnglish(b); + if (aIsEn && !bIsEn) return -1; + if (!aIsEn && bIsEn) return 1; + return (a.display || '').localeCompare(b.display || ''); + }); setAvailableSubtitles(uniqueSubtitles); if (autoSelectEnglish) { const englishSubtitle = uniqueSubtitles.find(sub => @@ -956,6 +972,7 @@ const VideoPlayer: React.FC = () => { } } if (!autoSelectEnglish) { + // If no English found and not auto-selecting, still open the modal setShowSubtitleLanguageModal(true); } } catch (error) { @@ -1023,7 +1040,8 @@ const VideoPlayer: React.FC = () => { // Immediately set current subtitle text try { - const cueNow = parsedCues.find(cue => currentTime >= cue.start && currentTime <= cue.end); + const adjustedTime = currentTime + (subtitleOffsetSec || 0); + const cueNow = parsedCues.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end); const textNow = cueNow ? cueNow.text : ''; setCurrentSubtitle(textNow); logger.log('[VideoPlayer] currentSubtitle set immediately after apply'); @@ -1031,33 +1049,7 @@ const VideoPlayer: React.FC = () => { logger.error('[VideoPlayer] Error setting immediate subtitle', e); } - // VLC micro-seek nudge - try { - if (vlcRef.current && duration > 0) { - const wasPaused = paused; - const original = currentTime; - const forward = Math.min(original + 0.05, Math.max(duration - 0.1, 0)); - logger.log('[VideoPlayer] Performing micro-seek nudge', { original, forward }); - if (wasPaused) setPaused(false); - setTimeout(() => { - try { - // @ts-ignore - VLCPlayer seek method - vlcRef.current?.seek(forward); - setTimeout(() => { - // @ts-ignore - vlcRef.current?.seek(original); - if (wasPaused) setPaused(true); - logger.log('[VideoPlayer] Micro-seek nudge complete'); - }, 150); - } catch (e) { - logger.warn('[VideoPlayer] Micro-seek nudge failed', e); - if (wasPaused) setPaused(true); - } - }, 50); - } - } catch(e) { - logger.warn('[VideoPlayer] Outer micro-seek failed', e); - } + // Removed micro-seek nudge } catch (error) { logger.error('[VideoPlayer] Error loading Wyzie subtitle:', error); setIsLoadingSubtitles(false); @@ -1096,12 +1088,70 @@ const VideoPlayer: React.FC = () => { } return; } + const adjustedTime = currentTime + (subtitleOffsetSec || 0); const currentCue = customSubtitles.find(cue => - currentTime >= cue.start && currentTime <= cue.end + adjustedTime >= cue.start && adjustedTime <= cue.end ); const newSubtitle = currentCue ? currentCue.text : ''; setCurrentSubtitle(newSubtitle); - }, [currentTime, customSubtitles, useCustomSubtitles]); + }, [currentTime, customSubtitles, useCustomSubtitles, subtitleOffsetSec]); + + // Load global subtitle settings + useEffect(() => { + (async () => { + try { + const saved = await storageService.getSubtitleSettings(); + if (saved) { + if (typeof saved.subtitleSize === 'number') setSubtitleSize(saved.subtitleSize); + if (typeof saved.subtitleBackground === 'boolean') setSubtitleBackground(saved.subtitleBackground); + if (typeof saved.subtitleTextColor === 'string') setSubtitleTextColor(saved.subtitleTextColor); + if (typeof saved.subtitleBgOpacity === 'number') setSubtitleBgOpacity(saved.subtitleBgOpacity); + if (typeof saved.subtitleTextShadow === 'boolean') setSubtitleTextShadow(saved.subtitleTextShadow); + if (typeof saved.subtitleOutline === 'boolean') setSubtitleOutline(saved.subtitleOutline); + if (typeof saved.subtitleOutlineColor === 'string') setSubtitleOutlineColor(saved.subtitleOutlineColor); + if (typeof saved.subtitleOutlineWidth === 'number') setSubtitleOutlineWidth(saved.subtitleOutlineWidth); + if (typeof saved.subtitleAlign === 'string') setSubtitleAlign(saved.subtitleAlign as 'center' | 'left' | 'right'); + if (typeof saved.subtitleBottomOffset === 'number') setSubtitleBottomOffset(saved.subtitleBottomOffset); + if (typeof saved.subtitleLetterSpacing === 'number') setSubtitleLetterSpacing(saved.subtitleLetterSpacing); + if (typeof saved.subtitleLineHeightMultiplier === 'number') setSubtitleLineHeightMultiplier(saved.subtitleLineHeightMultiplier); + if (typeof saved.subtitleOffsetSec === 'number') setSubtitleOffsetSec(saved.subtitleOffsetSec); + } + } catch {} + })(); + }, []); + + // Persist global subtitle settings on change + useEffect(() => { + storageService.saveSubtitleSettings({ + subtitleSize, + subtitleBackground, + subtitleTextColor, + subtitleBgOpacity, + subtitleTextShadow, + subtitleOutline, + subtitleOutlineColor, + subtitleOutlineWidth, + subtitleAlign, + subtitleBottomOffset, + subtitleLetterSpacing, + subtitleLineHeightMultiplier, + subtitleOffsetSec, + }); + }, [ + subtitleSize, + subtitleBackground, + subtitleTextColor, + subtitleBgOpacity, + subtitleTextShadow, + subtitleOutline, + subtitleOutlineColor, + subtitleOutlineWidth, + subtitleAlign, + subtitleBottomOffset, + subtitleLetterSpacing, + subtitleLineHeightMultiplier, + subtitleOffsetSec, + ]); useEffect(() => { loadSubtitleSize(); @@ -1434,7 +1484,6 @@ const VideoPlayer: React.FC = () => { subtitleSize={subtitleSize} subtitleBackground={subtitleBackground} zoomScale={zoomScale} - fontFamily={subtitleFontFamily} textColor={subtitleTextColor} backgroundOpacity={subtitleBgOpacity} textShadow={subtitleTextShadow} @@ -1487,8 +1536,6 @@ const VideoPlayer: React.FC = () => { increaseSubtitleSize={increaseSubtitleSize} decreaseSubtitleSize={decreaseSubtitleSize} toggleSubtitleBackground={toggleSubtitleBackground} - subtitleFontFamily={subtitleFontFamily} - setSubtitleFontFamily={setSubtitleFontFamily} subtitleTextColor={subtitleTextColor} setSubtitleTextColor={setSubtitleTextColor} subtitleBgOpacity={subtitleBgOpacity} @@ -1509,6 +1556,8 @@ const VideoPlayer: React.FC = () => { setSubtitleLetterSpacing={setSubtitleLetterSpacing} subtitleLineHeightMultiplier={subtitleLineHeightMultiplier} setSubtitleLineHeightMultiplier={setSubtitleLineHeightMultiplier} + subtitleOffsetSec={subtitleOffsetSec} + setSubtitleOffsetSec={setSubtitleOffsetSec} /> void; toggleSubtitleBackground: () => void; // Customization props - subtitleFontFamily?: string; - setSubtitleFontFamily: (f?: string) => void; subtitleTextColor: string; setSubtitleTextColor: (c: string) => void; subtitleBgOpacity: number; @@ -54,6 +52,8 @@ interface SubtitleModalsProps { setSubtitleLetterSpacing: (n: number) => void; subtitleLineHeightMultiplier: number; setSubtitleLineHeightMultiplier: (n: number) => void; + subtitleOffsetSec: number; + setSubtitleOffsetSec: (n: number) => void; } const { width, height } = Dimensions.get('window'); @@ -79,8 +79,6 @@ export const SubtitleModals: React.FC = ({ increaseSubtitleSize, decreaseSubtitleSize, toggleSubtitleBackground, - subtitleFontFamily, - setSubtitleFontFamily, subtitleTextColor, setSubtitleTextColor, subtitleBgOpacity, @@ -101,12 +99,23 @@ export const SubtitleModals: React.FC = ({ setSubtitleLetterSpacing, subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier, + subtitleOffsetSec, + setSubtitleOffsetSec, }) => { // Track which specific online subtitle is currently loaded const [selectedOnlineSubtitleId, setSelectedOnlineSubtitleId] = React.useState(null); // Track which online subtitle is currently loading to show spinner per-item const [loadingSubtitleId, setLoadingSubtitleId] = React.useState(null); - + // Active tab for better organization + const [activeTab, setActiveTab] = React.useState<'built-in' | 'online' | 'appearance'>(useCustomSubtitles ? 'online' : 'built-in'); + // Responsive tuning + const isCompact = width < 360 || height < 640; + const sectionPad = isCompact ? 12 : 16; + const chipPadH = isCompact ? 8 : 12; + const chipPadV = isCompact ? 6 : 8; + const controlBtn = { size: isCompact ? 28 : 32, radius: isCompact ? 14 : 16 }; + const previewHeight = isCompact ? 90 : 120; + React.useEffect(() => { if (showSubtitleModal && !isLoadingSubtitleList && availableSubtitles.length === 0) { fetchAvailableSubtitles(); @@ -127,7 +136,10 @@ export const SubtitleModals: React.FC = ({ } }, [isLoadingSubtitles]); - // Only OpenSubtitles are provided now; render as a single list + // Keep tab in sync with current usage + React.useEffect(() => { + setActiveTab(useCustomSubtitles ? 'online' : 'built-in'); + }, [useCustomSubtitles]); const handleClose = () => { setShowSubtitleModal(false); @@ -194,17 +206,18 @@ export const SubtitleModals: React.FC = ({ justifyContent: 'space-between', paddingHorizontal: 20, paddingTop: 60, - paddingBottom: 20, + paddingBottom: 12, borderBottomWidth: 1, borderBottomColor: 'rgba(255, 255, 255, 0.08)', }}> - - Subtitles - + + Subtitles + + + {useCustomSubtitles ? 'Online in use' : 'Built‑in in use'} + + + = ({ + {/* Segmented Tabs */} + + {([ + { key: 'built-in', label: 'Built‑in' }, + { key: 'online', label: 'Online' }, + { key: 'appearance', label: 'Appearance' }, + ] as const).map(tab => ( + setActiveTab(tab.key)} + style={{ + paddingHorizontal: chipPadH, + paddingVertical: chipPadV, + borderRadius: 16, + backgroundColor: activeTab === tab.key ? 'rgba(255,255,255,0.15)' : 'rgba(255,255,255,0.06)', + borderWidth: 1, + borderColor: activeTab === tab.key ? 'rgba(255,255,255,0.3)' : 'rgba(255,255,255,0.1)' + }} + > + {tab.label} + + ))} + + - {/* Font Size Section - Only show for custom subtitles */} - {useCustomSubtitles && ( - - - Font Size - - - - - - - - - {subtitleSize} - - - - - - - - )} - - {/* Background Toggle Section - Only show for custom subtitles */} - {useCustomSubtitles && ( - - - Background - - - - - Show Background - - - - - - - - )} - - {/* Customization Section - Only for custom subtitles */} - {useCustomSubtitles && ( - - Appearance - - - Text Color - - {['#FFFFFF', '#FFD700', '#00E5FF', '#FF5C5C', '#00FF88'].map(c => ( - setSubtitleTextColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 1, borderColor: 'rgba(255,255,255,0.3)' }} /> - ))} - - - - - Align - - {(['left','center','right'] as const).map(a => ( - setSubtitleAlign(a)} style={{ paddingHorizontal: 10, paddingVertical: 6, borderRadius: 6, backgroundColor: subtitleAlign === a ? 'rgba(255,255,255,0.2)' : 'transparent', borderWidth: 1, borderColor: 'rgba(255,255,255,0.2)' }}> - {a} - - ))} - - - - - Bottom Offset - - setSubtitleBottomOffset(Math.max(0, subtitleBottomOffset - 5))} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}> - - - {subtitleBottomOffset} - setSubtitleBottomOffset(subtitleBottomOffset + 5)} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}> - - - - - - - Background Opacity - - setSubtitleBgOpacity(Math.max(0, +(subtitleBgOpacity - 0.1).toFixed(1)))} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}> - - - {subtitleBgOpacity.toFixed(1)} - setSubtitleBgOpacity(Math.min(1, +(subtitleBgOpacity + 0.1).toFixed(1)))} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}> - - - - - - - Text Shadow - setSubtitleTextShadow(!subtitleTextShadow)} style={{ paddingHorizontal: 10, paddingVertical: 6, borderRadius: 6, backgroundColor: subtitleTextShadow ? 'rgba(255,255,255,0.2)' : 'transparent', borderWidth: 1, borderColor: 'rgba(255,255,255,0.2)' }}> - {subtitleTextShadow ? 'On' : 'Off'} - - - - - Outline - - setSubtitleOutline(!subtitleOutline)} style={{ paddingHorizontal: 10, paddingVertical: 6, borderRadius: 6, backgroundColor: subtitleOutline ? 'rgba(255,255,255,0.2)' : 'transparent', borderWidth: 1, borderColor: 'rgba(255,255,255,0.2)' }}> - {subtitleOutline ? 'On' : 'Off'} - - {['#000000', '#FFFFFF', '#00E5FF', '#FF5C5C'].map(c => ( - setSubtitleOutlineColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 1, borderColor: 'rgba(255,255,255,0.3)' }} /> - ))} - - - - - Outline Width - - setSubtitleOutlineWidth(Math.max(0, subtitleOutlineWidth - 1))} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}> - - - {subtitleOutlineWidth} - setSubtitleOutlineWidth(subtitleOutlineWidth + 1)} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}> - - - - - - - Letter Spacing - - setSubtitleLetterSpacing(Math.max(0, +(subtitleLetterSpacing - 0.5).toFixed(1)))} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}> - - - {subtitleLetterSpacing.toFixed(1)} - setSubtitleLetterSpacing(+(subtitleLetterSpacing + 0.5).toFixed(1))} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}> - - - - - - - Line Height - - setSubtitleLineHeightMultiplier(Math.max(1, +(subtitleLineHeightMultiplier - 0.1).toFixed(1)))} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}> - - - {subtitleLineHeightMultiplier.toFixed(1)} - setSubtitleLineHeightMultiplier(+(subtitleLineHeightMultiplier + 0.1).toFixed(1))} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}> - - - - - - - )} - - {/* Built-in Subtitles */} - {vlcTextTracks.length > 0 && ( + {activeTab === 'built-in' && ( = ({ style={{ backgroundColor: isSelected ? 'rgba(59, 130, 246, 0.15)' : 'rgba(255, 255, 255, 0.05)', borderRadius: 16, - padding: 16, + padding: sectionPad, borderWidth: 1, borderColor: isSelected ? 'rgba(59, 130, 246, 0.3)' : 'rgba(255, 255, 255, 0.1)', }} @@ -488,7 +298,7 @@ export const SubtitleModals: React.FC = ({ @@ -505,7 +315,7 @@ export const SubtitleModals: React.FC = ({ )} - {/* Online Subtitles */} + {activeTab === 'online' && ( = ({ }}> = ({ style={{ backgroundColor: 'rgba(34, 197, 94, 0.15)', borderRadius: 12, - paddingHorizontal: 12, - paddingVertical: 6, + paddingHorizontal: chipPadH, + paddingVertical: chipPadV-2, flexDirection: 'row', alignItems: 'center', }} @@ -541,7 +351,7 @@ export const SubtitleModals: React.FC = ({ )} @@ -555,7 +365,7 @@ export const SubtitleModals: React.FC = ({ style={{ backgroundColor: 'rgba(255, 255, 255, 0.05)', borderRadius: 16, - padding: 20, + padding: isCompact ? 14 : 20, alignItems: 'center', borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.1)', @@ -567,7 +377,7 @@ export const SubtitleModals: React.FC = ({ @@ -578,13 +388,13 @@ export const SubtitleModals: React.FC = ({ Searching... @@ -600,7 +410,7 @@ export const SubtitleModals: React.FC = ({ style={{ backgroundColor: isSelected ? 'rgba(34, 197, 94, 0.15)' : 'rgba(255, 255, 255, 0.05)', borderRadius: 16, - padding: 16, + padding: sectionPad, borderWidth: 1, borderColor: isSelected ? 'rgba(34, 197, 94, 0.3)' : 'rgba(255, 255, 255, 0.1)', }} @@ -633,60 +443,338 @@ export const SubtitleModals: React.FC = ({ )} + )} - {/* Turn Off Subtitles */} - - - Options - - - { - selectTextTrack(-1); - setSelectedOnlineSubtitleId(null); - }} - activeOpacity={0.7} - > - - - - - Turn Off Subtitles - + {activeTab === 'appearance' && ( + + {/* Live Preview */} + + + + Preview + + + + + + The quick brown fox jumps over the lazy dog. + + + - {selectedTextTrack === -1 && !useCustomSubtitles && ( - - )} - - + + {/* Quick Presets */} + + + + Quick Presets + + + { + setSubtitleTextColor('#FFFFFF'); + setSubtitleBgOpacity(0.7); + setSubtitleTextShadow(true); + setSubtitleOutline(false); + setSubtitleOutlineColor('#000000'); + setSubtitleOutlineWidth(2); + setSubtitleAlign('center'); + setSubtitleBottomOffset(20); + setSubtitleLetterSpacing(0); + setSubtitleLineHeightMultiplier(1.2); + }} + style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }} + > + Default + + { + setSubtitleTextColor('#FFD700'); + setSubtitleOutline(true); + setSubtitleOutlineColor('#000000'); + setSubtitleOutlineWidth(2); + setSubtitleBgOpacity(0.3); + setSubtitleTextShadow(false); + }} + style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(255,215,0,0.12)', borderWidth: 1, borderColor: 'rgba(255,215,0,0.35)' }} + > + Yellow + + { + setSubtitleTextColor('#FFFFFF'); + setSubtitleOutline(true); + setSubtitleOutlineColor('#000000'); + setSubtitleOutlineWidth(3); + setSubtitleBgOpacity(0.0); + setSubtitleTextShadow(false); + setSubtitleLetterSpacing(0.5); + }} + style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(34,197,94,0.12)', borderWidth: 1, borderColor: 'rgba(34,197,94,0.35)' }} + > + High Contrast + + { + setSubtitleTextColor('#FFFFFF'); + setSubtitleBgOpacity(0.6); + setSubtitleTextShadow(true); + setSubtitleOutline(false); + setSubtitleAlign('center'); + setSubtitleLineHeightMultiplier(1.3); + }} + style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(59,130,246,0.12)', borderWidth: 1, borderColor: 'rgba(59,130,246,0.35)' }} + > + Large + + + + + {/* Core controls */} + + + + Core + + {/* Font Size */} + + + + Font Size + + + + + + + {subtitleSize} + + + + + + + {/* Background toggle */} + + + + Show Background + + + + + + + + {/* Advanced controls */} + + + + Advanced + + + {/* Text Color */} + + + + Text Color + + + {['#FFFFFF', '#FFD700', '#00E5FF', '#FF5C5C', '#00FF88', '#9b59b6', '#f97316'].map(c => ( + setSubtitleTextColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleTextColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} /> + ))} + + + + {/* Align */} + + Align + + {([ + { key: 'left', icon: 'format-align-left' }, + { key: 'center', icon: 'format-align-center' }, + { key: 'right', icon: 'format-align-right' }, + ] as const).map(a => ( + setSubtitleAlign(a.key)} + style={{ paddingHorizontal: isCompact ? 8 : 10, paddingVertical: isCompact ? 4 : 6, borderRadius: 8, backgroundColor: subtitleAlign === a.key ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }} + > + + + ))} + + + + {/* Bottom Offset */} + + Bottom Offset + + setSubtitleBottomOffset(Math.max(0, subtitleBottomOffset - 5))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + + + + {subtitleBottomOffset} + + setSubtitleBottomOffset(subtitleBottomOffset + 5)} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + + + + + + {/* Background Opacity */} + + Background Opacity + + setSubtitleBgOpacity(Math.max(0, +(subtitleBgOpacity - 0.1).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + + + + {subtitleBgOpacity.toFixed(1)} + + setSubtitleBgOpacity(Math.min(1, +(subtitleBgOpacity + 0.1).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + + + + + + {/* Shadow & Outline */} + + {/* Shadow */} + + Text Shadow + setSubtitleTextShadow(!subtitleTextShadow)} style={{ paddingHorizontal: 10, paddingVertical: 8, borderRadius: 10, backgroundColor: subtitleTextShadow ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)', alignItems: 'center' }}> + {subtitleTextShadow ? 'On' : 'Off'} + + + {/* Outline */} + + Outline + setSubtitleOutline(!subtitleOutline)} style={{ paddingHorizontal: 10, paddingVertical: 8, borderRadius: 10, backgroundColor: subtitleOutline ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)', alignItems: 'center' }}> + {subtitleOutline ? 'On' : 'Off'} + + + + {/* Outline color & width */} + + Outline Color + + {['#000000', '#FFFFFF', '#00E5FF', '#FF5C5C'].map(c => ( + setSubtitleOutlineColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleOutlineColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} /> + ))} + + + + Outline Width + + setSubtitleOutlineWidth(Math.max(0, subtitleOutlineWidth - 1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + + + + {subtitleOutlineWidth} + + setSubtitleOutlineWidth(subtitleOutlineWidth + 1)} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + + + + + + {/* Spacing (two columns) */} + + + Letter Spacing + + setSubtitleLetterSpacing(Math.max(0, +(subtitleLetterSpacing - 0.5).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + + + + {subtitleLetterSpacing.toFixed(1)} + + setSubtitleLetterSpacing(+(subtitleLetterSpacing + 0.5).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + + + + + + Line Height + + setSubtitleLineHeightMultiplier(Math.max(1, +(subtitleLineHeightMultiplier - 0.1).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + + + + {subtitleLineHeightMultiplier.toFixed(1)} + + setSubtitleLineHeightMultiplier(+(subtitleLineHeightMultiplier + 0.1).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + + + + + + + {/* Timing Offset */} + + + Timing Offset (s) + + setSubtitleOffsetSec(+(subtitleOffsetSec - 0.1).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + + + + {subtitleOffsetSec.toFixed(1)} + + setSubtitleOffsetSec(+(subtitleOffsetSec + 0.1).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + + + + + Nudge subtitles earlier (-) or later (+) to sync if needed. + + + {/* Reset to defaults */} + + { + setSubtitleTextColor('#FFFFFF'); + setSubtitleBgOpacity(0.7); + setSubtitleTextShadow(true); + setSubtitleOutline(false); + setSubtitleOutlineColor('#000000'); + setSubtitleOutlineWidth(2); + setSubtitleAlign('center'); + setSubtitleBottomOffset(20); + setSubtitleLetterSpacing(0); + setSubtitleLineHeightMultiplier(1.2); + setSubtitleOffsetSec(0); + }} + style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 8, backgroundColor: 'rgba(255,255,255,0.1)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }} + > + Reset to defaults + + + + + )} + diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 75c296c6..0f7189b4 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -14,6 +14,7 @@ class StorageService { private static instance: StorageService; private readonly WATCH_PROGRESS_KEY = '@watch_progress:'; private readonly CONTENT_DURATION_KEY = '@content_duration:'; + private readonly SUBTITLE_SETTINGS_KEY = '@subtitle_settings'; private watchProgressSubscribers: (() => void)[] = []; private notificationDebounceTimer: NodeJS.Timeout | null = null; private lastNotificationTime: number = 0; @@ -420,6 +421,24 @@ class StorageService { logger.error('Error merging with Trakt progress:', error); } } + + public async saveSubtitleSettings(settings: Record): Promise { + try { + await AsyncStorage.setItem(this.SUBTITLE_SETTINGS_KEY, JSON.stringify(settings)); + } catch (error) { + logger.error('Error saving subtitle settings:', error); + } + } + + public async getSubtitleSettings(): Promise | null> { + try { + const data = await AsyncStorage.getItem(this.SUBTITLE_SETTINGS_KEY); + return data ? JSON.parse(data) : null; + } catch (error) { + logger.error('Error loading subtitle settings:', error); + return null; + } + } } export const storageService = StorageService.getInstance(); \ No newline at end of file