Added ExoPlayer as primary for better hardwre decoder support and MPV as fallback

This commit is contained in:
tapframe 2025-12-28 02:14:39 +05:30
parent 2d97cad1dc
commit 6e2ddd2dda
12 changed files with 3428 additions and 386 deletions

View file

@ -1,8 +1,41 @@
import React, { useEffect, useRef, useState, useCallback } from 'react'; import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { Platform, Animated, TouchableWithoutFeedback, View } from 'react-native'; import { Platform, Animated, TouchableWithoutFeedback, View } from 'react-native';
import Video, { VideoRef, SelectedTrack, BufferingStrategyType, ResizeMode } from 'react-native-video'; import Video, { VideoRef, SelectedTrack, BufferingStrategyType, ResizeMode } from 'react-native-video';
import RNImmersiveMode from 'react-native-immersive-mode'; import RNImmersiveMode from 'react-native-immersive-mode';
// Subtitle style configuration interface - matches ExoPlayer's SubtitleStyle
export interface SubtitleStyleConfig {
// Font size in SP (scale-independent pixels) for subtitle text
// Default: -1 (uses system default)
fontSize?: number;
// Padding values in pixels
paddingTop?: number;
paddingBottom?: number;
paddingLeft?: number;
paddingRight?: number;
// Opacity of subtitles (0.0 to 1.0)
// 0 = hidden, 1 = fully visible
opacity?: number;
// Whether subtitles should follow video position when video is resized
// true = subtitles stay within video bounds
// false = subtitles can extend beyond video bounds
subtitlesFollowVideo?: boolean;
}
// Default subtitle style configuration
export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = {
fontSize: 18,
paddingTop: 0,
paddingBottom: 60,
paddingLeft: 16,
paddingRight: 16,
opacity: 1,
subtitlesFollowVideo: true,
};
interface VideoPlayerProps { interface VideoPlayerProps {
src: string; src: string;
headers?: { [key: string]: string }; headers?: { [key: string]: string };
@ -12,6 +45,8 @@ interface VideoPlayerProps {
selectedAudioTrack?: SelectedTrack; selectedAudioTrack?: SelectedTrack;
selectedTextTrack?: SelectedTrack; selectedTextTrack?: SelectedTrack;
resizeMode?: ResizeMode; resizeMode?: ResizeMode;
// Subtitle customization - pass custom subtitle styling
subtitleStyle?: SubtitleStyleConfig;
onProgress?: (data: { currentTime: number; playableDuration: number }) => void; onProgress?: (data: { currentTime: number; playableDuration: number }) => void;
onLoad?: (data: { duration: number }) => void; onLoad?: (data: { duration: number }) => void;
onError?: (error: any) => void; onError?: (error: any) => void;
@ -29,6 +64,7 @@ export const AndroidVideoPlayer: React.FC<VideoPlayerProps> = ({
selectedAudioTrack, selectedAudioTrack,
selectedTextTrack, selectedTextTrack,
resizeMode = 'contain' as ResizeMode, resizeMode = 'contain' as ResizeMode,
subtitleStyle: customSubtitleStyle,
onProgress, onProgress,
onLoad, onLoad,
onError, onError,
@ -41,6 +77,12 @@ export const AndroidVideoPlayer: React.FC<VideoPlayerProps> = ({
const [isSeeking, setIsSeeking] = useState(false); const [isSeeking, setIsSeeking] = useState(false);
const [lastSeekTime, setLastSeekTime] = useState<number>(0); const [lastSeekTime, setLastSeekTime] = useState<number>(0);
// Merge custom subtitle style with defaults
const subtitleStyle = useMemo(() => ({
...DEFAULT_SUBTITLE_STYLE,
...customSubtitleStyle,
}), [customSubtitleStyle]);
// Enable immersive mode when video player mounts, disable when it unmounts // Enable immersive mode when video player mounts, disable when it unmounts
useEffect(() => { useEffect(() => {
if (Platform.OS === 'android') { if (Platform.OS === 'android') {
@ -132,13 +174,21 @@ export const AndroidVideoPlayer: React.FC<VideoPlayerProps> = ({
rate={1.0} rate={1.0}
repeat={false} repeat={false}
reportBandwidth={true} reportBandwidth={true}
textTracks={[]} useTextureView={true}
useTextureView={false}
disableFocus={false} disableFocus={false}
minLoadRetryCount={3} minLoadRetryCount={3}
automaticallyWaitsToMinimizeStalling={true} automaticallyWaitsToMinimizeStalling={true}
hideShutterView={false} hideShutterView={false}
shutterColor="#000000" shutterColor="#000000"
subtitleStyle={{
fontSize: subtitleStyle.fontSize,
paddingTop: subtitleStyle.paddingTop,
paddingBottom: subtitleStyle.paddingBottom,
paddingLeft: subtitleStyle.paddingLeft,
paddingRight: subtitleStyle.paddingRight,
opacity: subtitleStyle.opacity,
subtitlesFollowVideo: subtitleStyle.subtitlesFollowVideo,
}}
/> />
); );
}; };

File diff suppressed because it is too large Load diff

View file

@ -2182,6 +2182,7 @@ public class ReactExoplayerView extends FrameLayout implements
if (textRendererIndex != C.INDEX_UNSET) { if (textRendererIndex != C.INDEX_UNSET) {
TrackGroupArray groups = info.getTrackGroups(textRendererIndex); TrackGroupArray groups = info.getTrackGroups(textRendererIndex);
boolean trackFound = false; boolean trackFound = false;
int cumulativeIndex = 0; // Track cumulative index across all groups
for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) {
TrackGroup group = groups.get(groupIndex); TrackGroup group = groups.get(groupIndex);
@ -2195,7 +2196,8 @@ public class ReactExoplayerView extends FrameLayout implements
isMatch = true; isMatch = true;
} else if ("index".equals(type)) { } else if ("index".equals(type)) {
int targetIndex = ReactBridgeUtils.safeParseInt(value, -1); int targetIndex = ReactBridgeUtils.safeParseInt(value, -1);
if (targetIndex == trackIndex) { // Use cumulative index to match getTextTrackInfo() behavior
if (targetIndex == cumulativeIndex) {
isMatch = true; isMatch = true;
} }
} }
@ -2207,6 +2209,7 @@ public class ReactExoplayerView extends FrameLayout implements
trackFound = true; trackFound = true;
break; break;
} }
cumulativeIndex++; // Increment after each track
} }
if (trackFound) if (trackFound)
break; break;

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import React, { useRef, useEffect, useMemo, useCallback, useState } from 'react'; import React, { useRef, useEffect, useMemo, useCallback, useState } from 'react';
import { View, StyleSheet, Platform, Animated } from 'react-native'; import { View, StyleSheet, Platform, Animated, ToastAndroid } from 'react-native';
import { toast } from '@backpackapp-io/react-native-toast'; import { toast } from '@backpackapp-io/react-native-toast';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
@ -78,6 +78,7 @@ const AndroidVideoPlayer: React.FC = () => {
const videoRef = useRef<any>(null); const videoRef = useRef<any>(null);
const mpvPlayerRef = useRef<MpvPlayerRef>(null); const mpvPlayerRef = useRef<MpvPlayerRef>(null);
const exoPlayerRef = useRef<any>(null);
const pinchRef = useRef(null); const pinchRef = useRef(null);
const tracksHook = usePlayerTracks(); const tracksHook = usePlayerTracks();
@ -92,6 +93,10 @@ const AndroidVideoPlayer: React.FC = () => {
// State to force unmount VideoSurface during stream transitions // State to force unmount VideoSurface during stream transitions
const [isTransitioningStream, setIsTransitioningStream] = useState(false); const [isTransitioningStream, setIsTransitioningStream] = useState(false);
// Dual video engine state: ExoPlayer primary, MPV fallback
const [useExoPlayer, setUseExoPlayer] = useState(true);
const hasExoPlayerFailed = useRef(false);
// Subtitle addon state // Subtitle addon state
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]); const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false); const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false);
@ -131,7 +136,9 @@ const AndroidVideoPlayer: React.FC = () => {
playerState.currentTime, playerState.currentTime,
playerState.duration, playerState.duration,
playerState.isSeeking, playerState.isSeeking,
playerState.isMounted playerState.isMounted,
exoPlayerRef,
useExoPlayer
); );
const traktAutosync = useTraktAutosync({ const traktAutosync = useTraktAutosync({
@ -327,6 +334,16 @@ const AndroidVideoPlayer: React.FC = () => {
else navigation.reset({ index: 0, routes: [{ name: 'Home' }] } as any); else navigation.reset({ index: 0, routes: [{ name: 'Home' }] } as any);
}, [navigation]); }, [navigation]);
// Handle codec errors from ExoPlayer - silently switch to MPV
const handleCodecError = useCallback(() => {
if (!hasExoPlayerFailed.current) {
hasExoPlayerFailed.current = true;
logger.warn('[AndroidVideoPlayer] ExoPlayer codec error detected, switching to MPV silently');
ToastAndroid.show('Switching to MPV due to unsupported codec', ToastAndroid.SHORT);
setUseExoPlayer(false);
}
}, []);
const handleSelectStream = async (newStream: any) => { const handleSelectStream = async (newStream: any) => {
if (newStream.url === currentStreamUrl) { if (newStream.url === currentStreamUrl) {
modals.setShowSourcesModal(false); modals.setShowSourcesModal(false);
@ -485,6 +502,13 @@ const AndroidVideoPlayer: React.FC = () => {
else playerState.setResizeMode('contain'); else playerState.setResizeMode('contain');
}, [playerState.resizeMode]); }, [playerState.resizeMode]);
// Memoize selectedTextTrack to prevent unnecessary re-renders
const memoizedSelectedTextTrack = useMemo(() => {
return tracksHook.selectedTextTrack === -1
? { type: 'disabled' as const }
: { type: 'index' as const, value: tracksHook.selectedTextTrack };
}, [tracksHook.selectedTextTrack]);
return ( return (
<View style={[styles.container, { <View style={[styles.container, {
width: playerState.screenDimensions.width, width: playerState.screenDimensions.width,
@ -566,12 +590,18 @@ const AndroidVideoPlayer: React.FC = () => {
} }
}} }}
mpvPlayerRef={mpvPlayerRef} mpvPlayerRef={mpvPlayerRef}
exoPlayerRef={exoPlayerRef}
pinchRef={pinchRef} pinchRef={pinchRef}
onPinchGestureEvent={() => { }} onPinchGestureEvent={() => { }}
onPinchHandlerStateChange={() => { }} onPinchHandlerStateChange={() => { }}
screenDimensions={playerState.screenDimensions} screenDimensions={playerState.screenDimensions}
decoderMode={settings.decoderMode} decoderMode={settings.decoderMode}
gpuMode={settings.gpuMode} gpuMode={settings.gpuMode}
// Dual video engine props
useExoPlayer={useExoPlayer}
onCodecError={handleCodecError}
selectedAudioTrack={tracksHook.selectedAudioTrack as any || undefined}
selectedTextTrack={memoizedSelectedTextTrack as any}
// Subtitle Styling - pass to MPV for built-in subtitle customization // Subtitle Styling - pass to MPV for built-in subtitle customization
// MPV uses different scaling than React Native, so we apply conversion factors: // MPV uses different scaling than React Native, so we apply conversion factors:
// - Font size: MPV needs ~1.5x larger values (MPV's sub-font-size vs RN fontSize) // - Font size: MPV needs ~1.5x larger values (MPV's sub-font-size vs RN fontSize)
@ -669,7 +699,7 @@ const AndroidVideoPlayer: React.FC = () => {
}} }}
buffered={playerState.buffered} buffered={playerState.buffered}
formatTime={formatTime} formatTime={formatTime}
playerBackend={'MPV'} playerBackend={useExoPlayer ? 'ExoPlayer' : 'MPV'}
/> />
<SpeedActivatedOverlay <SpeedActivatedOverlay
@ -764,16 +794,19 @@ const AndroidVideoPlayer: React.FC = () => {
selectedTextTrack={tracksHook.computedSelectedTextTrack} selectedTextTrack={tracksHook.computedSelectedTextTrack}
useCustomSubtitles={useCustomSubtitles} useCustomSubtitles={useCustomSubtitles}
isKsPlayerActive={true} isKsPlayerActive={true}
useExoPlayer={useExoPlayer}
subtitleSize={subtitleSize} subtitleSize={subtitleSize}
subtitleBackground={subtitleBackground} subtitleBackground={subtitleBackground}
fetchAvailableSubtitles={fetchAvailableSubtitles} fetchAvailableSubtitles={fetchAvailableSubtitles}
loadWyzieSubtitle={loadWyzieSubtitle} loadWyzieSubtitle={loadWyzieSubtitle}
selectTextTrack={(trackId) => { selectTextTrack={(trackId) => {
tracksHook.setSelectedTextTrack(trackId); tracksHook.setSelectedTextTrack(trackId);
// Actually tell MPV to switch the subtitle track // For MPV, manually switch the subtitle track
if (mpvPlayerRef.current) { if (!useExoPlayer && mpvPlayerRef.current) {
mpvPlayerRef.current.setSubtitleTrack(trackId); mpvPlayerRef.current.setSubtitleTrack(trackId);
} }
// For ExoPlayer, the selectedTextTrack prop will be updated via memoizedSelectedTextTrack
// which triggers a re-render with the new track selection
// Disable custom subtitles when selecting built-in track // Disable custom subtitles when selecting built-in track
setUseCustomSubtitles(false); setUseCustomSubtitles(false);
modals.setShowSubtitleModal(false); modals.setShowSubtitleModal(false);

View file

@ -1,9 +1,35 @@
import React, { useCallback, memo } from 'react'; import React, { useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
import { View, TouchableWithoutFeedback, StyleSheet } from 'react-native'; import { View, TouchableWithoutFeedback, StyleSheet } from 'react-native';
import { PinchGestureHandler } from 'react-native-gesture-handler'; import { PinchGestureHandler } from 'react-native-gesture-handler';
import Video, { VideoRef, SelectedTrack, SelectedVideoTrack, ResizeMode } from 'react-native-video';
import MpvPlayer, { MpvPlayerRef } from '../MpvPlayer'; import MpvPlayer, { MpvPlayerRef } from '../MpvPlayer';
import { styles } from '../../utils/playerStyles'; import { styles } from '../../utils/playerStyles';
import { ResizeModeType } from '../../utils/playerTypes'; import { ResizeModeType } from '../../utils/playerTypes';
import { logger } from '../../../../utils/logger';
// Codec error patterns that indicate we should fallback to MPV
const CODEC_ERROR_PATTERNS = [
'exceeds_capabilities',
'no_exceeds_capabilities',
'decoder_exception',
'decoder.*error',
'codec.*error',
'unsupported.*codec',
'mediacodec.*exception',
'omx.*error',
'dolby.*vision',
'hevc.*error',
'no suitable decoder',
'decoder initialization failed',
'format.no_decoder',
'no_decoder',
'decoding_failed',
'error_code_decoding',
'exoplaybackexception',
'mediacodecvideodecoder',
'mediacodecvideodecoderexception',
'decoder failed',
];
interface VideoSurfaceProps { interface VideoSurfaceProps {
processedStreamUrl: string; processedStreamUrl: string;
@ -25,6 +51,7 @@ interface VideoSurfaceProps {
// Refs // Refs
mpvPlayerRef?: React.RefObject<MpvPlayerRef>; mpvPlayerRef?: React.RefObject<MpvPlayerRef>;
exoPlayerRef?: React.RefObject<VideoRef>;
pinchRef: any; pinchRef: any;
// Handlers // Handlers
@ -32,8 +59,16 @@ interface VideoSurfaceProps {
onPinchHandlerStateChange: any; onPinchHandlerStateChange: any;
screenDimensions: { width: number, height: number }; screenDimensions: { width: number, height: number };
onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void; onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void;
selectedAudioTrack?: SelectedTrack;
selectedTextTrack?: SelectedTrack;
decoderMode?: 'auto' | 'sw' | 'hw' | 'hw+'; decoderMode?: 'auto' | 'sw' | 'hw' | 'hw+';
gpuMode?: 'gpu' | 'gpu-next'; gpuMode?: 'gpu' | 'gpu-next';
// Dual Engine Props
useExoPlayer?: boolean;
onCodecError?: () => void;
onEngineChange?: (engine: 'exoplayer' | 'mpv') => void;
// Subtitle Styling // Subtitle Styling
subtitleSize?: number; subtitleSize?: number;
subtitleColor?: string; subtitleColor?: string;
@ -46,6 +81,15 @@ interface VideoSurfaceProps {
subtitleAlignment?: 'left' | 'center' | 'right'; subtitleAlignment?: 'left' | 'center' | 'right';
} }
// Helper function to check if error is a codec error
const isCodecError = (errorString: string): boolean => {
const lowerError = errorString.toLowerCase();
return CODEC_ERROR_PATTERNS.some(pattern => {
const regex = new RegExp(pattern, 'i');
return regex.test(lowerError);
});
};
export const VideoSurface: React.FC<VideoSurfaceProps> = ({ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
processedStreamUrl, processedStreamUrl,
headers, headers,
@ -62,13 +106,20 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
onError, onError,
onBuffer, onBuffer,
mpvPlayerRef, mpvPlayerRef,
exoPlayerRef,
pinchRef, pinchRef,
onPinchGestureEvent, onPinchGestureEvent,
onPinchHandlerStateChange, onPinchHandlerStateChange,
screenDimensions, screenDimensions,
onTracksChanged, onTracksChanged,
selectedAudioTrack,
selectedTextTrack,
decoderMode, decoderMode,
gpuMode, gpuMode,
// Dual Engine
useExoPlayer = true,
onCodecError,
onEngineChange,
// Subtitle Styling // Subtitle Styling
subtitleSize, subtitleSize,
subtitleColor, subtitleColor,
@ -83,10 +134,9 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
// Use the actual stream URL // Use the actual stream URL
const streamUrl = currentStreamUrl || processedStreamUrl; const streamUrl = currentStreamUrl || processedStreamUrl;
// Debug logging removed to prevent console spam // ========== MPV Handlers ==========
const handleMpvLoad = (data: { duration: number; width: number; height: number }) => {
const handleLoad = (data: { duration: number; width: number; height: number }) => { console.log('[VideoSurface] MPV onLoad received:', data);
console.log('[VideoSurface] onLoad received:', data);
onLoad({ onLoad({
duration: data.duration, duration: data.duration,
naturalSize: { naturalSize: {
@ -96,15 +146,15 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
}); });
}; };
const handleProgress = (data: { currentTime: number; duration: number }) => { const handleMpvProgress = (data: { currentTime: number; duration: number }) => {
onProgress({ onProgress({
currentTime: data.currentTime, currentTime: data.currentTime,
playableDuration: data.currentTime, playableDuration: data.currentTime,
}); });
}; };
const handleError = (error: { error: string }) => { const handleMpvError = (error: { error: string }) => {
console.log('[VideoSurface] onError received:', error); console.log('[VideoSurface] MPV onError received:', error);
onError({ onError({
error: { error: {
errorString: error.error, errorString: error.error,
@ -112,44 +162,204 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
}); });
}; };
const handleEnd = () => { const handleMpvEnd = () => {
console.log('[VideoSurface] onEnd received'); console.log('[VideoSurface] MPV onEnd received');
onEnd(); onEnd();
}; };
// ========== ExoPlayer Handlers ==========
const handleExoLoad = (data: any) => {
console.log('[VideoSurface] ExoPlayer onLoad received:', data);
console.log('[VideoSurface] ExoPlayer textTracks raw:', JSON.stringify(data.textTracks, null, 2));
// Extract track information
const audioTracks = data.audioTracks?.map((t: any, i: number) => ({
id: t.index ?? i,
name: t.title || t.language || `Track ${i + 1}`,
language: t.language,
})) ?? [];
const subtitleTracks = data.textTracks?.map((t: any, i: number) => {
const track = {
id: t.index ?? i,
name: t.title || t.language || `Track ${i + 1}`,
language: t.language,
};
console.log('[VideoSurface] Mapped subtitle track:', track, 'original:', t);
return track;
}) ?? [];
if (onTracksChanged && (audioTracks.length > 0 || subtitleTracks.length > 0)) {
onTracksChanged({ audioTracks, subtitleTracks });
}
onLoad({
duration: data.duration,
naturalSize: data.naturalSize || { width: 1920, height: 1080 },
audioTracks: data.audioTracks,
textTracks: data.textTracks,
});
};
const handleExoProgress = (data: any) => {
onProgress({
currentTime: data.currentTime,
playableDuration: data.playableDuration || data.currentTime,
});
};
const handleExoError = (error: any) => {
console.log('[VideoSurface] ExoPlayer onError received:', JSON.stringify(error, null, 2));
// Extract error string - try multiple paths
let errorString = 'Unknown error';
const errorParts: string[] = [];
if (typeof error?.error === 'string') {
errorParts.push(error.error);
}
if (error?.error?.errorString) {
errorParts.push(error.error.errorString);
}
if (error?.error?.errorCode) {
errorParts.push(String(error.error.errorCode));
}
if (typeof error === 'string') {
errorParts.push(error);
}
if (error?.nativeStackAndroid) {
errorParts.push(error.nativeStackAndroid.join(' '));
}
if (error?.message) {
errorParts.push(error.message);
}
// Combine all error parts for comprehensive checking
errorString = errorParts.length > 0 ? errorParts.join(' ') : JSON.stringify(error);
console.log('[VideoSurface] Extracted error string:', errorString);
console.log('[VideoSurface] isCodecError result:', isCodecError(errorString));
// Check if this is a codec error that should trigger fallback
if (isCodecError(errorString)) {
logger.warn('[VideoSurface] ExoPlayer codec error detected, triggering MPV fallback:', errorString);
onCodecError?.();
return; // Don't propagate codec errors - we're falling back silently
}
// Non-codec errors should be propagated
onError({
error: {
errorString: errorString,
},
});
};
const handleExoBuffer = (data: any) => {
onBuffer({ isBuffering: data.isBuffering });
};
const handleExoEnd = () => {
console.log('[VideoSurface] ExoPlayer onEnd received');
onEnd();
};
const handleExoSeek = (data: any) => {
onSeek({ currentTime: data.currentTime });
};
// Map ResizeModeType to react-native-video ResizeMode
const getExoResizeMode = (): ResizeMode => {
switch (resizeMode) {
case 'cover':
return ResizeMode.COVER;
case 'stretch':
return ResizeMode.STRETCH;
case 'contain':
default:
return ResizeMode.CONTAIN;
}
};
return ( return (
<View style={[styles.videoContainer, { <View style={[styles.videoContainer, {
width: screenDimensions.width, width: screenDimensions.width,
height: screenDimensions.height, height: screenDimensions.height,
}]}> }]}>
{/* MPV Player - rendered at the bottom of the z-order */} {useExoPlayer ? (
<MpvPlayer /* ExoPlayer via react-native-video */
ref={mpvPlayerRef} <Video
source={streamUrl} ref={exoPlayerRef}
headers={headers} source={{
paused={paused} uri: streamUrl,
volume={volume} headers: headers,
rate={playbackSpeed} }}
resizeMode={resizeMode === 'none' ? 'contain' : resizeMode} paused={paused}
style={localStyles.player} volume={volume}
onLoad={handleLoad} rate={playbackSpeed}
onProgress={handleProgress} resizeMode={getExoResizeMode()}
onEnd={handleEnd} selectedAudioTrack={selectedAudioTrack}
onError={handleError} selectedTextTrack={selectedTextTrack}
onTracksChanged={onTracksChanged} style={localStyles.player}
decoderMode={decoderMode} onLoad={handleExoLoad}
gpuMode={gpuMode} onProgress={handleExoProgress}
// Subtitle Styling onEnd={handleExoEnd}
subtitleSize={subtitleSize} onError={handleExoError}
subtitleColor={subtitleColor} onBuffer={handleExoBuffer}
subtitleBackgroundOpacity={subtitleBackgroundOpacity} onSeek={handleExoSeek}
subtitleBorderSize={subtitleBorderSize} progressUpdateInterval={500}
subtitleBorderColor={subtitleBorderColor} playInBackground={false}
subtitleShadowEnabled={subtitleShadowEnabled} playWhenInactive={false}
subtitlePosition={subtitlePosition} ignoreSilentSwitch="ignore"
subtitleDelay={subtitleDelay} automaticallyWaitsToMinimizeStalling={true}
subtitleAlignment={subtitleAlignment} useTextureView={true}
/> // Subtitle Styling for ExoPlayer
// ExoPlayer supports: fontSize, paddingTop/Bottom/Left/Right, opacity, subtitlesFollowVideo
subtitleStyle={{
// Convert MPV-scaled size back to ExoPlayer scale (~1.5x conversion was applied)
fontSize: subtitleSize ? Math.round(subtitleSize / 1.5) : 18,
paddingTop: 0,
// Convert MPV position (0=top, 100=bottom) to paddingBottom
// Higher MPV position = less padding from bottom
paddingBottom: subtitlePosition ? Math.max(20, Math.round((100 - subtitlePosition) * 2)) : 60,
paddingLeft: 16,
paddingRight: 16,
// Opacity controls entire subtitle view visibility
// Always keep text visible (opacity 1), background control is limited in ExoPlayer
opacity: 1,
subtitlesFollowVideo: false,
}}
/>
) : (
/* MPV Player fallback */
<MpvPlayer
ref={mpvPlayerRef}
source={streamUrl}
headers={headers}
paused={paused}
volume={volume}
rate={playbackSpeed}
resizeMode={resizeMode === 'none' ? 'contain' : resizeMode}
style={localStyles.player}
onLoad={handleMpvLoad}
onProgress={handleMpvProgress}
onEnd={handleMpvEnd}
onError={handleMpvError}
onTracksChanged={onTracksChanged}
decoderMode={decoderMode}
gpuMode={gpuMode}
// Subtitle Styling
subtitleSize={subtitleSize}
subtitleColor={subtitleColor}
subtitleBackgroundOpacity={subtitleBackgroundOpacity}
subtitleBorderSize={subtitleBorderSize}
subtitleBorderColor={subtitleBorderColor}
subtitleShadowEnabled={subtitleShadowEnabled}
subtitlePosition={subtitlePosition}
subtitleDelay={subtitleDelay}
subtitleAlignment={subtitleAlignment}
/>
)}
{/* Gesture overlay - transparent, on top of the player */} {/* Gesture overlay - transparent, on top of the player */}
<PinchGestureHandler <PinchGestureHandler

View file

@ -1,5 +1,6 @@
import { useRef, useCallback } from 'react'; import { useRef, useCallback } from 'react';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
import { VideoRef } from 'react-native-video';
import { logger } from '../../../../utils/logger'; import { logger } from '../../../../utils/logger';
const DEBUG_MODE = true; // Temporarily enable for debugging seek const DEBUG_MODE = true; // Temporarily enable for debugging seek
@ -12,7 +13,10 @@ export const usePlayerControls = (
currentTime: number, currentTime: number,
duration: number, duration: number,
isSeeking: React.MutableRefObject<boolean>, isSeeking: React.MutableRefObject<boolean>,
isMounted: React.MutableRefObject<boolean> isMounted: React.MutableRefObject<boolean>,
// Dual engine support
exoPlayerRef?: React.RefObject<VideoRef>,
useExoPlayer?: boolean
) => { ) => {
// iOS seeking helpers // iOS seeking helpers
const iosWasPausedDuringSeekRef = useRef<boolean | null>(null); const iosWasPausedDuringSeekRef = useRef<boolean | null>(null);
@ -28,12 +32,30 @@ export const usePlayerControls = (
rawSeconds, rawSeconds,
timeInSeconds, timeInSeconds,
hasMpvRef: !!mpvPlayerRef?.current, hasMpvRef: !!mpvPlayerRef?.current,
hasExoRef: !!exoPlayerRef?.current,
useExoPlayer,
duration, duration,
isSeeking: isSeeking.current isSeeking: isSeeking.current
}); });
// MPV Player // ExoPlayer
if (mpvPlayerRef.current && duration > 0) { if (useExoPlayer && exoPlayerRef?.current && duration > 0) {
console.log(`[usePlayerControls][ExoPlayer] Seeking to ${timeInSeconds}`);
isSeeking.current = true;
exoPlayerRef.current.seek(timeInSeconds);
// Reset seeking flag after a delay
setTimeout(() => {
if (isMounted.current) {
isSeeking.current = false;
}
}, 500);
return;
}
// MPV Player (fallback or when useExoPlayer is false)
if (mpvPlayerRef?.current && duration > 0) {
console.log(`[usePlayerControls][MPV] Seeking to ${timeInSeconds}`); console.log(`[usePlayerControls][MPV] Seeking to ${timeInSeconds}`);
isSeeking.current = true; isSeeking.current = true;
@ -45,13 +67,16 @@ export const usePlayerControls = (
isSeeking.current = false; isSeeking.current = false;
} }
}, 500); }, 500);
} else { return;
console.log('[usePlayerControls][MPV] Cannot seek - ref or duration invalid:', {
hasRef: !!mpvPlayerRef?.current,
duration
});
} }
}, [duration, paused, setPaused, mpvPlayerRef, isSeeking, isMounted]);
console.log('[usePlayerControls] Cannot seek - no valid ref:', {
hasExoRef: !!exoPlayerRef?.current,
hasMpvRef: !!mpvPlayerRef?.current,
useExoPlayer,
duration
});
}, [duration, paused, setPaused, mpvPlayerRef, exoPlayerRef, useExoPlayer, isSeeking, isMounted]);
const skip = useCallback((seconds: number) => { const skip = useCallback((seconds: number) => {
console.log('[usePlayerControls] skip called:', { seconds, currentTime, newTime: currentTime + seconds }); console.log('[usePlayerControls] skip called:', { seconds, currentTime, newTime: currentTime + seconds });

View file

@ -13,7 +13,7 @@ import { logger } from '../../../utils/logger';
interface AudioTrackModalProps { interface AudioTrackModalProps {
showAudioModal: boolean; showAudioModal: boolean;
setShowAudioModal: (show: boolean) => void; setShowAudioModal: (show: boolean) => void;
ksAudioTracks: Array<{id: number, name: string, language?: string}>; ksAudioTracks: Array<{ id: number, name: string, language?: string }>;
selectedAudioTrack: number | null; selectedAudioTrack: number | null;
selectAudioTrack: (trackId: number) => void; selectAudioTrack: (trackId: number) => void;
} }
@ -36,7 +36,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
if (!showAudioModal) return null; if (!showAudioModal) return null;
return ( return (
<View style={StyleSheet.absoluteFill} zIndex={9999}> <View style={[StyleSheet.absoluteFill, { zIndex: 9999 }]}>
{/* Backdrop matching SubtitleModal */} {/* Backdrop matching SubtitleModal */}
<TouchableOpacity <TouchableOpacity
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}

View file

@ -20,7 +20,7 @@ interface EpisodeStreamsModalProps {
metadata?: { id?: string; name?: string }; metadata?: { id?: string; name?: string };
} }
const QualityBadge = ({ quality }: { quality: string | null }) => { const QualityBadge = ({ quality }: { quality: string | null | undefined }) => {
if (!quality) return null; if (!quality) return null;
const qualityNum = parseInt(quality); const qualityNum = parseInt(quality);
@ -140,7 +140,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
const sortedProviders = Object.entries(availableStreams); const sortedProviders = Object.entries(availableStreams);
return ( return (
<View style={StyleSheet.absoluteFill} zIndex={10000}> <View style={[StyleSheet.absoluteFill, { zIndex: 10000 }]}>
{/* Backdrop */} {/* Backdrop */}
<TouchableOpacity <TouchableOpacity
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
@ -263,9 +263,9 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
)} )}
{hasErrors.length > 0 && ( {hasErrors.length > 0 && (
<View style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', borderRadius: 12, padding: 12, marginTop: 10 }}> <View style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', borderRadius: 12, padding: 12, marginTop: 10 }}>
<Text style={{ color: '#EF4444', fontSize: 11 }}>Sources might be limited due to provider errors.</Text> <Text style={{ color: '#EF4444', fontSize: 11 }}>Sources might be limited due to provider errors.</Text>
</View> </View>
)} )}
</ScrollView> </ScrollView>
</Animated.View> </Animated.View>

View file

@ -18,7 +18,7 @@ interface SourcesModalProps {
isChangingSource?: boolean; isChangingSource?: boolean;
} }
const QualityBadge = ({ quality }: { quality: string | null }) => { const QualityBadge = ({ quality }: { quality: string | null | undefined }) => {
if (!quality) return null; if (!quality) return null;
const qualityNum = parseInt(quality); const qualityNum = parseInt(quality);
@ -85,7 +85,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
}; };
return ( return (
<View style={StyleSheet.absoluteFill} zIndex={10000}> <View style={[StyleSheet.absoluteFill, { zIndex: 10000 }]}>
{/* Backdrop */} {/* Backdrop */}
<TouchableOpacity <TouchableOpacity
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}

View file

@ -62,7 +62,7 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
if (!showSpeedModal) return null; if (!showSpeedModal) return null;
return ( return (
<View style={StyleSheet.absoluteFill} zIndex={9999}> <View style={[StyleSheet.absoluteFill, { zIndex: 9999 }]}>
<TouchableOpacity <TouchableOpacity
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
activeOpacity={1} activeOpacity={1}
@ -85,7 +85,7 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
}} }}
> >
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20, alignItems: 'center' }}> <View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20, alignItems: 'center' }}>
<Text style={{ color: 'white', fontSize: 16, fontWeight: '600'}}>Playback Speed</Text> <Text style={{ color: 'white', fontSize: 16, fontWeight: '600' }}>Playback Speed</Text>
</View> </View>
{/* Speed Selection Row */} {/* Speed Selection Row */}

View file

@ -25,6 +25,8 @@ interface SubtitleModalsProps {
selectedTextTrack: number; selectedTextTrack: number;
useCustomSubtitles: boolean; useCustomSubtitles: boolean;
isKsPlayerActive?: boolean; isKsPlayerActive?: boolean;
// Whether ExoPlayer is being used (limits subtitle styling options)
useExoPlayer?: boolean;
subtitleSize: number; subtitleSize: number;
subtitleBackground: boolean; subtitleBackground: boolean;
fetchAvailableSubtitles: () => void; fetchAvailableSubtitles: () => void;
@ -81,6 +83,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
subtitleSize, subtitleBackground, fetchAvailableSubtitles, subtitleSize, subtitleBackground, fetchAvailableSubtitles,
loadWyzieSubtitle, selectTextTrack, increaseSubtitleSize, loadWyzieSubtitle, selectTextTrack, increaseSubtitleSize,
decreaseSubtitleSize, toggleSubtitleBackground, subtitleTextColor, setSubtitleTextColor, decreaseSubtitleSize, toggleSubtitleBackground, subtitleTextColor, setSubtitleTextColor,
useExoPlayer = false,
subtitleBgOpacity, setSubtitleBgOpacity, subtitleTextShadow, setSubtitleTextShadow, subtitleBgOpacity, setSubtitleBgOpacity, subtitleTextShadow, setSubtitleTextShadow,
subtitleOutline, setSubtitleOutline, subtitleOutlineColor, setSubtitleOutlineColor, subtitleOutline, setSubtitleOutline, subtitleOutlineColor, setSubtitleOutlineColor,
subtitleOutlineWidth, setSubtitleOutlineWidth, subtitleAlign, setSubtitleAlign, subtitleOutlineWidth, setSubtitleOutlineWidth, subtitleAlign, setSubtitleAlign,
@ -96,6 +99,8 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
const isCompact = width < 360 || height < 640; const isCompact = width < 360 || height < 640;
// Internal subtitle is active when a built-in track is selected AND not using custom/addon subtitles // Internal subtitle is active when a built-in track is selected AND not using custom/addon subtitles
const isUsingInternalSubtitle = selectedTextTrack >= 0 && !useCustomSubtitles; const isUsingInternalSubtitle = selectedTextTrack >= 0 && !useCustomSubtitles;
// ExoPlayer has limited styling support - hide unsupported options when using ExoPlayer with internal subs
const isExoPlayerInternal = useExoPlayer && isUsingInternalSubtitle;
const sectionPad = isCompact ? 12 : 16; const sectionPad = isCompact ? 12 : 16;
const chipPadH = isCompact ? 8 : 12; const chipPadH = isCompact ? 8 : 12;
const chipPadV = isCompact ? 6 : 8; const chipPadV = isCompact ? 6 : 8;
@ -114,7 +119,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
if (!showSubtitleModal) return null; if (!showSubtitleModal) return null;
return ( return (
<View style={StyleSheet.absoluteFill} zIndex={9999}> <View style={[StyleSheet.absoluteFill, { zIndex: 9999 }]}>
{/* Backdrop */} {/* Backdrop */}
<TouchableOpacity style={StyleSheet.absoluteFill} activeOpacity={1} onPress={handleClose}> <TouchableOpacity style={StyleSheet.absoluteFill} activeOpacity={1} onPress={handleClose}>
<Animated.View entering={FadeIn} exiting={FadeOut} style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }} /> <Animated.View entering={FadeIn} exiting={FadeOut} style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }} />
@ -182,7 +187,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
<TouchableOpacity <TouchableOpacity
key={sub.id} key={sub.id}
onPress={() => { setSelectedOnlineSubtitleId(sub.id); loadWyzieSubtitle(sub); }} onPress={() => { setSelectedOnlineSubtitleId(sub.id); loadWyzieSubtitle(sub); }}
style={{ padding: 5, paddingLeft: 8, paddingRight: 10, borderRadius: 12, backgroundColor: selectedOnlineSubtitleId === sub.id ? 'white' : 'rgba(255,255,255,0.05)', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', textAlignVertical: 'center' }} style={{ padding: 5, paddingLeft: 8, paddingRight: 10, borderRadius: 12, backgroundColor: selectedOnlineSubtitleId === sub.id ? 'white' : 'rgba(255,255,255,0.05)', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}
> >
<View> <View>
<Text style={{ marginLeft: 5, color: selectedOnlineSubtitleId === sub.id ? 'black' : 'white', fontWeight: '600' }}>{sub.display}</Text> <Text style={{ marginLeft: 5, color: selectedOnlineSubtitleId === sub.id ? 'black' : 'white', fontWeight: '600' }}>{sub.display}</Text>
@ -228,7 +233,8 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</View> </View>
</View> </View>
{/* Quick Presets */} {/* Quick Presets - Hidden for ExoPlayer internal subtitles */}
{!isExoPlayerInternal && (
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad }}> <View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad }}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 10 }}> <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 10 }}>
<MaterialIcons name="star" size={16} color="rgba(255,255,255,0.7)" /> <MaterialIcons name="star" size={16} color="rgba(255,255,255,0.7)" />
@ -272,6 +278,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
)}
{/* Core controls */} {/* Core controls */}
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad, gap: isCompact ? 10 : 14 }}> <View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad, gap: isCompact ? 10 : 14 }}>
@ -296,6 +303,8 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
{/* Show Background - Not supported on ExoPlayer internal subtitles */}
{!isExoPlayerInternal && (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}> <View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="layers" size={16} color="rgba(255,255,255,0.7)" /> <MaterialIcons name="layers" size={16} color="rgba(255,255,255,0.7)" />
@ -308,14 +317,17 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
<View style={{ width: 24, height: 24, backgroundColor: subtitleBackground ? 'black' : 'white', borderRadius: 12 }} /> <View style={{ width: 24, height: 24, backgroundColor: subtitleBackground ? 'black' : 'white', borderRadius: 12 }} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)}
</View> </View>
{/* Advanced controls */} {/* Advanced controls - Limited for ExoPlayer */}
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad, gap: isCompact ? 10 : 14 }}> <View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad, gap: isCompact ? 10 : 14 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}> <View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="build" size={16} color="rgba(255,255,255,0.7)" /> <MaterialIcons name="build" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>Advanced</Text> <Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>{isExoPlayerInternal ? 'Position' : 'Advanced'}</Text>
</View> </View>
{/* Text Color - Not supported on ExoPlayer internal subtitles */}
{!isExoPlayerInternal && (
<View style={{ marginTop: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ marginTop: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}> <View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="palette" size={16} color="rgba(255,255,255,0.7)" /> <MaterialIcons name="palette" size={16} color="rgba(255,255,255,0.7)" />
@ -327,6 +339,9 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
))} ))}
</View> </View>
</View> </View>
)}
{/* Align - Not supported on ExoPlayer internal subtitles */}
{!isExoPlayerInternal && (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Align</Text> <Text style={{ color: 'white', fontWeight: '600' }}>Align</Text>
<View style={{ flexDirection: 'row', gap: 8 }}> <View style={{ flexDirection: 'row', gap: 8 }}>
@ -337,6 +352,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
))} ))}
</View> </View>
</View> </View>
)}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Bottom Offset</Text> <Text style={{ color: 'white', fontWeight: '600' }}>Bottom Offset</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}> <View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
@ -351,6 +367,8 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
{/* Background Opacity - Not supported on ExoPlayer internal subtitles */}
{!isExoPlayerInternal && (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Background Opacity</Text> <Text style={{ color: 'white', fontWeight: '600' }}>Background Opacity</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}> <View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
@ -365,6 +383,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
)}
{!isUsingInternalSubtitle && ( {!isUsingInternalSubtitle && (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Text Shadow</Text> <Text style={{ color: 'white', fontWeight: '600' }}>Text Shadow</Text>
@ -431,6 +450,8 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</View> </View>
</View> </View>
)} )}
{/* Timing Offset - Not supported on ExoPlayer internal subtitles */}
{!isExoPlayerInternal && (
<View style={{ marginTop: 4 }}> <View style={{ marginTop: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Timing Offset (s)</Text> <Text style={{ color: 'white', fontWeight: '600' }}>Timing Offset (s)</Text>
@ -448,6 +469,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</View> </View>
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, marginTop: 6 }}>Nudge subtitles earlier (-) or later (+) to sync if needed.</Text> <Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, marginTop: 6 }}>Nudge subtitles earlier (-) or later (+) to sync if needed.</Text>
</View> </View>
)}
<View style={{ alignItems: 'flex-end', marginTop: 8 }}> <View style={{ alignItems: 'flex-end', marginTop: 8 }}>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {