NuvioStreaming/src/components/player/AndroidVideoPlayer.tsx
2025-12-22 21:40:11 +05:30

614 lines
24 KiB
TypeScript

import React, { useRef, useEffect, useMemo, useCallback, useState } from 'react';
import { View, StyleSheet, Platform, Animated } 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
} from './hooks';
// Android-specific hooks (VLC integration, dual player support)
import { usePlayerSetup } from './android/hooks/usePlayerSetup';
import { useVlcPlayer } from './android/hooks/useVlcPlayer';
import { usePlayerTracks } from './android/hooks/usePlayerTracks';
import { useWatchProgress } from './android/hooks/useWatchProgress';
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';
// 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 SpeedModal from './modals/SpeedModal';
import { SourcesModal } from './modals/SourcesModal';
import { EpisodesModal } from './modals/EpisodesModal';
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
import { ErrorModal } from './modals/ErrorModal';
// 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, processUrlForVLC, getHlsHeaders, defaultAndroidHeaders } from './utils/playerUtils';
import { storageService } from '../../services/storageService';
const DEBUG_MODE = false;
const AndroidVideoPlayer: React.FC = () => {
const navigation = useNavigation();
const route = useRoute<RouteProp<RootStackParamList, 'PlayerAndroid'>>();
const insets = useSafeAreaInsets();
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 forceVlc = useMemo(() => {
const rp: any = route.params || {};
const v = rp.forceVlc !== undefined ? rp.forceVlc : rp.forceVLC;
return typeof v === 'string' ? v.toLowerCase() === 'true' : Boolean(v);
}, [route.params]);
const useVLC = (Platform.OS === 'android' && forceVlc);
const videoRef = useRef<any>(null);
const mpvPlayerRef = useRef<MpvPlayerRef>(null);
const vlcHook = useVlcPlayer(useVLC, playerState.paused, playerState.currentTime);
const tracksHook = usePlayerTracks(
useVLC,
vlcHook.vlcAudioTracks,
vlcHook.vlcSubtitleTracks,
vlcHook.vlcSelectedAudioTrack,
vlcHook.vlcSelectedSubtitleTrack
);
const [currentStreamUrl, setCurrentStreamUrl] = useState<string>(uri);
const [currentVideoType, setCurrentVideoType] = useState<string | undefined>((route.params as any).videoType);
const processedStreamUrl = useMemo(() => useVLC ? processUrlForVLC(currentStreamUrl) : currentStreamUrl, [currentStreamUrl, useVLC]);
const [availableStreams, setAvailableStreams] = useState<any>(passedAvailableStreams || {});
const [currentQuality, setCurrentQuality] = useState(quality);
const [currentStreamProvider, setCurrentStreamProvider] = useState(streamProvider);
const [currentStreamName, setCurrentStreamName] = useState(streamName);
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, // Use mpvPlayerRef for MPV player
vlcHook.vlcPlayerRef,
useVLC,
playerState.paused,
playerState.setPaused,
playerState.currentTime,
playerState.duration,
playerState.isSeeking,
playerState.isMounted
);
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
);
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]);
useEffect(() => {
openingAnimation.startOpeningAnimation();
}, []);
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 (!useVLC) {
if (data.audioTracks) {
const formatted = data.audioTracks.map((t: any, i: number) => ({
id: t.index !== undefined ? t.index : 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) => ({
id: t.index !== undefined ? t.index : i,
name: t.title || t.name || `Track ${i + 1}`,
language: t.language
}));
tracksHook.setRnVideoTextTracks(formatted);
}
}
playerState.setIsVideoLoaded(true);
openingAnimation.completeOpeningAnimation();
// Handle Resume - check both initialPosition and initialSeekTargetRef
const resumeTarget = watchProgress.initialPosition || watchProgress.initialSeekTargetRef?.current;
if (resumeTarget && resumeTarget > 0 && !watchProgress.showResumeOverlay && videoDuration > 0) {
console.log('[AndroidVideoPlayer] Seeking to resume position:', resumeTarget, 'duration:', videoDuration);
// Use a small delay to ensure the player is ready, then seek directly
setTimeout(() => {
if (mpvPlayerRef.current) {
console.log('[AndroidVideoPlayer] Calling mpvPlayerRef.current.seek directly');
mpvPlayerRef.current.seek(Math.min(resumeTarget, videoDuration - 0.5));
}
}, 200);
}
}, [id, type, episodeId, useVLC, playerState.isMounted, watchProgress.initialPosition]);
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]);
const toggleControls = useCallback(() => {
playerState.setShowControls(prev => !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]);
const handleSelectStream = async (newStream: any) => {
if (newStream.url === currentStreamUrl) {
modals.setShowSourcesModal(false);
return;
}
modals.setShowSourcesModal(false);
playerState.setPaused(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';
setTimeout(() => {
(navigation as any).replace('PlayerAndroid', {
...route.params,
uri: newStream.url,
quality: newQuality,
streamProvider: newProvider,
streamName: newStreamName,
headers: newStream.headers,
availableStreams: availableStreams
});
}, 100);
};
const handleEpisodeStreamSelect = async (stream: any) => {
if (!modals.selectedEpisodeForStreams) return;
modals.setShowEpisodeStreamsModal(false);
playerState.setPaused(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';
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,
forceVlc: false,
id,
type: 'series',
episodeId: ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`,
imdbId: imdbId ?? undefined,
backdrop: backdrop || undefined,
availableStreams: {},
groupedEpisodes: groupedEpisodes,
});
}, 100);
};
const cycleResizeMode = useCallback(() => {
if (playerState.resizeMode === 'contain') playerState.setResizeMode('cover');
else playerState.setResizeMode('contain');
}, [playerState.resizeMode]);
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' }}>
<VideoSurface
useVLC={!!useVLC}
forceVlcRemount={vlcHook.forceVlcRemount}
processedStreamUrl={processedStreamUrl}
volume={volume}
playbackSpeed={speedControl.playbackSpeed}
zoomScale={1.0}
resizeMode={playerState.resizeMode}
paused={playerState.paused}
currentStreamUrl={currentStreamUrl}
headers={headers || (isHlsStream(currentStreamUrl) ? getHlsHeaders() : defaultAndroidHeaders)}
videoType={currentVideoType}
vlcSelectedAudioTrack={vlcHook.vlcSelectedAudioTrack}
vlcSelectedSubtitleTrack={vlcHook.vlcSelectedSubtitleTrack}
vlcRestoreTime={vlcHook.vlcRestoreTime}
vlcKey={vlcHook.vlcKey}
selectedAudioTrack={tracksHook.selectedAudioTrack}
selectedTextTrack={tracksHook.selectedTextTrack}
useCustomSubtitles={false}
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);
// Check for decoding errors to switch to VLC
const errorString = err?.errorString || err?.error?.errorString;
const errorCode = err?.errorCode || err?.error?.errorCode;
const causeMessage = err?.error?.cause?.message;
const isDecodingError =
(errorString && errorString.includes('ERROR_CODE_DECODING_FAILED')) ||
errorCode === '24003' ||
(causeMessage && causeMessage.includes('MediaCodecVideoRenderer error'));
if (!useVLC && isDecodingError) {
const toastId = toast.loading('Decoding error. Switching to VLC Player...');
setTimeout(() => toast.dismiss(toastId), 3000);
// We can just show a normal toast or use the existing modal system if we want,
// but checking the file imports, I don't see Toast imported.
// Let's implement the navigation replace.
// Using a simple navigation replace to force VLC
(navigation as any).replace('PlayerAndroid', {
...route.params,
forceVlc: true
});
return;
}
modals.setErrorDetails(JSON.stringify(err));
modals.setShowErrorModal(true);
}}
onBuffer={(buf) => playerState.setIsBuffering(buf.isBuffering)}
onTracksUpdate={vlcHook.handleVlcTracksUpdate}
vlcPlayerRef={vlcHook.vlcPlayerRef}
mpvPlayerRef={mpvPlayerRef}
videoRef={videoRef}
pinchRef={useRef(null)}
onPinchGestureEvent={() => { }}
onPinchHandlerStateChange={() => { }}
vlcLoadedRef={vlcHook.vlcLoadedRef}
screenDimensions={playerState.screenDimensions}
customVideoStyles={{}}
loadStartAtRef={loadStartAtRef}
firstFrameAtRef={firstFrameAtRef}
/>
<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={useVLC ? 'VLC' : 'ExoPlayer'}
/>
<SpeedActivatedOverlay
visible={speedControl.showSpeedActivatedOverlay}
opacity={speedControl.speedActivatedOverlayOpacity}
speed={speedControl.holdToSpeedValue}
/>
<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}
/>
</View>
<AudioTrackModal
showAudioModal={modals.showAudioModal}
setShowAudioModal={modals.setShowAudioModal}
ksAudioTracks={tracksHook.ksAudioTracks}
selectedAudioTrack={tracksHook.computedSelectedAudioTrack}
selectAudioTrack={(trackId) => {
useVLC ? vlcHook.selectVlcAudioTrack(trackId) :
tracksHook.setSelectedAudioTrack(trackId === null ? null : { type: 'index', value: trackId });
}}
/>
<SubtitleModals
showSubtitleModal={modals.showSubtitleModal}
setShowSubtitleModal={modals.setShowSubtitleModal}
showSubtitleLanguageModal={false} // Placeholder
setShowSubtitleLanguageModal={() => { }} // Placeholder
isLoadingSubtitleList={false} // Placeholder
isLoadingSubtitles={false} // Placeholder
customSubtitles={[]} // Placeholder
availableSubtitles={[]} // Placeholder
ksTextTracks={tracksHook.ksTextTracks}
selectedTextTrack={tracksHook.computedSelectedTextTrack}
useCustomSubtitles={false}
isKsPlayerActive={!useVLC}
subtitleSize={30} // Placeholder
subtitleBackground={false} // Placeholder
fetchAvailableSubtitles={() => { }} // Placeholder
loadWyzieSubtitle={() => { }} // Placeholder
selectTextTrack={(trackId) => {
useVLC ? vlcHook.selectVlcSubtitleTrack(trackId) : tracksHook.setSelectedTextTrack(trackId);
modals.setShowSubtitleModal(false);
}}
disableCustomSubtitles={() => { }} // Placeholder
increaseSubtitleSize={() => { }} // Placeholder
decreaseSubtitleSize={() => { }} // Placeholder
toggleSubtitleBackground={() => { }} // Placeholder
subtitleTextColor="#FFF" // Placeholder
setSubtitleTextColor={() => { }} // Placeholder
subtitleBgOpacity={0.5} // Placeholder
setSubtitleBgOpacity={() => { }} // Placeholder
subtitleTextShadow={false} // Placeholder
setSubtitleTextShadow={() => { }} // Placeholder
subtitleOutline={false} // Placeholder
setSubtitleOutline={() => { }} // Placeholder
subtitleOutlineColor="#000" // Placeholder
setSubtitleOutlineColor={() => { }} // Placeholder
subtitleOutlineWidth={1} // Placeholder
setSubtitleOutlineWidth={() => { }} // Placeholder
subtitleAlign="center" // Placeholder
setSubtitleAlign={() => { }} // Placeholder
subtitleBottomOffset={10} // Placeholder
setSubtitleBottomOffset={() => { }} // Placeholder
subtitleLetterSpacing={0} // Placeholder
setSubtitleLetterSpacing={() => { }} // Placeholder
subtitleLineHeightMultiplier={1} // Placeholder
setSubtitleLineHeightMultiplier={() => { }} // Placeholder
subtitleOffsetSec={0} // Placeholder
setSubtitleOffsetSec={() => { }} // Placeholder
/>
<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 }}
/>
</View>
);
};
export default AndroidVideoPlayer;