NuvioStreaming/src/components/player/AndroidVideoPlayer.tsx
2026-01-10 23:43:32 +05:30

1140 lines
47 KiB
TypeScript

import React, { useRef, useEffect, useMemo, useCallback, useState } from 'react';
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';
import { RootStackParamList } from '../../navigation/AppNavigator';
// Shared Hooks (cross-platform)
import {
usePlayerState,
usePlayerModals,
useSpeedControl,
useOpeningAnimation,
useWatchProgress
} from './hooks';
// Android-specific hooks
import { usePlayerSetup } from './android/hooks/usePlayerSetup';
import { usePlayerTracks } from './android/hooks/usePlayerTracks';
import { usePlayerControls } from './android/hooks/usePlayerControls';
import { useNextEpisode } from './android/hooks/useNextEpisode';
// App-level Hooks
import { useTraktAutosync } from '../../hooks/useTraktAutosync';
import { useMetadata } from '../../hooks/useMetadata';
import { usePlayerGestureControls } from '../../hooks/usePlayerGestureControls';
import { useSettings } from '../../hooks/useSettings';
// Shared Components
import { GestureControls, PauseOverlay, SpeedActivatedOverlay } from './components';
import LoadingOverlay from './modals/LoadingOverlay';
import PlayerControls from './controls/PlayerControls';
import { AudioTrackModal } from './modals/AudioTrackModal';
import { SubtitleModals } from './modals/SubtitleModals';
import { SubtitleSyncModal } from './modals/SubtitleSyncModal';
import SpeedModal from './modals/SpeedModal';
import { SourcesModal } from './modals/SourcesModal';
import { EpisodesModal } from './modals/EpisodesModal';
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
import { ErrorModal } from './modals/ErrorModal';
import { CustomSubtitles } from './subtitles/CustomSubtitles';
import ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
import SkipIntroButton from './overlays/SkipIntroButton';
import UpNextButton from './common/UpNextButton';
import { CustomAlert } from '../CustomAlert';
// Android-specific components
import { VideoSurface } from './android/components/VideoSurface';
import { MpvPlayerRef } from './android/MpvPlayer';
// Utils
import { logger } from '../../utils/logger';
import { styles } from './utils/playerStyles';
import { formatTime, isHlsStream, getHlsHeaders, defaultAndroidHeaders, parseSRT } from './utils/playerUtils';
import { storageService } from '../../services/storageService';
import stremioService from '../../services/stremioService';
import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes';
import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
import { useTheme } from '../../contexts/ThemeContext';
import axios from 'axios';
const DEBUG_MODE = false;
const AndroidVideoPlayer: React.FC = () => {
const navigation = useNavigation();
const route = useRoute<RouteProp<RootStackParamList, 'PlayerAndroid'>>();
const insets = useSafeAreaInsets();
const { currentTheme } = useTheme();
const {
uri, title = 'Episode Name', season, episode, episodeTitle, quality, year,
streamProvider, streamName, headers, id, type, episodeId, imdbId,
availableStreams: passedAvailableStreams, backdrop, groupedEpisodes
} = route.params;
// --- State & Custom Hooks ---
const playerState = usePlayerState();
const modals = usePlayerModals();
const speedControl = useSpeedControl();
const { settings } = useSettings();
const videoRef = useRef<any>(null);
const mpvPlayerRef = useRef<MpvPlayerRef>(null);
const exoPlayerRef = useRef<any>(null);
const pinchRef = useRef(null);
const tracksHook = usePlayerTracks();
const [currentStreamUrl, setCurrentStreamUrl] = useState<string>(uri);
const [currentVideoType, setCurrentVideoType] = useState<string | undefined>((route.params as any).videoType);
const [availableStreams, setAvailableStreams] = useState<any>(passedAvailableStreams || {});
const [currentQuality, setCurrentQuality] = useState(quality);
const [currentStreamProvider, setCurrentStreamProvider] = useState(streamProvider);
const [currentStreamName, setCurrentStreamName] = useState(streamName);
// State to force unmount VideoSurface during stream transitions
const [isTransitioningStream, setIsTransitioningStream] = useState(false);
// Dual video engine state: ExoPlayer primary, MPV fallback
// If videoPlayerEngine is 'mpv', always use MPV; otherwise use auto behavior
const shouldUseMpvOnly = settings.videoPlayerEngine === 'mpv';
const [useExoPlayer, setUseExoPlayer] = useState(!shouldUseMpvOnly);
const hasExoPlayerFailed = useRef(false);
const [showMpvSwitchAlert, setShowMpvSwitchAlert] = useState(false);
// Sync useExoPlayer with settings when videoPlayerEngine is set to 'mpv'
// Only run once on mount to avoid re-render loops
const hasAppliedEngineSettingRef = useRef(false);
useEffect(() => {
if (!hasAppliedEngineSettingRef.current && settings.videoPlayerEngine === 'mpv') {
hasAppliedEngineSettingRef.current = true;
setUseExoPlayer(false);
}
}, [settings.videoPlayerEngine]);
// Subtitle addon state
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false);
const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false);
const [useCustomSubtitles, setUseCustomSubtitles] = useState(false);
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
const [selectedExternalSubtitleId, setSelectedExternalSubtitleId] = useState<string | null>(null);
// Subtitle customization state
const [subtitleSize, setSubtitleSize] = useState(28);
const [subtitleBackground, setSubtitleBackground] = useState(false);
const [subtitleTextColor, setSubtitleTextColor] = useState('#FFFFFF');
const [subtitleBgOpacity, setSubtitleBgOpacity] = useState(0.7);
const [subtitleTextShadow, setSubtitleTextShadow] = useState(true);
const [subtitleOutline, setSubtitleOutline] = useState(true);
const [subtitleOutlineColor, setSubtitleOutlineColor] = useState('#000000');
const [subtitleOutlineWidth, setSubtitleOutlineWidth] = useState(3);
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 [subtitleOffsetSec, setSubtitleOffsetSec] = useState(0);
// Subtitle sync modal state
const [showSyncModal, setShowSyncModal] = useState(false);
// Track auto-selection ref to prevent duplicate selections
const hasAutoSelectedTracks = useRef(false);
// Track previous video session to reset subtitle offset only when video actually changes
const previousVideoRef = useRef<{ uri?: string; episodeId?: string }>({});
// Reset subtitle offset when starting a new video session
useEffect(() => {
const currentVideo = { uri, episodeId };
const previousVideo = previousVideoRef.current;
// Only reset if this is actually a new video (uri or episodeId changed)
if (previousVideo.uri !== undefined &&
(previousVideo.uri !== currentVideo.uri || previousVideo.episodeId !== currentVideo.episodeId)) {
setSubtitleOffsetSec(0);
}
// Update the ref for next comparison
previousVideoRef.current = currentVideo;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uri, episodeId]);
const metadataResult = useMetadata({ id: id || 'placeholder', type: (type as any) });
const { metadata, cast } = Boolean(id && type) ? (metadataResult as any) : { metadata: null, cast: [] };
const hasLogo = metadata && metadata.logo;
const openingAnimation = useOpeningAnimation(backdrop, metadata);
const [volume, setVolume] = useState(1.0);
const [brightness, setBrightness] = useState(1.0);
const setupHook = usePlayerSetup(playerState.setScreenDimensions, setVolume, setBrightness, playerState.paused);
const controlsHook = usePlayerControls(
mpvPlayerRef,
playerState.paused,
playerState.setPaused,
playerState.currentTime,
playerState.duration,
playerState.isSeeking,
playerState.isMounted,
exoPlayerRef,
useExoPlayer
);
const traktAutosync = useTraktAutosync({
id: id || '',
type: type === 'series' ? 'series' : 'movie',
title: episodeTitle || title,
year: year || 0,
imdbId: imdbId || '',
season: season,
episode: episode,
showTitle: title,
showYear: year,
showImdbId: imdbId,
episodeId: episodeId
});
const watchProgress = useWatchProgress(
id, type, episodeId,
playerState.currentTime,
playerState.duration,
playerState.paused,
traktAutosync,
controlsHook.seekToTime,
currentStreamProvider
);
const gestureControls = usePlayerGestureControls({
volume,
setVolume,
brightness,
setBrightness,
volumeRange: { min: 0, max: 1 },
volumeSensitivity: 0.006,
brightnessSensitivity: 0.004,
debugMode: DEBUG_MODE,
});
const nextEpisodeHook = useNextEpisode(type, season, episode, groupedEpisodes, (metadataResult as any)?.groupedEpisodes, episodeId);
const fadeAnim = useRef(new Animated.Value(1)).current;
useEffect(() => {
Animated.timing(fadeAnim, {
toValue: playerState.showControls ? 1 : 0,
duration: 300,
useNativeDriver: true
}).start();
}, [playerState.showControls]);
// Auto-hide controls after 3 seconds of inactivity
useEffect(() => {
// Clear any existing timeout
if (controlsTimeout.current) {
clearTimeout(controlsTimeout.current);
controlsTimeout.current = null;
}
// Only set timeout if controls are visible and video is playing
if (playerState.showControls && !playerState.paused) {
controlsTimeout.current = setTimeout(() => {
// Don't hide if user is dragging the seek bar
if (!playerState.isDragging.current) {
playerState.setShowControls(false);
}
}, 2000); // 2 seconds delay
}
// Cleanup on unmount or when dependencies change
return () => {
if (controlsTimeout.current) {
clearTimeout(controlsTimeout.current);
controlsTimeout.current = null;
}
};
}, [playerState.showControls, playerState.paused, playerState.isDragging]);
useEffect(() => {
openingAnimation.startOpeningAnimation();
}, []);
// Load subtitle settings on mount
useEffect(() => {
const loadSubtitleSettings = async () => {
const settings = await storageService.getSubtitleSettings();
if (settings) {
if (settings.subtitleSize !== undefined) setSubtitleSize(settings.subtitleSize);
if (settings.subtitleBackground !== undefined) setSubtitleBackground(settings.subtitleBackground);
if (settings.subtitleTextColor !== undefined) setSubtitleTextColor(settings.subtitleTextColor);
if (settings.subtitleBgOpacity !== undefined) setSubtitleBgOpacity(settings.subtitleBgOpacity);
if (settings.subtitleTextShadow !== undefined) setSubtitleTextShadow(settings.subtitleTextShadow);
if (settings.subtitleOutline !== undefined) setSubtitleOutline(settings.subtitleOutline);
if (settings.subtitleOutlineColor !== undefined) setSubtitleOutlineColor(settings.subtitleOutlineColor);
if (settings.subtitleOutlineWidth !== undefined) setSubtitleOutlineWidth(settings.subtitleOutlineWidth);
if (settings.subtitleAlign !== undefined) setSubtitleAlign(settings.subtitleAlign);
if (settings.subtitleBottomOffset !== undefined) setSubtitleBottomOffset(settings.subtitleBottomOffset);
if (settings.subtitleLetterSpacing !== undefined) setSubtitleLetterSpacing(settings.subtitleLetterSpacing);
if (settings.subtitleLineHeightMultiplier !== undefined) setSubtitleLineHeightMultiplier(settings.subtitleLineHeightMultiplier);
}
};
loadSubtitleSettings();
}, []);
// Save subtitle settings when they change
useEffect(() => {
const saveSettings = async () => {
await storageService.saveSubtitleSettings({
subtitleSize,
subtitleBackground,
subtitleTextColor,
subtitleBgOpacity,
subtitleTextShadow,
subtitleOutline,
subtitleOutlineColor,
subtitleOutlineWidth,
subtitleAlign,
subtitleBottomOffset,
subtitleLetterSpacing,
subtitleLineHeightMultiplier,
});
};
saveSettings();
}, [
subtitleSize, subtitleBackground, subtitleTextColor, subtitleBgOpacity,
subtitleTextShadow, subtitleOutline, subtitleOutlineColor, subtitleOutlineWidth,
subtitleAlign, subtitleBottomOffset, subtitleLetterSpacing, subtitleLineHeightMultiplier
]);
const handleLoad = useCallback((data: any) => {
if (!playerState.isMounted.current) return;
const videoDuration = data.duration;
console.log('[AndroidVideoPlayer] handleLoad called:', {
duration: videoDuration,
initialPosition: watchProgress.initialPosition,
showResumeOverlay: watchProgress.showResumeOverlay,
initialSeekTarget: watchProgress.initialSeekTargetRef?.current
});
if (videoDuration > 0) {
playerState.setDuration(videoDuration);
if (id && type) {
storageService.setContentDuration(id, type, videoDuration, episodeId);
storageService.updateProgressDuration(id, type, videoDuration, episodeId);
}
}
if (data.naturalSize) {
playerState.setVideoAspectRatio(data.naturalSize.width / data.naturalSize.height);
} else {
playerState.setVideoAspectRatio(16 / 9);
}
if (data.audioTracks) {
const formatted = data.audioTracks.map((t: any, i: number) => ({
// react-native-video selectedAudioTrack {type:'index'} uses 0-based list index.
id: i,
name: t.title || t.name || `Track ${i + 1}`,
language: t.language
}));
tracksHook.setRnVideoAudioTracks(formatted);
}
if (data.textTracks) {
const formatted = data.textTracks.map((t: any, i: number) => ({
// react-native-video selectedTextTrack {type:'index'} uses 0-based list index.
// Using `t.index` can be non-unique/misaligned and breaks selection/rendering.
id: i,
name: t.title || t.name || `Track ${i + 1}`,
language: t.language
}));
tracksHook.setRnVideoTextTracks(formatted);
}
playerState.setIsVideoLoaded(true);
openingAnimation.completeOpeningAnimation();
// Auto-select audio track based on preferences
if (data.audioTracks && data.audioTracks.length > 0 && settings?.preferredAudioLanguage) {
const formatted = data.audioTracks.map((t: any, i: number) => ({
id: i,
name: t.title || t.name || `Track ${i + 1}`,
language: t.language
}));
const bestAudioTrack = findBestAudioTrack(formatted, settings.preferredAudioLanguage);
if (bestAudioTrack !== null) {
logger.debug(`[AndroidVideoPlayer] Auto-selecting audio track ${bestAudioTrack} for language: ${settings.preferredAudioLanguage}`);
tracksHook.setSelectedAudioTrack({ type: 'index', value: bestAudioTrack });
}
}
// Auto-select subtitle track based on preferences
// Only auto-select internal tracks here if preference is 'internal' or 'any'
// If preference is 'external', we wait for the useEffect to handle selection after external subs load
if (data.textTracks && data.textTracks.length > 0 && !hasAutoSelectedTracks.current && settings?.enableSubtitleAutoSelect) {
const sourcePreference = settings?.subtitleSourcePreference || 'internal';
// Only pre-select internal if preference is internal or any
if (sourcePreference === 'internal' || sourcePreference === 'any') {
const formatted = data.textTracks.map((t: any, i: number) => ({
id: i,
name: t.title || t.name || `Track ${i + 1}`,
language: t.language
}));
const subtitleSelection = findBestSubtitleTrack(
formatted,
[], // External subtitles not yet loaded
{
preferredSubtitleLanguage: settings?.preferredSubtitleLanguage || 'en',
subtitleSourcePreference: sourcePreference,
enableSubtitleAutoSelect: true
}
);
if (subtitleSelection.type === 'internal' && subtitleSelection.internalTrackId !== undefined) {
logger.debug(`[AndroidVideoPlayer] Auto-selecting internal subtitle track ${subtitleSelection.internalTrackId}`);
tracksHook.setSelectedTextTrack(subtitleSelection.internalTrackId);
hasAutoSelectedTracks.current = true;
}
}
// If preference is 'external', don't select anything here - useEffect will handle it
}
// Handle Resume - check both initialPosition and initialSeekTargetRef
const resumeTarget = watchProgress.initialPosition || watchProgress.initialSeekTargetRef?.current;
if (resumeTarget && resumeTarget > 0 && !watchProgress.showResumeOverlay && videoDuration > 0) {
const seekPosition = Math.min(resumeTarget, videoDuration - 0.5);
console.log('[AndroidVideoPlayer] Seeking to resume position:', seekPosition, 'duration:', videoDuration, 'useExoPlayer:', useExoPlayer);
// Use a small delay to ensure the player is ready
// Directly use refs to avoid stale closure issues
setTimeout(() => {
console.log('[AndroidVideoPlayer] Executing resume seek to:', seekPosition, 'ExoPlayer available:', !!exoPlayerRef.current, 'MPV available:', !!mpvPlayerRef.current);
if (useExoPlayer && exoPlayerRef.current) {
console.log('[AndroidVideoPlayer] Seeking ExoPlayer to resume position:', seekPosition);
exoPlayerRef.current.seek(seekPosition);
} else if (mpvPlayerRef.current) {
console.log('[AndroidVideoPlayer] Seeking MPV to resume position:', seekPosition);
mpvPlayerRef.current.seek(seekPosition);
} else {
console.warn('[AndroidVideoPlayer] No player ref available for resume seek');
}
}, 300);
}
}, [id, type, episodeId, playerState.isMounted, watchProgress.initialPosition, useExoPlayer]);
const handleProgress = useCallback((data: any) => {
if (playerState.isDragging.current || playerState.isSeeking.current || !playerState.isMounted.current || setupHook.isAppBackgrounded.current) return;
const currentTimeInSeconds = data.currentTime;
if (Math.abs(currentTimeInSeconds - playerState.currentTime) > 0.5) {
playerState.setCurrentTime(currentTimeInSeconds);
playerState.setBuffered(data.playableDuration || currentTimeInSeconds);
}
}, [playerState.currentTime, playerState.isDragging, playerState.isSeeking, setupHook.isAppBackgrounded]);
// Auto-select subtitles when both internal tracks and video are loaded
// This ensures we wait for internal tracks before falling back to external
useEffect(() => {
if (!playerState.isVideoLoaded || hasAutoSelectedTracks.current || !settings?.enableSubtitleAutoSelect) {
return;
}
const internalTracks = tracksHook.ksTextTracks;
const externalSubs = availableSubtitles;
// Wait a short delay to ensure tracks are fully populated
const timeoutId = setTimeout(() => {
if (hasAutoSelectedTracks.current) return;
const subtitleSelection = findBestSubtitleTrack(
internalTracks,
externalSubs,
{
preferredSubtitleLanguage: settings?.preferredSubtitleLanguage || 'en',
subtitleSourcePreference: settings?.subtitleSourcePreference || 'internal',
enableSubtitleAutoSelect: true
}
);
// Trust the findBestSubtitleTrack function's decision - it already implements priority logic
if (subtitleSelection.type === 'internal' && subtitleSelection.internalTrackId !== undefined) {
logger.debug(`[AndroidVideoPlayer] Auto-selecting internal subtitle track ${subtitleSelection.internalTrackId}`);
tracksHook.setSelectedTextTrack(subtitleSelection.internalTrackId);
hasAutoSelectedTracks.current = true;
} else if (subtitleSelection.type === 'external' && subtitleSelection.externalSubtitle) {
logger.debug(`[AndroidVideoPlayer] Auto-selecting external subtitle: ${subtitleSelection.externalSubtitle.display}`);
loadWyzieSubtitle(subtitleSelection.externalSubtitle);
hasAutoSelectedTracks.current = true;
}
}, 500); // Short delay to ensure tracks are populated
return () => clearTimeout(timeoutId);
}, [playerState.isVideoLoaded, tracksHook.ksTextTracks, availableSubtitles, settings]);
// Sync custom subtitle text with current playback time
useEffect(() => {
if (!useCustomSubtitles || customSubtitles.length === 0) return;
// Apply timing offset for custom/addon subtitles (ExoPlayer internal subtitles do not support offset)
const adjustedTime = playerState.currentTime + (subtitleOffsetSec || 0);
const cueNow = customSubtitles.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end);
setCurrentSubtitle(cueNow ? cueNow.text : '');
}, [playerState.currentTime, subtitleOffsetSec, useCustomSubtitles, customSubtitles]);
const toggleControls = useCallback(() => {
playerState.setShowControls(prev => {
// If we're showing controls, the useEffect will handle the auto-hide timer
return !prev;
});
}, []);
const hideControls = useCallback(() => {
if (playerState.isDragging.current) return;
playerState.setShowControls(false);
}, []);
const loadStartAtRef = useRef<number | null>(null);
const firstFrameAtRef = useRef<number | null>(null);
const controlsTimeout = useRef<NodeJS.Timeout | null>(null);
const handleClose = useCallback(() => {
if (navigation.canGoBack()) navigation.goBack();
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 playback issue', ToastAndroid.SHORT);
setUseExoPlayer(false);
}
}, []);
// Handle manual switch to MPV - for users experiencing black screen
const handleManualSwitchToMPV = useCallback(() => {
if (useExoPlayer && !hasExoPlayerFailed.current) {
setShowMpvSwitchAlert(true);
}
}, [useExoPlayer]);
// Confirm and execute the switch to MPV
const confirmSwitchToMPV = useCallback(() => {
hasExoPlayerFailed.current = true;
logger.info('[AndroidVideoPlayer] User confirmed switch to MPV');
ToastAndroid.show('Switching to MPV player...', ToastAndroid.SHORT);
// Store current playback position before switching
const currentPos = playerState.currentTime;
// Switch to MPV
setUseExoPlayer(false);
// Seek to current position after a brief delay to ensure MPV is loaded
setTimeout(() => {
if (mpvPlayerRef.current && currentPos > 0) {
mpvPlayerRef.current.seek(currentPos);
}
}, 500);
}, [playerState.currentTime]);
const handleSelectStream = async (newStream: any) => {
if (newStream.url === currentStreamUrl) {
modals.setShowSourcesModal(false);
return;
}
modals.setShowSourcesModal(false);
playerState.setPaused(true);
// Unmount VideoSurface first to ensure MPV is fully destroyed
setIsTransitioningStream(true);
const newQuality = newStream.quality || newStream.title?.match(/(\d+)p/)?.[0];
const newProvider = newStream.addonName || newStream.name || newStream.addon || 'Unknown';
const newStreamName = newStream.name || newStream.title || 'Unknown';
// Wait for unmount to complete, then navigate
setTimeout(() => {
(navigation as any).replace('PlayerAndroid', {
...route.params,
uri: newStream.url,
quality: newQuality,
streamProvider: newProvider,
streamName: newStreamName,
headers: newStream.headers,
availableStreams: availableStreams
});
}, 300);
};
const handleEpisodeStreamSelect = async (stream: any) => {
if (!modals.selectedEpisodeForStreams) return;
modals.setShowEpisodeStreamsModal(false);
playerState.setPaused(true);
// Unmount VideoSurface first to ensure MPV is fully destroyed
setIsTransitioningStream(true);
const ep = modals.selectedEpisodeForStreams;
const newQuality = stream.quality || (stream.title?.match(/(\d+)p/)?.[0]);
const newProvider = stream.addonName || stream.name || stream.addon || 'Unknown';
const newStreamName = stream.name || stream.title || 'Unknown Stream';
// Wait for unmount to complete, then navigate
setTimeout(() => {
(navigation as any).replace('PlayerAndroid', {
uri: stream.url,
title: title,
episodeTitle: ep.name,
season: ep.season_number,
episode: ep.episode_number,
quality: newQuality,
year: year,
streamProvider: newProvider,
streamName: newStreamName,
headers: stream.headers || undefined,
id,
type: 'series',
episodeId: ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`,
imdbId: imdbId ?? undefined,
backdrop: backdrop || undefined,
availableStreams: {},
groupedEpisodes: groupedEpisodes,
});
}, 300);
};
// Subtitle addon fetching
const fetchAvailableSubtitles = useCallback(async () => {
const targetImdbId = imdbId;
if (!targetImdbId) {
logger.warn('[AndroidVideoPlayer] No IMDB ID for subtitle fetch');
return;
}
setIsLoadingSubtitleList(true);
try {
const stremioType = type === 'series' ? 'series' : 'movie';
const stremioVideoId = stremioType === 'series' && season && episode
? `series:${targetImdbId}:${season}:${episode}`
: undefined;
const results = await stremioService.getSubtitles(stremioType, targetImdbId, stremioVideoId);
const subs: WyzieSubtitle[] = (results || []).map((sub: any) => ({
id: sub.id || `${sub.lang}-${sub.url}`,
url: sub.url,
flagUrl: '',
format: 'srt',
encoding: 'utf-8',
media: sub.addonName || sub.addon || '',
display: sub.lang || 'Unknown',
language: (sub.lang || '').toLowerCase(),
isHearingImpaired: false,
source: sub.addonName || sub.addon || 'Addon',
}));
setAvailableSubtitles(subs);
logger.info(`[AndroidVideoPlayer] Fetched ${subs.length} addon subtitles`);
// Auto-selection is now handled by useEffect that waits for internal tracks
} catch (e) {
logger.error('[AndroidVideoPlayer] Error fetching addon subtitles', e);
} finally {
setIsLoadingSubtitleList(false);
}
}, [imdbId, type, season, episode]);
const loadWyzieSubtitle = useCallback(async (subtitle: WyzieSubtitle) => {
if (!subtitle.url) return;
modals.setShowSubtitleModal(false);
setIsLoadingSubtitles(true);
try {
// Download subtitle file
let srtContent = '';
try {
const resp = await axios.get(subtitle.url, { timeout: 10000 });
srtContent = typeof resp.data === 'string' ? resp.data : String(resp.data);
} catch {
const resp = await fetch(subtitle.url);
srtContent = await resp.text();
}
// Parse subtitle file
const parsedCues = parseSRT(srtContent);
setCustomSubtitles(parsedCues);
setUseCustomSubtitles(true);
setSelectedExternalSubtitleId(subtitle.id); // Track the selected external subtitle
// Disable MPV's built-in subtitle track when using custom subtitles
tracksHook.setSelectedTextTrack(-1);
if (mpvPlayerRef.current) {
mpvPlayerRef.current.setSubtitleTrack(-1);
}
// Set initial subtitle based on current time (+ any timing offset)
const adjustedTime = playerState.currentTime + (subtitleOffsetSec || 0);
const cueNow = parsedCues.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end);
setCurrentSubtitle(cueNow ? cueNow.text : '');
logger.info(`[AndroidVideoPlayer] Loaded addon subtitle: ${subtitle.display} (${parsedCues.length} cues)`);
toast.success(`Subtitle loaded: ${subtitle.display}`);
} catch (e) {
logger.error('[AndroidVideoPlayer] Error loading subtitle', e);
toast.error('Failed to load subtitle');
} finally {
setIsLoadingSubtitles(false);
}
}, [modals, playerState.currentTime, subtitleOffsetSec, tracksHook]);
const disableCustomSubtitles = useCallback(() => {
setUseCustomSubtitles(false);
setCustomSubtitles([]);
setCurrentSubtitle('');
setSelectedExternalSubtitleId(null); // Clear external selection
}, []);
const cycleResizeMode = useCallback(() => {
if (playerState.resizeMode === 'contain') playerState.setResizeMode('cover');
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,
height: playerState.screenDimensions.height,
position: 'absolute', top: 0, left: 0
}]}>
<LoadingOverlay
visible={!openingAnimation.shouldHideOpeningOverlay}
backdrop={backdrop || null}
hasLogo={hasLogo}
logo={metadata?.logo}
backgroundFadeAnim={openingAnimation.backgroundFadeAnim}
backdropImageOpacityAnim={openingAnimation.backdropImageOpacityAnim}
onClose={handleClose}
width={playerState.screenDimensions.width}
height={playerState.screenDimensions.height}
/>
<View style={{ flex: 1, backgroundColor: 'black' }}>
{!isTransitioningStream && (
<VideoSurface
processedStreamUrl={currentStreamUrl}
headers={headers}
volume={volume}
playbackSpeed={speedControl.playbackSpeed}
resizeMode={playerState.resizeMode}
paused={playerState.paused}
currentStreamUrl={currentStreamUrl}
toggleControls={toggleControls}
onLoad={handleLoad}
onProgress={handleProgress}
onSeek={(data) => {
playerState.isSeeking.current = false;
if (data.currentTime) traktAutosync.handleProgressUpdate(data.currentTime, playerState.duration, true);
}}
onEnd={() => {
if (modals.showEpisodeStreamsModal) return;
playerState.setPaused(true);
}}
onError={(err: any) => {
logger.error('Video Error', err);
// Determine the actual error message
let displayError = 'An unknown error occurred';
if (typeof err?.error === 'string') {
displayError = err.error;
} else if (err?.error?.errorString) {
displayError = err.error.errorString;
} else if (err?.errorString) {
displayError = err.errorString;
} else if (typeof err === 'string') {
displayError = err;
} else {
displayError = JSON.stringify(err);
}
modals.setErrorDetails(displayError);
modals.setShowErrorModal(true);
}}
onBuffer={(buf) => playerState.setIsBuffering(buf.isBuffering)}
onTracksChanged={(data) => {
console.log('[AndroidVideoPlayer] onTracksChanged:', data);
if (data?.audioTracks) {
const formatted = data.audioTracks.map((t: any) => ({
id: t.id,
name: t.name || `Track ${t.id}`,
language: t.language
}));
tracksHook.setRnVideoAudioTracks(formatted);
}
if (data?.subtitleTracks) {
const formatted = data.subtitleTracks.map((t: any) => ({
id: t.id,
name: t.name || `Track ${t.id}`,
language: t.language
}));
tracksHook.setRnVideoTextTracks(formatted);
}
}}
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)
// - Border: MPV needs ~1.5x larger values
// - Position: MPV sub-pos uses 0=top, 100=bottom, >100=below screen
subtitleSize={Math.round(subtitleSize * 1.5)}
subtitleColor={subtitleTextColor}
subtitleBackgroundOpacity={subtitleBackground ? subtitleBgOpacity : 0}
subtitleBorderSize={subtitleOutline ? Math.round(subtitleOutlineWidth * 1.5) : 0}
subtitleBorderColor={subtitleOutlineColor}
subtitleShadowEnabled={subtitleTextShadow}
subtitlePosition={Math.max(50, 100 - Math.floor(subtitleBottomOffset * 0.3))} // Scale offset to MPV range
subtitleDelay={subtitleOffsetSec}
subtitleAlignment={subtitleAlign}
/>
)}
{/* Custom Subtitles for addon subtitles */}
<CustomSubtitles
useCustomSubtitles={useCustomSubtitles}
currentSubtitle={currentSubtitle}
subtitleSize={subtitleSize}
subtitleBackground={subtitleBackground}
zoomScale={1.0}
textColor={subtitleTextColor}
backgroundOpacity={subtitleBgOpacity}
textShadow={subtitleTextShadow}
outline={subtitleOutline}
outlineColor={subtitleOutlineColor}
outlineWidth={subtitleOutlineWidth}
align={subtitleAlign}
bottomOffset={subtitleBottomOffset}
letterSpacing={subtitleLetterSpacing}
lineHeightMultiplier={subtitleLineHeightMultiplier}
controlsVisible={playerState.showControls}
controlsExtraOffset={100}
/>
<GestureControls
screenDimensions={playerState.screenDimensions}
gestureControls={gestureControls}
onLongPressActivated={speedControl.activateSpeedBoost}
onLongPressEnd={speedControl.deactivateSpeedBoost}
onLongPressStateChange={(e) => {
if (e.nativeEvent.state !== 4 && e.nativeEvent.state !== 2) speedControl.deactivateSpeedBoost();
}}
toggleControls={toggleControls}
showControls={playerState.showControls}
hideControls={hideControls}
volume={volume}
brightness={brightness}
controlsTimeout={controlsTimeout}
/>
<PlayerControls
showControls={playerState.showControls}
fadeAnim={fadeAnim}
paused={playerState.paused}
title={title}
episodeTitle={episodeTitle}
season={season}
episode={episode}
quality={currentQuality || quality}
year={year}
streamProvider={currentStreamProvider || streamProvider}
streamName={currentStreamName}
currentTime={playerState.currentTime}
duration={playerState.duration}
zoomScale={1}
currentResizeMode={playerState.resizeMode}
ksAudioTracks={tracksHook.ksAudioTracks}
selectedAudioTrack={tracksHook.computedSelectedAudioTrack}
availableStreams={availableStreams}
togglePlayback={controlsHook.togglePlayback}
skip={controlsHook.skip}
handleClose={handleClose}
cycleAspectRatio={cycleResizeMode}
cyclePlaybackSpeed={() => {
const speeds = [0.5, 1, 1.25, 1.5, 2];
const idx = speeds.indexOf(speedControl.playbackSpeed);
const next = speeds[(idx + 1) % speeds.length];
speedControl.setPlaybackSpeed(next);
}}
currentPlaybackSpeed={speedControl.playbackSpeed}
setShowAudioModal={modals.setShowAudioModal}
setShowSubtitleModal={modals.setShowSubtitleModal}
setShowSpeedModal={modals.setShowSpeedModal}
isSubtitleModalOpen={modals.showSubtitleModal}
setShowSourcesModal={modals.setShowSourcesModal}
setShowEpisodesModal={type === 'series' ? modals.setShowEpisodesModal : undefined}
onSliderValueChange={(val) => { playerState.isDragging.current = true; }}
onSlidingStart={() => { playerState.isDragging.current = true; }}
onSlidingComplete={(val) => {
playerState.isDragging.current = false;
controlsHook.seekToTime(val);
}}
buffered={playerState.buffered}
formatTime={formatTime}
playerBackend={useExoPlayer ? 'ExoPlayer' : 'MPV'}
onSwitchToMPV={handleManualSwitchToMPV}
useExoPlayer={useExoPlayer}
/>
<SpeedActivatedOverlay
visible={speedControl.showSpeedActivatedOverlay}
opacity={speedControl.speedActivatedOverlayOpacity}
speed={speedControl.holdToSpeedValue}
screenDimensions={playerState.screenDimensions}
/>
<PauseOverlay
visible={playerState.paused && !playerState.showControls}
onClose={() => playerState.setShowControls(true)}
title={title}
episodeTitle={episodeTitle}
season={season}
episode={episode}
year={year}
type={type || 'movie'}
description={nextEpisodeHook.currentEpisodeDescription || ''}
cast={cast}
screenDimensions={playerState.screenDimensions}
/>
{/* Parental Guide Overlay - Shows after controls first hide */}
<ParentalGuideOverlay
imdbId={imdbId || (id?.startsWith('tt') ? id : undefined)}
type={type as 'movie' | 'series'}
season={season}
episode={episode}
shouldShow={playerState.isVideoLoaded && !playerState.showControls && !playerState.paused}
/>
{/* Skip Intro Button - Shows during intro section of TV episodes */}
<SkipIntroButton
imdbId={imdbId || (id?.startsWith('tt') ? id : undefined)}
type={type || 'movie'}
season={season}
episode={episode}
malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id}
kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined}
currentTime={playerState.currentTime}
onSkip={(endTime) => controlsHook.seekToTime(endTime)}
controlsVisible={playerState.showControls}
controlsFixedOffset={100}
/>
{/* Up Next Button - Shows near end of episodes */}
<UpNextButton
type={type || 'movie'}
nextEpisode={nextEpisodeHook.nextEpisode}
currentTime={playerState.currentTime}
duration={playerState.duration}
insets={insets}
isLoading={false}
nextLoadingProvider={null}
nextLoadingQuality={null}
nextLoadingTitle={null}
onPress={() => {
if (nextEpisodeHook.nextEpisode) {
logger.log(`[AndroidVideoPlayer] Opening streams for next episode: S${nextEpisodeHook.nextEpisode.season_number}E${nextEpisodeHook.nextEpisode.episode_number}`);
modals.setSelectedEpisodeForStreams(nextEpisodeHook.nextEpisode);
modals.setShowEpisodeStreamsModal(true);
}
}}
metadata={metadataResult?.metadata ? { poster: metadataResult.metadata.poster, id: metadataResult.metadata.id } : undefined}
controlsVisible={playerState.showControls}
controlsFixedOffset={100}
/>
</View>
<AudioTrackModal
showAudioModal={modals.showAudioModal}
setShowAudioModal={modals.setShowAudioModal}
ksAudioTracks={tracksHook.ksAudioTracks}
selectedAudioTrack={tracksHook.computedSelectedAudioTrack}
selectAudioTrack={(trackId) => {
tracksHook.setSelectedAudioTrack(trackId === null ? null : { type: 'index', value: trackId });
// Actually tell MPV to switch the audio track
if (trackId !== null && mpvPlayerRef.current) {
mpvPlayerRef.current.setAudioTrack(trackId);
}
}}
/>
<SubtitleModals
showSubtitleModal={modals.showSubtitleModal}
setShowSubtitleModal={modals.setShowSubtitleModal}
showSubtitleLanguageModal={false}
setShowSubtitleLanguageModal={() => { }}
isLoadingSubtitleList={isLoadingSubtitleList}
isLoadingSubtitles={isLoadingSubtitles}
customSubtitles={[]}
availableSubtitles={availableSubtitles}
ksTextTracks={tracksHook.ksTextTracks}
selectedTextTrack={tracksHook.computedSelectedTextTrack}
useCustomSubtitles={useCustomSubtitles}
isKsPlayerActive={true}
useExoPlayer={useExoPlayer}
subtitleSize={subtitleSize}
subtitleBackground={subtitleBackground}
fetchAvailableSubtitles={fetchAvailableSubtitles}
loadWyzieSubtitle={loadWyzieSubtitle}
selectTextTrack={(trackId) => {
tracksHook.setSelectedTextTrack(trackId);
// 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);
}}
disableCustomSubtitles={disableCustomSubtitles}
increaseSubtitleSize={() => setSubtitleSize(prev => Math.min(prev + 2, 60))}
decreaseSubtitleSize={() => setSubtitleSize(prev => Math.max(prev - 2, 12))}
toggleSubtitleBackground={() => setSubtitleBackground(prev => !prev)}
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}
subtitleOffsetSec={subtitleOffsetSec}
setSubtitleOffsetSec={setSubtitleOffsetSec}
selectedExternalSubtitleId={selectedExternalSubtitleId}
onOpenSyncModal={() => setShowSyncModal(true)}
/>
{/* Visual Subtitle Sync Modal */}
<SubtitleSyncModal
visible={showSyncModal}
onClose={() => setShowSyncModal(false)}
onConfirm={(offset) => setSubtitleOffsetSec(offset)}
currentOffset={subtitleOffsetSec}
currentTime={playerState.currentTime}
subtitles={customSubtitles}
primaryColor={currentTheme.colors.primary}
/>
<SourcesModal
showSourcesModal={modals.showSourcesModal}
setShowSourcesModal={modals.setShowSourcesModal}
availableStreams={availableStreams}
currentStreamUrl={currentStreamUrl}
onSelectStream={(stream) => handleSelectStream(stream)}
/>
<SpeedModal
showSpeedModal={modals.showSpeedModal}
setShowSpeedModal={modals.setShowSpeedModal}
currentSpeed={speedControl.playbackSpeed}
setPlaybackSpeed={speedControl.setPlaybackSpeed}
holdToSpeedEnabled={speedControl.holdToSpeedEnabled}
setHoldToSpeedEnabled={speedControl.setHoldToSpeedEnabled}
holdToSpeedValue={speedControl.holdToSpeedValue}
setHoldToSpeedValue={speedControl.setHoldToSpeedValue}
/>
<EpisodesModal
showEpisodesModal={modals.showEpisodesModal}
setShowEpisodesModal={modals.setShowEpisodesModal}
groupedEpisodes={groupedEpisodes || (metadataResult as any)?.groupedEpisodes}
currentEpisode={season && episode ? { season, episode } : undefined}
metadata={metadata}
onSelectEpisode={(ep) => {
modals.setSelectedEpisodeForStreams(ep);
modals.setShowEpisodesModal(false);
modals.setShowEpisodeStreamsModal(true);
}}
/>
<ErrorModal
showErrorModal={modals.showErrorModal}
setShowErrorModal={modals.setShowErrorModal}
errorDetails={modals.errorDetails}
onDismiss={handleClose}
/>
<EpisodeStreamsModal
visible={modals.showEpisodeStreamsModal}
onClose={() => modals.setShowEpisodeStreamsModal(false)}
episode={modals.selectedEpisodeForStreams}
onSelectStream={handleEpisodeStreamSelect}
metadata={{ id: id, name: title }}
/>
{/* MPV Switch Confirmation Alert */}
<CustomAlert
visible={showMpvSwitchAlert}
title="Switch to MPV Player?"
message="This will switch from ExoPlayer to MPV player. Use this if you're facing playback issues that don't automatically switch to MPV. The switch cannot be undone during this playback session."
onClose={() => setShowMpvSwitchAlert(false)}
actions={[
{
label: 'Cancel',
onPress: () => setShowMpvSwitchAlert(false),
},
{
label: 'Switch to MPV',
onPress: () => {
setShowMpvSwitchAlert(false);
confirmSwitchToMPV();
},
},
]}
/>
</View>
);
};
export default AndroidVideoPlayer;