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 Video, { VideoRef, SelectedTrack, BufferingStrategyType, ResizeMode } from 'react-native-video';
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 {
src: string;
headers?: { [key: string]: string };
@ -12,6 +45,8 @@ interface VideoPlayerProps {
selectedAudioTrack?: SelectedTrack;
selectedTextTrack?: SelectedTrack;
resizeMode?: ResizeMode;
// Subtitle customization - pass custom subtitle styling
subtitleStyle?: SubtitleStyleConfig;
onProgress?: (data: { currentTime: number; playableDuration: number }) => void;
onLoad?: (data: { duration: number }) => void;
onError?: (error: any) => void;
@ -29,6 +64,7 @@ export const AndroidVideoPlayer: React.FC<VideoPlayerProps> = ({
selectedAudioTrack,
selectedTextTrack,
resizeMode = 'contain' as ResizeMode,
subtitleStyle: customSubtitleStyle,
onProgress,
onLoad,
onError,
@ -41,6 +77,12 @@ export const AndroidVideoPlayer: React.FC<VideoPlayerProps> = ({
const [isSeeking, setIsSeeking] = useState(false);
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
useEffect(() => {
if (Platform.OS === 'android') {
@ -132,13 +174,21 @@ export const AndroidVideoPlayer: React.FC<VideoPlayerProps> = ({
rate={1.0}
repeat={false}
reportBandwidth={true}
textTracks={[]}
useTextureView={false}
useTextureView={true}
disableFocus={false}
minLoadRetryCount={3}
automaticallyWaitsToMinimizeStalling={true}
hideShutterView={false}
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) {
TrackGroupArray groups = info.getTrackGroups(textRendererIndex);
boolean trackFound = false;
int cumulativeIndex = 0; // Track cumulative index across all groups
for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) {
TrackGroup group = groups.get(groupIndex);
@ -2195,7 +2196,8 @@ public class ReactExoplayerView extends FrameLayout implements
isMatch = true;
} else if ("index".equals(type)) {
int targetIndex = ReactBridgeUtils.safeParseInt(value, -1);
if (targetIndex == trackIndex) {
// Use cumulative index to match getTextTrackInfo() behavior
if (targetIndex == cumulativeIndex) {
isMatch = true;
}
}
@ -2207,6 +2209,7 @@ public class ReactExoplayerView extends FrameLayout implements
trackFound = true;
break;
}
cumulativeIndex++; // Increment after each track
}
if (trackFound)
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 { 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 { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
@ -78,6 +78,7 @@ const AndroidVideoPlayer: React.FC = () => {
const videoRef = useRef<any>(null);
const mpvPlayerRef = useRef<MpvPlayerRef>(null);
const exoPlayerRef = useRef<any>(null);
const pinchRef = useRef(null);
const tracksHook = usePlayerTracks();
@ -92,6 +93,10 @@ const AndroidVideoPlayer: React.FC = () => {
// State to force unmount VideoSurface during stream transitions
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
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false);
@ -131,7 +136,9 @@ const AndroidVideoPlayer: React.FC = () => {
playerState.currentTime,
playerState.duration,
playerState.isSeeking,
playerState.isMounted
playerState.isMounted,
exoPlayerRef,
useExoPlayer
);
const traktAutosync = useTraktAutosync({
@ -327,6 +334,16 @@ const AndroidVideoPlayer: React.FC = () => {
else navigation.reset({ index: 0, routes: [{ name: 'Home' }] } as any);
}, [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) => {
if (newStream.url === currentStreamUrl) {
modals.setShowSourcesModal(false);
@ -485,6 +502,13 @@ const AndroidVideoPlayer: React.FC = () => {
else playerState.setResizeMode('contain');
}, [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 (
<View style={[styles.container, {
width: playerState.screenDimensions.width,
@ -566,12 +590,18 @@ const AndroidVideoPlayer: React.FC = () => {
}
}}
mpvPlayerRef={mpvPlayerRef}
exoPlayerRef={exoPlayerRef}
pinchRef={pinchRef}
onPinchGestureEvent={() => { }}
onPinchHandlerStateChange={() => { }}
screenDimensions={playerState.screenDimensions}
decoderMode={settings.decoderMode}
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
// 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)
@ -669,7 +699,7 @@ const AndroidVideoPlayer: React.FC = () => {
}}
buffered={playerState.buffered}
formatTime={formatTime}
playerBackend={'MPV'}
playerBackend={useExoPlayer ? 'ExoPlayer' : 'MPV'}
/>
<SpeedActivatedOverlay
@ -764,16 +794,19 @@ const AndroidVideoPlayer: React.FC = () => {
selectedTextTrack={tracksHook.computedSelectedTextTrack}
useCustomSubtitles={useCustomSubtitles}
isKsPlayerActive={true}
useExoPlayer={useExoPlayer}
subtitleSize={subtitleSize}
subtitleBackground={subtitleBackground}
fetchAvailableSubtitles={fetchAvailableSubtitles}
loadWyzieSubtitle={loadWyzieSubtitle}
selectTextTrack={(trackId) => {
tracksHook.setSelectedTextTrack(trackId);
// Actually tell MPV to switch the subtitle track
if (mpvPlayerRef.current) {
// For MPV, manually switch the subtitle track
if (!useExoPlayer && mpvPlayerRef.current) {
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
setUseCustomSubtitles(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 { PinchGestureHandler } from 'react-native-gesture-handler';
import Video, { VideoRef, SelectedTrack, SelectedVideoTrack, ResizeMode } from 'react-native-video';
import MpvPlayer, { MpvPlayerRef } from '../MpvPlayer';
import { styles } from '../../utils/playerStyles';
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 {
processedStreamUrl: string;
@ -25,6 +51,7 @@ interface VideoSurfaceProps {
// Refs
mpvPlayerRef?: React.RefObject<MpvPlayerRef>;
exoPlayerRef?: React.RefObject<VideoRef>;
pinchRef: any;
// Handlers
@ -32,8 +59,16 @@ interface VideoSurfaceProps {
onPinchHandlerStateChange: any;
screenDimensions: { width: number, height: number };
onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void;
selectedAudioTrack?: SelectedTrack;
selectedTextTrack?: SelectedTrack;
decoderMode?: 'auto' | 'sw' | 'hw' | 'hw+';
gpuMode?: 'gpu' | 'gpu-next';
// Dual Engine Props
useExoPlayer?: boolean;
onCodecError?: () => void;
onEngineChange?: (engine: 'exoplayer' | 'mpv') => void;
// Subtitle Styling
subtitleSize?: number;
subtitleColor?: string;
@ -46,6 +81,15 @@ interface VideoSurfaceProps {
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> = ({
processedStreamUrl,
headers,
@ -62,13 +106,20 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
onError,
onBuffer,
mpvPlayerRef,
exoPlayerRef,
pinchRef,
onPinchGestureEvent,
onPinchHandlerStateChange,
screenDimensions,
onTracksChanged,
selectedAudioTrack,
selectedTextTrack,
decoderMode,
gpuMode,
// Dual Engine
useExoPlayer = true,
onCodecError,
onEngineChange,
// Subtitle Styling
subtitleSize,
subtitleColor,
@ -83,10 +134,9 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
// Use the actual stream URL
const streamUrl = currentStreamUrl || processedStreamUrl;
// Debug logging removed to prevent console spam
const handleLoad = (data: { duration: number; width: number; height: number }) => {
console.log('[VideoSurface] onLoad received:', data);
// ========== MPV Handlers ==========
const handleMpvLoad = (data: { duration: number; width: number; height: number }) => {
console.log('[VideoSurface] MPV onLoad received:', data);
onLoad({
duration: data.duration,
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({
currentTime: data.currentTime,
playableDuration: data.currentTime,
});
};
const handleError = (error: { error: string }) => {
console.log('[VideoSurface] onError received:', error);
const handleMpvError = (error: { error: string }) => {
console.log('[VideoSurface] MPV onError received:', error);
onError({
error: {
errorString: error.error,
@ -112,44 +162,204 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
});
};
const handleEnd = () => {
console.log('[VideoSurface] onEnd received');
const handleMpvEnd = () => {
console.log('[VideoSurface] MPV onEnd received');
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 (
<View style={[styles.videoContainer, {
width: screenDimensions.width,
height: screenDimensions.height,
}]}>
{/* MPV Player - rendered at the bottom of the z-order */}
<MpvPlayer
ref={mpvPlayerRef}
source={streamUrl}
headers={headers}
paused={paused}
volume={volume}
rate={playbackSpeed}
resizeMode={resizeMode === 'none' ? 'contain' : resizeMode}
style={localStyles.player}
onLoad={handleLoad}
onProgress={handleProgress}
onEnd={handleEnd}
onError={handleError}
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}
/>
{useExoPlayer ? (
/* ExoPlayer via react-native-video */
<Video
ref={exoPlayerRef}
source={{
uri: streamUrl,
headers: headers,
}}
paused={paused}
volume={volume}
rate={playbackSpeed}
resizeMode={getExoResizeMode()}
selectedAudioTrack={selectedAudioTrack}
selectedTextTrack={selectedTextTrack}
style={localStyles.player}
onLoad={handleExoLoad}
onProgress={handleExoProgress}
onEnd={handleExoEnd}
onError={handleExoError}
onBuffer={handleExoBuffer}
onSeek={handleExoSeek}
progressUpdateInterval={500}
playInBackground={false}
playWhenInactive={false}
ignoreSilentSwitch="ignore"
automaticallyWaitsToMinimizeStalling={true}
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 */}
<PinchGestureHandler

View file

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

View file

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

View file

@ -20,7 +20,7 @@ interface EpisodeStreamsModalProps {
metadata?: { id?: string; name?: string };
}
const QualityBadge = ({ quality }: { quality: string | null }) => {
const QualityBadge = ({ quality }: { quality: string | null | undefined }) => {
if (!quality) return null;
const qualityNum = parseInt(quality);
@ -140,7 +140,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
const sortedProviders = Object.entries(availableStreams);
return (
<View style={StyleSheet.absoluteFill} zIndex={10000}>
<View style={[StyleSheet.absoluteFill, { zIndex: 10000 }]}>
{/* Backdrop */}
<TouchableOpacity
style={StyleSheet.absoluteFill}
@ -263,9 +263,9 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
)}
{hasErrors.length > 0 && (
<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>
</View>
<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>
</View>
)}
</ScrollView>
</Animated.View>

View file

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

View file

@ -62,7 +62,7 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
if (!showSpeedModal) return null;
return (
<View style={StyleSheet.absoluteFill} zIndex={9999}>
<View style={[StyleSheet.absoluteFill, { zIndex: 9999 }]}>
<TouchableOpacity
style={StyleSheet.absoluteFill}
activeOpacity={1}
@ -85,7 +85,7 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
}}
>
<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>
{/* Speed Selection Row */}

View file

@ -25,6 +25,8 @@ interface SubtitleModalsProps {
selectedTextTrack: number;
useCustomSubtitles: boolean;
isKsPlayerActive?: boolean;
// Whether ExoPlayer is being used (limits subtitle styling options)
useExoPlayer?: boolean;
subtitleSize: number;
subtitleBackground: boolean;
fetchAvailableSubtitles: () => void;
@ -81,6 +83,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
subtitleSize, subtitleBackground, fetchAvailableSubtitles,
loadWyzieSubtitle, selectTextTrack, increaseSubtitleSize,
decreaseSubtitleSize, toggleSubtitleBackground, subtitleTextColor, setSubtitleTextColor,
useExoPlayer = false,
subtitleBgOpacity, setSubtitleBgOpacity, subtitleTextShadow, setSubtitleTextShadow,
subtitleOutline, setSubtitleOutline, subtitleOutlineColor, setSubtitleOutlineColor,
subtitleOutlineWidth, setSubtitleOutlineWidth, subtitleAlign, setSubtitleAlign,
@ -96,6 +99,8 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
const isCompact = width < 360 || height < 640;
// Internal subtitle is active when a built-in track is selected AND not using custom/addon subtitles
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 chipPadH = isCompact ? 8 : 12;
const chipPadV = isCompact ? 6 : 8;
@ -114,7 +119,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
if (!showSubtitleModal) return null;
return (
<View style={StyleSheet.absoluteFill} zIndex={9999}>
<View style={[StyleSheet.absoluteFill, { zIndex: 9999 }]}>
{/* Backdrop */}
<TouchableOpacity style={StyleSheet.absoluteFill} activeOpacity={1} onPress={handleClose}>
<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
key={sub.id}
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>
<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>
{/* 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={{ flexDirection: 'row', alignItems: 'center', marginBottom: 10 }}>
<MaterialIcons name="star" size={16} color="rgba(255,255,255,0.7)" />
@ -272,6 +278,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</TouchableOpacity>
</View>
</View>
)}
{/* Core controls */}
<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>
</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' }}>
<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 }} />
</TouchableOpacity>
</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={{ flexDirection: 'row', alignItems: 'center' }}>
<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>
{/* Text Color - Not supported on ExoPlayer internal subtitles */}
{!isExoPlayerInternal && (
<View style={{ marginTop: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="palette" size={16} color="rgba(255,255,255,0.7)" />
@ -327,6 +339,9 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
))}
</View>
</View>
)}
{/* Align - Not supported on ExoPlayer internal subtitles */}
{!isExoPlayerInternal && (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Align</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
@ -337,6 +352,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
))}
</View>
</View>
)}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Bottom Offset</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
@ -351,6 +367,8 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</TouchableOpacity>
</View>
</View>
{/* Background Opacity - Not supported on ExoPlayer internal subtitles */}
{!isExoPlayerInternal && (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Background Opacity</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
@ -365,6 +383,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</TouchableOpacity>
</View>
</View>
)}
{!isUsingInternalSubtitle && (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Text Shadow</Text>
@ -431,6 +450,8 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</View>
</View>
)}
{/* Timing Offset - Not supported on ExoPlayer internal subtitles */}
{!isExoPlayerInternal && (
<View style={{ marginTop: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Timing Offset (s)</Text>
@ -448,6 +469,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</View>
<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 style={{ alignItems: 'flex-end', marginTop: 8 }}>
<TouchableOpacity
onPress={() => {