diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 80fb25acd..92101e4ad 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -149,6 +149,18 @@ const AndroidVideoPlayer: React.FC = () => { const [customSubtitleVersion, setCustomSubtitleVersion] = useState(0); 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); + const [subtitleOutline, setSubtitleOutline] = useState(false); + const [subtitleOutlineColor, setSubtitleOutlineColor] = useState('#000000'); + const [subtitleOutlineWidth, setSubtitleOutlineWidth] = useState(2); + const [subtitleAlign, setSubtitleAlign] = useState<'center' | 'left' | 'right'>('center'); + const [subtitleBottomOffset, setSubtitleBottomOffset] = useState(20); + const [subtitleLetterSpacing, setSubtitleLetterSpacing] = useState(0); + const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState(1.2); const [useCustomSubtitles, setUseCustomSubtitles] = useState(false); const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false); const [availableSubtitles, setAvailableSubtitles] = useState([]); @@ -1450,6 +1462,17 @@ const AndroidVideoPlayer: React.FC = () => { subtitleSize={subtitleSize} subtitleBackground={subtitleBackground} zoomScale={zoomScale} + fontFamily={subtitleFontFamily} + textColor={subtitleTextColor} + backgroundOpacity={subtitleBgOpacity} + textShadow={subtitleTextShadow} + outline={subtitleOutline} + outlineColor={subtitleOutlineColor} + outlineWidth={subtitleOutlineWidth} + align={subtitleAlign} + bottomOffset={subtitleBottomOffset} + letterSpacing={subtitleLetterSpacing} + lineHeightMultiplier={subtitleLineHeightMultiplier} /> { increaseSubtitleSize={increaseSubtitleSize} decreaseSubtitleSize={decreaseSubtitleSize} toggleSubtitleBackground={toggleSubtitleBackground} + subtitleFontFamily={subtitleFontFamily} + setSubtitleFontFamily={setSubtitleFontFamily} + subtitleTextColor={subtitleTextColor} + setSubtitleTextColor={setSubtitleTextColor} + subtitleBgOpacity={subtitleBgOpacity} + setSubtitleBgOpacity={setSubtitleBgOpacity} + subtitleTextShadow={subtitleTextShadow} + setSubtitleTextShadow={setSubtitleTextShadow} + subtitleOutline={subtitleOutline} + setSubtitleOutline={setSubtitleOutline} + subtitleOutlineColor={subtitleOutlineColor} + setSubtitleOutlineColor={setSubtitleOutlineColor} + subtitleOutlineWidth={subtitleOutlineWidth} + setSubtitleOutlineWidth={setSubtitleOutlineWidth} + subtitleAlign={subtitleAlign} + setSubtitleAlign={setSubtitleAlign} + subtitleBottomOffset={subtitleBottomOffset} + setSubtitleBottomOffset={setSubtitleBottomOffset} + subtitleLetterSpacing={subtitleLetterSpacing} + setSubtitleLetterSpacing={setSubtitleLetterSpacing} + subtitleLineHeightMultiplier={subtitleLineHeightMultiplier} + setSubtitleLineHeightMultiplier={setSubtitleLineHeightMultiplier} /> { @@ -159,6 +160,18 @@ const VideoPlayer: React.FC = () => { const [currentSubtitle, setCurrentSubtitle] = useState(''); 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); + const [subtitleOutline, setSubtitleOutline] = useState(false); + const [subtitleOutlineColor, setSubtitleOutlineColor] = useState('#000000'); + const [subtitleOutlineWidth, setSubtitleOutlineWidth] = useState(2); + const [subtitleAlign, setSubtitleAlign] = useState<'center' | 'left' | 'right'>('center'); + const [subtitleBottomOffset, setSubtitleBottomOffset] = useState(20); + const [subtitleLetterSpacing, setSubtitleLetterSpacing] = useState(0); + const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState(1.2); const [useCustomSubtitles, setUseCustomSubtitles] = useState(false); const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false); const [availableSubtitles, setAvailableSubtitles] = useState([]); @@ -953,18 +966,100 @@ const VideoPlayer: React.FC = () => { }; const loadWyzieSubtitle = async (subtitle: WyzieSubtitle) => { + logger.log(`[VideoPlayer] Subtitle click received: id=${subtitle.id}, lang=${subtitle.language}, url=${subtitle.url}`); setShowSubtitleLanguageModal(false); setIsLoadingSubtitles(true); try { - const response = await fetch(subtitle.url); - const srtContent = await response.text(); + logger.log('[VideoPlayer] Fetching subtitle SRT start'); + let srtContent = ''; + try { + const axiosResp = await axios.get(subtitle.url, { + timeout: 10000, + headers: { + 'Accept': 'text/plain, */*', + 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Nuvio/1.0' + }, + responseType: 'text', + transitional: { clarifyTimeoutError: true } + }); + srtContent = typeof axiosResp.data === 'string' ? axiosResp.data : String(axiosResp.data || ''); + } catch (axiosErr: any) { + logger.warn('[VideoPlayer] Axios subtitle fetch failed, falling back to fetch()', { + message: axiosErr?.message, + code: axiosErr?.code + }); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + try { + const resp = await fetch(subtitle.url, { signal: controller.signal }); + srtContent = await resp.text(); + } finally { + clearTimeout(timeoutId); + } + } + logger.log(`[VideoPlayer] Fetching subtitle SRT done, size=${srtContent.length}`); const parsedCues = parseSRT(srtContent); - setCustomSubtitles(parsedCues); - setUseCustomSubtitles(true); + logger.log(`[VideoPlayer] Parsed cues count=${parsedCues.length}`); + + // For VLC on iOS: stop spinner early, then clear-apply and micro-seek nudge + setIsLoadingSubtitles(false); + logger.log('[VideoPlayer] isLoadingSubtitles -> false (early)'); + + // Clear existing state + setUseCustomSubtitles(false); + logger.log('[VideoPlayer] useCustomSubtitles -> false'); + setCustomSubtitles([]); + logger.log('[VideoPlayer] customSubtitles -> []'); setSelectedTextTrack(-1); + logger.log('[VideoPlayer] selectedTextTrack -> -1'); + + // Apply immediately + setCustomSubtitles(parsedCues); + logger.log('[VideoPlayer] customSubtitles <- parsedCues'); + setUseCustomSubtitles(true); + logger.log('[VideoPlayer] useCustomSubtitles -> true'); + setSelectedTextTrack(-1); + logger.log('[VideoPlayer] selectedTextTrack -> -1 (disable native while using custom)'); + + // Immediately set current subtitle text + try { + const cueNow = parsedCues.find(cue => currentTime >= cue.start && currentTime <= cue.end); + const textNow = cueNow ? cueNow.text : ''; + setCurrentSubtitle(textNow); + logger.log('[VideoPlayer] currentSubtitle set immediately after apply'); + } catch (e) { + 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); + } } catch (error) { logger.error('[VideoPlayer] Error loading Wyzie subtitle:', error); - } finally { setIsLoadingSubtitles(false); } }; @@ -1275,7 +1370,7 @@ const VideoPlayer: React.FC = () => { { + source={(() => { // FORCEFULLY use headers from route params if available - no filtering or modification const sourceWithHeaders = headers ? { uri: currentStreamUrl, @@ -1339,6 +1434,17 @@ const VideoPlayer: React.FC = () => { subtitleSize={subtitleSize} subtitleBackground={subtitleBackground} zoomScale={zoomScale} + fontFamily={subtitleFontFamily} + textColor={subtitleTextColor} + backgroundOpacity={subtitleBgOpacity} + textShadow={subtitleTextShadow} + outline={subtitleOutline} + outlineColor={subtitleOutlineColor} + outlineWidth={subtitleOutlineWidth} + align={subtitleAlign} + bottomOffset={subtitleBottomOffset} + letterSpacing={subtitleLetterSpacing} + lineHeightMultiplier={subtitleLineHeightMultiplier} /> { increaseSubtitleSize={increaseSubtitleSize} decreaseSubtitleSize={decreaseSubtitleSize} toggleSubtitleBackground={toggleSubtitleBackground} + subtitleFontFamily={subtitleFontFamily} + setSubtitleFontFamily={setSubtitleFontFamily} + subtitleTextColor={subtitleTextColor} + setSubtitleTextColor={setSubtitleTextColor} + subtitleBgOpacity={subtitleBgOpacity} + setSubtitleBgOpacity={setSubtitleBgOpacity} + subtitleTextShadow={subtitleTextShadow} + setSubtitleTextShadow={setSubtitleTextShadow} + subtitleOutline={subtitleOutline} + setSubtitleOutline={setSubtitleOutline} + subtitleOutlineColor={subtitleOutlineColor} + setSubtitleOutlineColor={setSubtitleOutlineColor} + subtitleOutlineWidth={subtitleOutlineWidth} + setSubtitleOutlineWidth={setSubtitleOutlineWidth} + subtitleAlign={subtitleAlign} + setSubtitleAlign={setSubtitleAlign} + subtitleBottomOffset={subtitleBottomOffset} + setSubtitleBottomOffset={setSubtitleBottomOffset} + subtitleLetterSpacing={subtitleLetterSpacing} + setSubtitleLetterSpacing={setSubtitleLetterSpacing} + subtitleLineHeightMultiplier={subtitleLineHeightMultiplier} + setSubtitleLineHeightMultiplier={setSubtitleLineHeightMultiplier} /> void; decreaseSubtitleSize: () => void; toggleSubtitleBackground: () => void; + // Customization props + subtitleFontFamily?: string; + setSubtitleFontFamily: (f?: string) => void; + subtitleTextColor: string; + setSubtitleTextColor: (c: string) => void; + subtitleBgOpacity: number; + setSubtitleBgOpacity: (o: number) => void; + subtitleTextShadow: boolean; + setSubtitleTextShadow: (b: boolean) => void; + subtitleOutline: boolean; + setSubtitleOutline: (b: boolean) => void; + subtitleOutlineColor: string; + setSubtitleOutlineColor: (c: string) => void; + subtitleOutlineWidth: number; + setSubtitleOutlineWidth: (n: number) => void; + subtitleAlign: 'center' | 'left' | 'right'; + setSubtitleAlign: (a: 'center' | 'left' | 'right') => void; + subtitleBottomOffset: number; + setSubtitleBottomOffset: (n: number) => void; + subtitleLetterSpacing: number; + setSubtitleLetterSpacing: (n: number) => void; + subtitleLineHeightMultiplier: number; + setSubtitleLineHeightMultiplier: (n: number) => void; } const { width, height } = Dimensions.get('window'); @@ -56,6 +79,28 @@ export const SubtitleModals: React.FC = ({ increaseSubtitleSize, decreaseSubtitleSize, toggleSubtitleBackground, + subtitleFontFamily, + setSubtitleFontFamily, + subtitleTextColor, + setSubtitleTextColor, + subtitleBgOpacity, + setSubtitleBgOpacity, + subtitleTextShadow, + setSubtitleTextShadow, + subtitleOutline, + setSubtitleOutline, + subtitleOutlineColor, + setSubtitleOutlineColor, + subtitleOutlineWidth, + setSubtitleOutlineWidth, + subtitleAlign, + setSubtitleAlign, + subtitleBottomOffset, + setSubtitleBottomOffset, + subtitleLetterSpacing, + setSubtitleLetterSpacing, + subtitleLineHeightMultiplier, + setSubtitleLineHeightMultiplier, }) => { // Track which specific online subtitle is currently loaded const [selectedOnlineSubtitleId, setSelectedOnlineSubtitleId] = React.useState(null); @@ -295,6 +340,118 @@ export const SubtitleModals: React.FC = ({ )} + {/* 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 && ( diff --git a/src/components/player/subtitles/CustomSubtitles.tsx b/src/components/player/subtitles/CustomSubtitles.tsx index 38338769d..6f47e8b38 100644 --- a/src/components/player/subtitles/CustomSubtitles.tsx +++ b/src/components/player/subtitles/CustomSubtitles.tsx @@ -8,6 +8,18 @@ interface CustomSubtitlesProps { subtitleSize: number; subtitleBackground: boolean; zoomScale?: number; // current video zoom scale; defaults to 1 + // New customization props + fontFamily?: string; + textColor?: string; + backgroundOpacity?: number; // 0..1 + textShadow?: boolean; + outline?: boolean; + outlineColor?: string; + outlineWidth?: number; // px + align?: 'center' | 'left' | 'right'; + bottomOffset?: number; // px from bottom + letterSpacing?: number; + lineHeightMultiplier?: number; // multiplies subtitleSize } export const CustomSubtitles: React.FC = ({ @@ -16,27 +28,68 @@ export const CustomSubtitles: React.FC = ({ subtitleSize, subtitleBackground, zoomScale = 1, + fontFamily, + textColor = '#FFFFFF', + backgroundOpacity = 0.7, + textShadow = true, + outline = false, + outlineColor = '#000000', + outlineWidth = 2, + align = 'center', + bottomOffset = 20, + letterSpacing = 0, + lineHeightMultiplier = 1.2, }) => { if (!useCustomSubtitles || !currentSubtitle) return null; const inverseScale = 1 / zoomScale; + const bgColor = subtitleBackground ? `rgba(0, 0, 0, ${Math.min(Math.max(backgroundOpacity, 0), 1)})` : 'transparent'; + + // Outline via textShadow for multi-direction pass + const outlineStyle = outline + ? { + textShadowColor: outlineColor, + textShadowOffset: { width: 0, height: 0 }, + textShadowRadius: outlineWidth, + } + : {}; + + const shadowStyle = textShadow + ? { + textShadowColor: 'rgba(0, 0, 0, 0.9)', + textShadowOffset: { width: 2, height: 2 }, + textShadowRadius: 4, + } + : {}; + return ( {currentSubtitle}