NuvioStreaming/src/components/player/VideoPlayer.tsx
2025-07-30 00:45:58 +05:30

1482 lines
No EOL
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useRef, useEffect } from 'react';
import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, Image, StyleSheet, Modal } from 'react-native';
import { VLCPlayer } from 'react-native-vlc-media-player';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { RootStackParamList, RootStackNavigationProp } from '../../navigation/AppNavigator';
import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler';
import RNImmersiveMode from 'react-native-immersive-mode';
import * as ScreenOrientation from 'expo-screen-orientation';
import { storageService } from '../../services/storageService';
import { logger } from '../../utils/logger';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Slider from '@react-native-community/slider';
import AndroidVideoPlayer from './AndroidVideoPlayer';
import { useTraktAutosync } from '../../hooks/useTraktAutosync';
import { useTraktAutosyncSettings } from '../../hooks/useTraktAutosyncSettings';
import { useMetadata } from '../../hooks/useMetadata';
import { useSettings } from '../../hooks/useSettings';
import {
DEFAULT_SUBTITLE_SIZE,
AudioTrack,
TextTrack,
ResizeModeType,
WyzieSubtitle,
SubtitleCue,
RESUME_PREF_KEY,
RESUME_PREF,
SUBTITLE_SIZE_KEY
} from './utils/playerTypes';
import { safeDebugLog, parseSRT, DEBUG_MODE, formatTime } from './utils/playerUtils';
import { styles } from './utils/playerStyles';
import { SubtitleModals } from './modals/SubtitleModals';
import { AudioTrackModal } from './modals/AudioTrackModal';
import ResumeOverlay from './modals/ResumeOverlay';
import PlayerControls from './controls/PlayerControls';
import CustomSubtitles from './subtitles/CustomSubtitles';
import { SourcesModal } from './modals/SourcesModal';
const VideoPlayer: React.FC = () => {
const route = useRoute<RouteProp<RootStackParamList, 'Player'>>();
const { streamProvider, uri, headers } = route.params;
// Check if the stream is from Xprime
const isXprimeStream = streamProvider === 'xprime' || streamProvider === 'Xprime';
// Check if the file format is MKV
const isMkvFile = uri && (uri.toLowerCase().includes('.mkv') || uri.toLowerCase().includes('mkv'));
// Use AndroidVideoPlayer for:
// - Android devices
// - Xprime streams on any platform
// - Non-MKV files on iOS
if (Platform.OS === 'android' || isXprimeStream || (Platform.OS === 'ios' && !isMkvFile)) {
return <AndroidVideoPlayer />;
}
const navigation = useNavigation<RootStackNavigationProp>();
const {
title = 'Episode Name',
season,
episode,
episodeTitle,
quality,
year,
streamName,
id,
type,
episodeId,
imdbId,
availableStreams: passedAvailableStreams,
backdrop
} = route.params;
// Initialize Trakt autosync
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
});
// Get the Trakt autosync settings to use the user-configured sync frequency
const { settings: traktSettings } = useTraktAutosyncSettings();
safeDebugLog("Component mounted with props", {
uri, title, season, episode, episodeTitle, quality, year,
streamProvider, id, type, episodeId, imdbId
});
const screenData = Dimensions.get('screen');
const [screenDimensions, setScreenDimensions] = useState(screenData);
// iPad-specific fullscreen handling
const isIPad = Platform.OS === 'ios' && (screenData.width > 1000 || screenData.height > 1000);
const shouldUseFullscreen = isIPad;
// Use window dimensions for iPad instead of screen dimensions
const windowData = Dimensions.get('window');
const effectiveDimensions = shouldUseFullscreen ? windowData : screenData;
const [paused, setPaused] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [showControls, setShowControls] = useState(true);
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
const [selectedAudioTrack, setSelectedAudioTrack] = useState<number | null>(null);
const [textTracks, setTextTracks] = useState<TextTrack[]>([]);
const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1);
const [resizeMode, setResizeMode] = useState<ResizeModeType>('stretch');
const [buffered, setBuffered] = useState(0);
const [seekPosition, setSeekPosition] = useState<number | null>(null);
const vlcRef = useRef<any>(null);
const [showAudioModal, setShowAudioModal] = useState(false);
const [showSubtitleModal, setShowSubtitleModal] = useState(false);
const [initialPosition, setInitialPosition] = useState<number | null>(null);
const [progressSaveInterval, setProgressSaveInterval] = useState<NodeJS.Timeout | null>(null);
const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false);
const [showResumeOverlay, setShowResumeOverlay] = useState(false);
const [resumePosition, setResumePosition] = useState<number | null>(null);
const [savedDuration, setSavedDuration] = useState<number | null>(null);
const fadeAnim = useRef(new Animated.Value(1)).current;
const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false);
const openingFadeAnim = useRef(new Animated.Value(0)).current;
const openingScaleAnim = useRef(new Animated.Value(0.8)).current;
const backgroundFadeAnim = useRef(new Animated.Value(1)).current;
const [isBuffering, setIsBuffering] = useState(false);
const [vlcAudioTracks, setVlcAudioTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
const [vlcTextTracks, setVlcTextTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
const [isPlayerReady, setIsPlayerReady] = useState(false);
// Removed progressAnim and progressBarRef - no longer needed with React Native Community Slider
const [isDragging, setIsDragging] = useState(false);
const isSeeking = useRef(false);
const seekDebounceTimer = useRef<NodeJS.Timeout | null>(null);
const pendingSeekValue = useRef<number | null>(null);
const lastSeekTime = useRef<number>(0);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null);
const [is16by9Content, setIs16by9Content] = useState(false);
const [customVideoStyles, setCustomVideoStyles] = useState<any>({});
const [zoomScale, setZoomScale] = useState(1);
const [zoomTranslateX, setZoomTranslateX] = useState(0);
const [zoomTranslateY, setZoomTranslateY] = useState(0);
const [lastZoomScale, setLastZoomScale] = useState(1);
const [lastTranslateX, setLastTranslateX] = useState(0);
const [lastTranslateY, setLastTranslateY] = useState(0);
const pinchRef = useRef<PinchGestureHandler>(null);
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE);
const [subtitleBackground, setSubtitleBackground] = useState<boolean>(true);
const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false);
const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false);
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState<boolean>(false);
const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState<boolean>(false);
const [showSourcesModal, setShowSourcesModal] = useState<boolean>(false);
const [availableStreams, setAvailableStreams] = useState<{ [providerId: string]: { streams: any[]; addonName: string } }>(passedAvailableStreams || {});
const [currentStreamUrl, setCurrentStreamUrl] = useState<string>(uri);
const [isChangingSource, setIsChangingSource] = useState<boolean>(false);
const [showErrorModal, setShowErrorModal] = useState(false);
const [errorDetails, setErrorDetails] = useState<string>('');
const errorTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [pendingSeek, setPendingSeek] = useState<{ position: number; shouldPlay: boolean } | null>(null);
const [currentQuality, setCurrentQuality] = useState<string | undefined>(quality);
const [currentStreamProvider, setCurrentStreamProvider] = useState<string | undefined>(streamProvider);
const [currentStreamName, setCurrentStreamName] = useState<string | undefined>(streamName);
const isMounted = useRef(true);
const controlsTimeout = useRef<NodeJS.Timeout | null>(null);
const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false);
// Get metadata to access logo (only if we have a valid id)
const shouldLoadMetadata = Boolean(id && type);
const metadataResult = useMetadata({
id: id || 'placeholder',
type: type || 'movie'
});
const { metadata, loading: metadataLoading } = shouldLoadMetadata ? metadataResult : { metadata: null, loading: false };
const { settings } = useSettings();
// Logo animation values
const logoScaleAnim = useRef(new Animated.Value(0.8)).current;
const logoOpacityAnim = useRef(new Animated.Value(0)).current;
const pulseAnim = useRef(new Animated.Value(1)).current;
// Check if we have a logo to show
const hasLogo = metadata && metadata.logo && !metadataLoading;
// Small offset (in seconds) used to avoid seeking to the *exact* end of the
// file which triggers the `onEnd` callback and causes playback to restart.
const END_EPSILON = 0.3;
const hideControls = () => {
Animated.timing(fadeAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}).start(() => setShowControls(false));
};
const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => {
return {
position: 'absolute',
top: 0,
left: 0,
width: screenWidth,
height: screenHeight,
};
};
const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => {
const { scale } = event.nativeEvent;
const newScale = Math.max(1, Math.min(lastZoomScale * scale, 1.1));
setZoomScale(newScale);
if (DEBUG_MODE) {
logger.log(`[VideoPlayer] Center Zoom: ${newScale.toFixed(2)}x`);
}
};
const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => {
if (event.nativeEvent.state === State.END) {
setLastZoomScale(zoomScale);
if (DEBUG_MODE) {
logger.log(`[VideoPlayer] Pinch ended - saved scale: ${zoomScale.toFixed(2)}x`);
}
}
};
const resetZoom = () => {
const targetZoom = is16by9Content ? 1.1 : 1;
setZoomScale(targetZoom);
setLastZoomScale(targetZoom);
if (DEBUG_MODE) {
logger.log(`[VideoPlayer] Zoom reset to ${targetZoom}x (16:9: ${is16by9Content})`);
}
};
useEffect(() => {
if (videoAspectRatio && effectiveDimensions.width > 0 && effectiveDimensions.height > 0) {
const styles = calculateVideoStyles(
videoAspectRatio * 1000,
1000,
effectiveDimensions.width,
effectiveDimensions.height
);
setCustomVideoStyles(styles);
if (DEBUG_MODE) {
logger.log(`[VideoPlayer] Screen dimensions changed, recalculated styles:`, styles);
}
}
}, [effectiveDimensions, videoAspectRatio]);
// Force landscape orientation immediately when component mounts
useEffect(() => {
const lockOrientation = async () => {
try {
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
logger.log('[VideoPlayer] Locked to landscape orientation');
} catch (error) {
logger.warn('[VideoPlayer] Failed to lock orientation:', error);
}
};
// Lock orientation immediately
lockOrientation();
return () => {
// Unlock orientation when component unmounts
ScreenOrientation.unlockAsync().catch(() => {
// Ignore unlock errors
});
};
}, []);
useEffect(() => {
const subscription = Dimensions.addEventListener('change', ({ screen }) => {
setScreenDimensions(screen);
});
const initializePlayer = async () => {
StatusBar.setHidden(true, 'none');
enableImmersiveMode();
startOpeningAnimation();
};
initializePlayer();
return () => {
subscription?.remove();
disableImmersiveMode();
};
}, []);
const startOpeningAnimation = () => {
// Logo entrance animation - optimized for faster appearance
Animated.parallel([
Animated.timing(logoOpacityAnim, {
toValue: 1,
duration: 300, // Reduced from 600ms to 300ms
useNativeDriver: true,
}),
Animated.spring(logoScaleAnim, {
toValue: 1,
tension: 80, // Increased tension for faster spring
friction: 8,
useNativeDriver: true,
}),
]).start();
// Continuous pulse animation for the logo
const createPulseAnimation = () => {
return Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.05,
duration: 800, // Reduced from 1000ms to 800ms
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 800, // Reduced from 1000ms to 800ms
useNativeDriver: true,
}),
]);
};
const loopPulse = () => {
createPulseAnimation().start(() => {
if (!isOpeningAnimationComplete) {
loopPulse();
}
});
};
// Start pulsing immediately without delay
// Removed the 800ms delay
loopPulse();
};
const completeOpeningAnimation = () => {
Animated.parallel([
Animated.timing(openingFadeAnim, {
toValue: 1,
duration: 300, // Reduced from 600ms to 300ms
useNativeDriver: true,
}),
Animated.timing(openingScaleAnim, {
toValue: 1,
duration: 350, // Reduced from 700ms to 350ms
useNativeDriver: true,
}),
Animated.timing(backgroundFadeAnim, {
toValue: 0,
duration: 400, // Reduced from 800ms to 400ms
useNativeDriver: true,
}),
]).start(() => {
openingScaleAnim.setValue(1);
openingFadeAnim.setValue(1);
setIsOpeningAnimationComplete(true);
setTimeout(() => {
backgroundFadeAnim.setValue(0);
}, 100);
});
};
useEffect(() => {
const loadWatchProgress = async () => {
if (id && type) {
try {
logger.log(`[VideoPlayer] Loading watch progress for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`);
const savedProgress = await storageService.getWatchProgress(id, type, episodeId);
logger.log(`[VideoPlayer] Saved progress:`, savedProgress);
if (savedProgress) {
const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100;
logger.log(`[VideoPlayer] Progress: ${progressPercent.toFixed(1)}% (${savedProgress.currentTime}/${savedProgress.duration})`);
if (progressPercent < 85) {
setResumePosition(savedProgress.currentTime);
setSavedDuration(savedProgress.duration);
logger.log(`[VideoPlayer] Set resume position to: ${savedProgress.currentTime} of ${savedProgress.duration}`);
setShowResumeOverlay(true);
logger.log(`[VideoPlayer] Showing resume overlay`);
} else {
logger.log(`[VideoPlayer] Progress too high (${progressPercent.toFixed(1)}%), not showing resume overlay`);
}
} else {
logger.log(`[VideoPlayer] No saved progress found`);
}
} catch (error) {
logger.error('[VideoPlayer] Error loading watch progress:', error);
}
} else {
logger.log(`[VideoPlayer] Missing id or type: id=${id}, type=${type}`);
}
};
loadWatchProgress();
}, [id, type, episodeId]);
const saveWatchProgress = async () => {
if (id && type && currentTime > 0 && duration > 0) {
const progress = {
currentTime,
duration,
lastUpdated: Date.now()
};
try {
await storageService.setWatchProgress(id, type, progress, episodeId);
// Sync to Trakt if authenticated
await traktAutosync.handleProgressUpdate(currentTime, duration);
} catch (error) {
logger.error('[VideoPlayer] Error saving watch progress:', error);
}
}
};
useEffect(() => {
if (id && type && !paused && duration > 0) {
if (progressSaveInterval) {
clearInterval(progressSaveInterval);
}
// IMMEDIATE SYNC: Reduce sync interval to 5 seconds for near real-time sync
const syncInterval = 5000; // 5 seconds for immediate sync
const interval = setInterval(() => {
saveWatchProgress();
}, syncInterval);
logger.log(`[VideoPlayer] Watch progress save interval set to ${syncInterval}ms (immediate sync mode)`);
setProgressSaveInterval(interval);
return () => {
clearInterval(interval);
setProgressSaveInterval(null);
};
}
}, [id, type, paused, currentTime, duration]);
useEffect(() => {
return () => {
if (id && type && duration > 0) {
saveWatchProgress();
// Final Trakt sync on component unmount
traktAutosync.handlePlaybackEnd(currentTime, duration, 'unmount');
}
};
}, [id, type, currentTime, duration]);
const onPlaying = () => {
if (isMounted.current && !isSeeking.current) {
setPaused(false);
// Note: handlePlaybackStart is already called in onLoad
// We don't need to call it again here to avoid duplicate calls
}
};
const onPaused = () => {
if (isMounted.current) {
setPaused(true);
// Send a forced pause update to Trakt immediately when user pauses
if (duration > 0) {
traktAutosync.handleProgressUpdate(currentTime, duration, true);
logger.log('[VideoPlayer] Sent forced pause update to Trakt');
}
}
};
const seekToTime = (rawSeconds: number) => {
// Clamp to just before the end to avoid triggering onEnd.
const timeInSeconds = Math.max(0, Math.min(rawSeconds, duration > 0 ? duration - END_EPSILON : rawSeconds));
if (vlcRef.current && duration > 0 && !isSeeking.current) {
if (DEBUG_MODE) {
logger.log(`[VideoPlayer] Seeking to ${timeInSeconds.toFixed(2)}s out of ${duration.toFixed(2)}s`);
}
isSeeking.current = true;
// For Android, use direct seeking on VLC player ref instead of seek prop
if (Platform.OS === 'android' && vlcRef.current.seek) {
// Calculate position as fraction
const position = timeInSeconds / duration;
vlcRef.current.seek(position);
// Clear seek state after Android seek
setTimeout(() => {
if (isMounted.current) {
isSeeking.current = false;
if (DEBUG_MODE) {
logger.log(`[VideoPlayer] Android seek completed to ${timeInSeconds.toFixed(2)}s`);
}
}
}, 500);
} else {
// iOS (and other platforms) prefer direct seek on the ref to avoid re-mounts caused by the `seek` prop
const position = timeInSeconds / duration; // VLC expects a 0-1 fraction
if (vlcRef.current && typeof vlcRef.current.seek === 'function') {
vlcRef.current.seek(position);
} else {
// Fallback to legacy behaviour only if direct seek is unavailable
setSeekPosition(position);
}
setTimeout(() => {
if (isMounted.current) {
// Reset temporary seek state
setSeekPosition(null);
isSeeking.current = false;
if (DEBUG_MODE) {
logger.log(`[VideoPlayer] iOS seek completed to ${timeInSeconds.toFixed(2)}s`);
}
}
}, 500);
}
} else {
if (DEBUG_MODE) {
logger.error(`[VideoPlayer] Seek failed: vlcRef=${!!vlcRef.current}, duration=${duration}, seeking=${isSeeking.current}`);
}
}
};
// Slider callback functions for React Native Community Slider
const handleSliderValueChange = (value: number) => {
if (isDragging && duration > 0) {
const seekTime = Math.min(value, duration - END_EPSILON);
setCurrentTime(seekTime);
pendingSeekValue.current = seekTime;
}
};
const handleSlidingStart = () => {
setIsDragging(true);
};
const handleSlidingComplete = (value: number) => {
setIsDragging(false);
if (duration > 0) {
const seekTime = Math.min(value, duration - END_EPSILON);
seekToTime(seekTime);
pendingSeekValue.current = null;
}
};
// Removed processProgressTouch - no longer needed with React Native Community Slider
const handleProgress = (event: any) => {
if (isDragging || isSeeking.current) return;
const currentTimeInSeconds = event.currentTime / 1000;
// Only update if there's a significant change to avoid unnecessary updates
if (Math.abs(currentTimeInSeconds - currentTime) > 0.5) {
safeSetState(() => setCurrentTime(currentTimeInSeconds));
// Removed progressAnim animation - no longer needed with React Native Community Slider
const bufferedTime = event.bufferTime / 1000 || currentTimeInSeconds;
safeSetState(() => setBuffered(bufferedTime));
}
};
const onLoad = (data: any) => {
try {
if (DEBUG_MODE) {
logger.log('[VideoPlayer] Video loaded:', data);
}
if (!isMounted.current) {
logger.warn('[VideoPlayer] Component unmounted, skipping onLoad');
return;
}
if (!data) {
logger.error('[VideoPlayer] onLoad called with null/undefined data');
return;
}
const videoDuration = data.duration / 1000;
if (data.duration > 0) {
setDuration(videoDuration);
// Store the actual duration for future reference and update existing progress
if (id && type) {
storageService.setContentDuration(id, type, videoDuration, episodeId);
storageService.updateProgressDuration(id, type, videoDuration, episodeId);
// Update the saved duration for resume overlay if it was using an estimate
if (savedDuration && Math.abs(savedDuration - videoDuration) > 60) {
setSavedDuration(videoDuration);
}
}
}
// Set aspect ratio with null check for videoSize
if (data.videoSize && data.videoSize.width && data.videoSize.height) {
setVideoAspectRatio(data.videoSize.width / data.videoSize.height);
} else {
// Fallback to 16:9 aspect ratio if videoSize is not available
setVideoAspectRatio(16 / 9);
logger.warn('[VideoPlayer] videoSize not available, using default 16:9 aspect ratio');
}
if (data.audioTracks && data.audioTracks.length > 0) {
setVlcAudioTracks(data.audioTracks);
}
if (data.textTracks && data.textTracks.length > 0) {
setVlcTextTracks(data.textTracks);
}
setIsVideoLoaded(true);
setIsPlayerReady(true);
// Start Trakt watching session when video loads with proper duration
if (videoDuration > 0) {
traktAutosync.handlePlaybackStart(currentTime, videoDuration);
}
// Complete opening animation immediately before seeking
completeOpeningAnimation();
if (initialPosition && !isInitialSeekComplete) {
logger.log(`[VideoPlayer] Seeking to initial position: ${initialPosition}s (duration: ${videoDuration}s)`);
// Reduced timeout from 1000ms to 500ms
setTimeout(() => {
if (vlcRef.current && videoDuration > 0 && isMounted.current) {
seekToTime(initialPosition);
setIsInitialSeekComplete(true);
logger.log(`[VideoPlayer] Initial seek completed to: ${initialPosition}s`);
} else {
logger.error(`[VideoPlayer] Initial seek failed: vlcRef=${!!vlcRef.current}, duration=${videoDuration}, mounted=${isMounted.current}`);
}
}, 500);
}
controlsTimeout.current = setTimeout(hideControls, 5000);
} catch (error) {
logger.error('[VideoPlayer] Error in onLoad:', error);
// Set fallback values to prevent crashes
if (isMounted.current) {
setVideoAspectRatio(16 / 9);
setIsVideoLoaded(true);
setIsPlayerReady(true);
completeOpeningAnimation();
}
}
};
const skip = (seconds: number) => {
if (vlcRef.current) {
const newTime = Math.max(0, Math.min(currentTime + seconds, duration - END_EPSILON));
seekToTime(newTime);
}
};
const onAudioTracks = (data: { audioTracks: AudioTrack[] }) => {
setAudioTracks(data.audioTracks || []);
};
const onTextTracks = (e: Readonly<{ textTracks: TextTrack[] }>) => {
setTextTracks(e.textTracks || []);
};
const cycleAspectRatio = () => {
const newZoom = zoomScale === 1.1 ? 1 : 1.1;
setZoomScale(newZoom);
setZoomTranslateX(0);
setZoomTranslateY(0);
setLastZoomScale(newZoom);
setLastTranslateX(0);
setLastTranslateY(0);
};
const enableImmersiveMode = () => {
StatusBar.setHidden(true, 'none');
if (Platform.OS === 'android') {
try {
RNImmersiveMode.setBarMode('FullSticky');
RNImmersiveMode.fullLayout(true);
if (NativeModules.StatusBarManager) {
NativeModules.StatusBarManager.setHidden(true);
}
} catch (error) {
console.log('Immersive mode error:', error);
}
}
};
const disableImmersiveMode = () => {
StatusBar.setHidden(false);
if (Platform.OS === 'android') {
RNImmersiveMode.setBarMode('Normal');
RNImmersiveMode.fullLayout(false);
}
};
const handleClose = async () => {
// Prevent multiple close attempts
if (isSyncingBeforeClose) {
logger.log('[VideoPlayer] Close already in progress, ignoring duplicate call');
return;
}
logger.log('[VideoPlayer] Close button pressed - closing immediately and syncing to Trakt in background');
setIsSyncingBeforeClose(true);
// Make sure we have the most accurate current time
const actualCurrentTime = currentTime;
const progressPercent = duration > 0 ? (actualCurrentTime / duration) * 100 : 0;
logger.log(`[VideoPlayer] Current progress: ${actualCurrentTime}/${duration} (${progressPercent.toFixed(1)}%)`);
// Cleanup and navigate back immediately without delay
const cleanup = async () => {
try {
// Unlock orientation first
await ScreenOrientation.unlockAsync();
logger.log('[VideoPlayer] Orientation unlocked');
} catch (orientationError) {
logger.warn('[VideoPlayer] Failed to unlock orientation:', orientationError);
}
// Disable immersive mode
disableImmersiveMode();
// Navigate back with proper handling for fullscreen modal
try {
if (navigation.canGoBack()) {
navigation.goBack();
} else {
// Fallback: navigate to main tabs if can't go back
navigation.navigate('MainTabs');
}
logger.log('[VideoPlayer] Navigation completed');
} catch (navError) {
logger.error('[VideoPlayer] Navigation error:', navError);
// Last resort: try to navigate to home
navigation.navigate('MainTabs');
}
};
// Navigate immediately
cleanup();
// Send Trakt sync in background (don't await)
const backgroundSync = async () => {
try {
logger.log('[VideoPlayer] Starting background Trakt sync');
// Force one last progress update (scrobble/pause) with the exact time
await traktAutosync.handleProgressUpdate(actualCurrentTime, duration, true);
// Sync progress to Trakt
await traktAutosync.handlePlaybackEnd(actualCurrentTime, duration, 'unmount');
logger.log('[VideoPlayer] Background Trakt sync completed successfully');
} catch (error) {
logger.error('[VideoPlayer] Error in background Trakt sync:', error);
}
};
// Start background sync without blocking UI
backgroundSync();
};
const handleResume = async () => {
if (resumePosition) {
seekToTime(resumePosition);
}
setShowResumeOverlay(false);
};
const handleStartFromBeginning = async () => {
seekToTime(0);
setShowResumeOverlay(false);
};
const toggleControls = () => {
if (controlsTimeout.current) {
clearTimeout(controlsTimeout.current);
controlsTimeout.current = null;
}
setShowControls(prevShowControls => {
const newShowControls = !prevShowControls;
Animated.timing(fadeAnim, {
toValue: newShowControls ? 1 : 0,
duration: 300,
useNativeDriver: true,
}).start();
if (newShowControls) {
controlsTimeout.current = setTimeout(hideControls, 5000);
}
return newShowControls;
});
};
const handleError = (error: any) => {
logger.error('[VideoPlayer] Playback Error:', error);
// Format error details for user display
let errorMessage = 'An unknown error occurred';
if (error) {
if (typeof error === 'string') {
errorMessage = error;
} else if (error.message) {
errorMessage = error.message;
} else if (error.error && error.error.message) {
errorMessage = error.error.message;
} else if (error.code) {
errorMessage = `Error Code: ${error.code}`;
} else {
errorMessage = JSON.stringify(error, null, 2);
}
}
setErrorDetails(errorMessage);
setShowErrorModal(true);
// Clear any existing timeout
if (errorTimeoutRef.current) {
clearTimeout(errorTimeoutRef.current);
}
// Auto-exit after 5 seconds if user doesn't dismiss
errorTimeoutRef.current = setTimeout(() => {
handleErrorExit();
}, 5000);
};
const handleErrorExit = () => {
if (errorTimeoutRef.current) {
clearTimeout(errorTimeoutRef.current);
errorTimeoutRef.current = null;
}
setShowErrorModal(false);
handleClose();
};
const onBuffering = (event: any) => {
setIsBuffering(event.isBuffering);
};
const onEnd = async () => {
// Make sure we report 100% progress to Trakt
const finalTime = duration;
setCurrentTime(finalTime);
try {
// Force one last progress update (scrobble/pause) with the exact final time
logger.log('[VideoPlayer] Video ended naturally, sending final progress update with 100%');
await traktAutosync.handleProgressUpdate(finalTime, duration, true);
// IMMEDIATE SYNC: Remove delay for instant sync
// Now send the stop call immediately
logger.log('[VideoPlayer] Sending final stop call after natural end');
await traktAutosync.handlePlaybackEnd(finalTime, duration, 'ended');
logger.log('[VideoPlayer] Completed video end sync to Trakt');
} catch (error) {
logger.error('[VideoPlayer] Error syncing to Trakt on video end:', error);
}
};
const selectAudioTrack = (trackId: number) => {
setSelectedAudioTrack(trackId);
};
const selectTextTrack = (trackId: number) => {
if (trackId === -999) {
setUseCustomSubtitles(true);
setSelectedTextTrack(-1);
} else {
setUseCustomSubtitles(false);
setSelectedTextTrack(trackId);
}
};
const loadSubtitleSize = async () => {
try {
const savedSize = await AsyncStorage.getItem(SUBTITLE_SIZE_KEY);
if (savedSize) {
setSubtitleSize(parseInt(savedSize, 10));
}
} catch (error) {
logger.error('[VideoPlayer] Error loading subtitle size:', error);
}
};
const saveSubtitleSize = async (size: number) => {
try {
await AsyncStorage.setItem(SUBTITLE_SIZE_KEY, size.toString());
setSubtitleSize(size);
} catch (error) {
logger.error('[VideoPlayer] Error saving subtitle size:', error);
}
};
const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = false) => {
const targetImdbId = imdbIdParam || imdbId;
if (!targetImdbId) {
logger.error('[VideoPlayer] No IMDb ID available for subtitle search');
return;
}
setIsLoadingSubtitleList(true);
try {
let searchUrl = `https://sub.wyzie.ru/search?id=${targetImdbId}&encoding=utf-8&source=all`;
if (season && episode) {
searchUrl += `&season=${season}&episode=${episode}`;
}
const response = await fetch(searchUrl);
const subtitles: WyzieSubtitle[] = await response.json();
const uniqueSubtitles = subtitles.reduce((acc, current) => {
const exists = acc.find(item => item.language === current.language);
if (!exists) {
acc.push(current);
}
return acc;
}, [] as WyzieSubtitle[]);
uniqueSubtitles.sort((a, b) => a.display.localeCompare(b.display));
setAvailableSubtitles(uniqueSubtitles);
if (autoSelectEnglish) {
const englishSubtitle = uniqueSubtitles.find(sub =>
sub.language.toLowerCase() === 'eng' ||
sub.language.toLowerCase() === 'en' ||
sub.display.toLowerCase().includes('english')
);
if (englishSubtitle) {
loadWyzieSubtitle(englishSubtitle);
return;
}
}
if (!autoSelectEnglish) {
setShowSubtitleLanguageModal(true);
}
} catch (error) {
logger.error('[VideoPlayer] Error fetching subtitles from Wyzie API:', error);
} finally {
setIsLoadingSubtitleList(false);
}
};
const loadWyzieSubtitle = async (subtitle: WyzieSubtitle) => {
setShowSubtitleLanguageModal(false);
setIsLoadingSubtitles(true);
try {
const response = await fetch(subtitle.url);
const srtContent = await response.text();
const parsedCues = parseSRT(srtContent);
setCustomSubtitles(parsedCues);
setUseCustomSubtitles(true);
setSelectedTextTrack(-1);
} catch (error) {
logger.error('[VideoPlayer] Error loading Wyzie subtitle:', error);
} finally {
setIsLoadingSubtitles(false);
}
};
const togglePlayback = () => {
if (vlcRef.current) {
setPaused(!paused);
}
};
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
if (seekDebounceTimer.current) {
clearTimeout(seekDebounceTimer.current);
}
if (errorTimeoutRef.current) {
clearTimeout(errorTimeoutRef.current);
}
};
}, []);
const safeSetState = (setter: any) => {
if (isMounted.current) {
setter();
}
};
useEffect(() => {
if (!useCustomSubtitles || customSubtitles.length === 0) {
if (currentSubtitle !== '') {
setCurrentSubtitle('');
}
return;
}
const currentCue = customSubtitles.find(cue =>
currentTime >= cue.start && currentTime <= cue.end
);
const newSubtitle = currentCue ? currentCue.text : '';
setCurrentSubtitle(newSubtitle);
}, [currentTime, customSubtitles, useCustomSubtitles]);
useEffect(() => {
loadSubtitleSize();
}, []);
const increaseSubtitleSize = () => {
const newSize = Math.min(subtitleSize + 2, 32);
saveSubtitleSize(newSize);
};
const decreaseSubtitleSize = () => {
const newSize = Math.max(subtitleSize - 2, 8);
saveSubtitleSize(newSize);
};
const toggleSubtitleBackground = () => {
setSubtitleBackground(prev => !prev);
};
useEffect(() => {
if (pendingSeek && isPlayerReady && isVideoLoaded && duration > 0) {
logger.log(`[VideoPlayer] Player ready after source change, seeking to position: ${pendingSeek.position}s out of ${duration}s total`);
if (pendingSeek.position > 0 && vlcRef.current) {
const delayTime = Platform.OS === 'android' ? 1500 : 1000;
setTimeout(() => {
if (vlcRef.current && duration > 0 && pendingSeek) {
logger.log(`[VideoPlayer] Executing seek to ${pendingSeek.position}s`);
seekToTime(pendingSeek.position);
if (pendingSeek.shouldPlay) {
setTimeout(() => {
logger.log('[VideoPlayer] Resuming playback after source change seek');
setPaused(false);
}, 850); // Delay should be slightly more than seekToTime's internal timeout
}
setTimeout(() => {
setPendingSeek(null);
setIsChangingSource(false);
}, 900);
}
}, delayTime);
} else {
// No seeking needed, just resume playback if it was playing
if (pendingSeek.shouldPlay) {
setTimeout(() => {
logger.log('[VideoPlayer] No seek needed, just resuming playback');
setPaused(false);
}, 500);
}
setTimeout(() => {
setPendingSeek(null);
setIsChangingSource(false);
}, 600);
}
}
}, [pendingSeek, isPlayerReady, isVideoLoaded, duration]);
const handleSelectStream = async (newStream: any) => {
if (newStream.url === currentStreamUrl) {
setShowSourcesModal(false);
return;
}
setIsChangingSource(true);
setShowSourcesModal(false);
try {
// Save current state
const savedPosition = currentTime;
const wasPlaying = !paused;
logger.log(`[VideoPlayer] Changing source from ${currentStreamUrl} to ${newStream.url}`);
logger.log(`[VideoPlayer] Saved position: ${savedPosition}, was playing: ${wasPlaying}`);
// Extract quality and provider information from the new stream
let newQuality = newStream.quality;
if (!newQuality && newStream.title) {
// Try to extract quality from title (e.g., "1080p", "720p")
const qualityMatch = newStream.title.match(/(\d+)p/);
newQuality = qualityMatch ? qualityMatch[0] : undefined; // Use [0] to get full match like "1080p"
}
// For provider, try multiple fields
const newProvider = newStream.addonName || newStream.name || newStream.addon || 'Unknown';
// For stream name, prioritize the stream name over title
const newStreamName = newStream.name || newStream.title || 'Unknown Stream';
logger.log(`[VideoPlayer] Stream object:`, newStream);
logger.log(`[VideoPlayer] Extracted - Quality: ${newQuality}, Provider: ${newProvider}, Stream Name: ${newStreamName}`);
logger.log(`[VideoPlayer] Available fields - quality: ${newStream.quality}, title: ${newStream.title}, addonName: ${newStream.addonName}, name: ${newStream.name}, addon: ${newStream.addon}`);
// Stop current playback
if (vlcRef.current) {
vlcRef.current.pause && vlcRef.current.pause();
}
setPaused(true);
// Set pending seek state
setPendingSeek({ position: savedPosition, shouldPlay: wasPlaying });
// Update the stream URL and details immediately
setCurrentStreamUrl(newStream.url);
setCurrentQuality(newQuality);
setCurrentStreamProvider(newProvider);
setCurrentStreamName(newStreamName);
// Reset player state for new source
setCurrentTime(0);
setDuration(0);
setIsPlayerReady(false);
setIsVideoLoaded(false);
} catch (error) {
logger.error('[VideoPlayer] Error changing source:', error);
setPendingSeek(null);
setIsChangingSource(false);
}
};
return (
<View style={[
styles.container,
shouldUseFullscreen ? {
// iPad fullscreen: use flex layout instead of absolute positioning
flex: 1,
width: '100%',
height: '100%',
} : {
// iPhone: use absolute positioning with screen dimensions
width: screenDimensions.width,
height: screenDimensions.height,
position: 'absolute',
top: 0,
left: 0,
}]}>
<Animated.View
style={[
styles.openingOverlay,
{
opacity: backgroundFadeAnim,
zIndex: isOpeningAnimationComplete ? -1 : 3000,
width: screenDimensions.width,
height: screenDimensions.height,
}
]}
pointerEvents={isOpeningAnimationComplete ? 'none' : 'auto'}
>
{backdrop && (
<Image
source={{ uri: backdrop }}
style={[StyleSheet.absoluteFill, { width: screenDimensions.width, height: screenDimensions.height }]}
resizeMode="cover"
blurRadius={0}
/>
)}
<LinearGradient
colors={[
'rgba(0,0,0,0.3)',
'rgba(0,0,0,0.6)',
'rgba(0,0,0,0.8)',
'rgba(0,0,0,0.9)'
]}
locations={[0, 0.3, 0.7, 1]}
style={StyleSheet.absoluteFill}
/>
<TouchableOpacity
style={styles.loadingCloseButton}
onPress={handleClose}
activeOpacity={0.7}
>
<MaterialIcons name="close" size={24} color="#ffffff" />
</TouchableOpacity>
<View style={styles.openingContent}>
{hasLogo ? (
<Animated.View style={{
transform: [
{ scale: Animated.multiply(logoScaleAnim, pulseAnim) }
],
opacity: logoOpacityAnim,
alignItems: 'center',
}}>
<Image
source={{ uri: metadata.logo }}
style={{
width: 300,
height: 180,
resizeMode: 'contain',
}}
/>
</Animated.View>
) : (
<>
<ActivityIndicator size="large" color="#E50914" />
</>
)}
</View>
</Animated.View>
{/* Source Change Loading Overlay */}
{isChangingSource && (
<Animated.View
style={[
styles.sourceChangeOverlay,
{
width: screenDimensions.width,
height: screenDimensions.height,
opacity: fadeAnim,
}
]}
pointerEvents="auto"
>
<View style={styles.sourceChangeContent}>
<ActivityIndicator size="large" color="#E50914" />
<Text style={styles.sourceChangeText}>Changing source...</Text>
<Text style={styles.sourceChangeSubtext}>Please wait while we load the new stream</Text>
</View>
</Animated.View>
)}
<Animated.View
style={[
styles.videoPlayerContainer,
{
opacity: openingFadeAnim,
transform: isOpeningAnimationComplete ? [] : [{ scale: openingScaleAnim }],
width: screenDimensions.width,
height: screenDimensions.height,
}
]}
>
<TouchableOpacity
style={[styles.videoContainer, {
width: screenDimensions.width,
height: screenDimensions.height,
}]}
onPress={toggleControls}
activeOpacity={1}
>
<PinchGestureHandler
ref={pinchRef}
onGestureEvent={onPinchGestureEvent}
onHandlerStateChange={onPinchHandlerStateChange}
>
<View style={{
position: 'absolute',
top: 0,
left: 0,
width: screenDimensions.width,
height: screenDimensions.height,
}}>
<TouchableOpacity
style={{ flex: 1 }}
activeOpacity={1}
onPress={toggleControls}
onLongPress={resetZoom}
delayLongPress={300}
>
<VLCPlayer
ref={vlcRef}
style={[styles.video, customVideoStyles, { transform: [{ scale: zoomScale }] }]}
source={(() => {
// Use headers from route params if available, otherwise no headers
const sourceWithHeaders = headers ? {
uri: currentStreamUrl,
headers: headers
} : { uri: currentStreamUrl };
console.log('[VideoPlayer] Using headers from route params:', headers);
return sourceWithHeaders;
})()}
paused={paused}
onProgress={handleProgress}
onLoad={onLoad}
onEnd={onEnd}
onError={handleError}
onBuffering={onBuffering}
onPlaying={onPlaying}
onPaused={onPaused}
resizeMode={resizeMode as any}
audioTrack={selectedAudioTrack ?? undefined}
textTrack={useCustomSubtitles ? -1 : (selectedTextTrack ?? undefined)}
autoAspectRatio
/>
</TouchableOpacity>
</View>
</PinchGestureHandler>
<PlayerControls
showControls={showControls}
fadeAnim={fadeAnim}
paused={paused}
title={title}
episodeTitle={episodeTitle}
season={season}
episode={episode}
quality={currentQuality || quality}
year={year}
streamProvider={currentStreamProvider || streamProvider}
streamName={currentStreamName}
currentTime={currentTime}
duration={duration}
zoomScale={zoomScale}
vlcAudioTracks={vlcAudioTracks}
selectedAudioTrack={selectedAudioTrack}
availableStreams={availableStreams}
togglePlayback={togglePlayback}
skip={skip}
handleClose={handleClose}
cycleAspectRatio={cycleAspectRatio}
setShowAudioModal={setShowAudioModal}
setShowSubtitleModal={setShowSubtitleModal}
setShowSourcesModal={setShowSourcesModal}
onSliderValueChange={handleSliderValueChange}
onSlidingStart={handleSlidingStart}
onSlidingComplete={handleSlidingComplete}
buffered={buffered}
formatTime={formatTime}
/>
<CustomSubtitles
useCustomSubtitles={useCustomSubtitles}
currentSubtitle={currentSubtitle}
subtitleSize={subtitleSize}
subtitleBackground={subtitleBackground}
zoomScale={zoomScale}
/>
<ResumeOverlay
showResumeOverlay={showResumeOverlay}
resumePosition={resumePosition}
duration={savedDuration || duration}
title={episodeTitle || title}
season={season}
episode={episode}
handleResume={handleResume}
handleStartFromBeginning={handleStartFromBeginning}
/>
</TouchableOpacity>
</Animated.View>
<AudioTrackModal
showAudioModal={showAudioModal}
setShowAudioModal={setShowAudioModal}
vlcAudioTracks={vlcAudioTracks}
selectedAudioTrack={selectedAudioTrack}
selectAudioTrack={selectAudioTrack}
/>
<SubtitleModals
showSubtitleModal={showSubtitleModal}
setShowSubtitleModal={setShowSubtitleModal}
showSubtitleLanguageModal={showSubtitleLanguageModal}
setShowSubtitleLanguageModal={setShowSubtitleLanguageModal}
isLoadingSubtitleList={isLoadingSubtitleList}
isLoadingSubtitles={isLoadingSubtitles}
customSubtitles={customSubtitles}
availableSubtitles={availableSubtitles}
vlcTextTracks={vlcTextTracks}
selectedTextTrack={selectedTextTrack}
useCustomSubtitles={useCustomSubtitles}
subtitleSize={subtitleSize}
subtitleBackground={subtitleBackground}
fetchAvailableSubtitles={fetchAvailableSubtitles}
loadWyzieSubtitle={loadWyzieSubtitle}
selectTextTrack={selectTextTrack}
increaseSubtitleSize={increaseSubtitleSize}
decreaseSubtitleSize={decreaseSubtitleSize}
toggleSubtitleBackground={toggleSubtitleBackground}
/>
<SourcesModal
showSourcesModal={showSourcesModal}
setShowSourcesModal={setShowSourcesModal}
availableStreams={availableStreams}
currentStreamUrl={currentStreamUrl}
onSelectStream={handleSelectStream}
isChangingSource={isChangingSource}
/>
{/* Error Modal */}
<Modal
visible={showErrorModal}
transparent
animationType="fade"
onRequestClose={handleErrorExit}
>
<View style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.8)'
}}>
<View style={{
backgroundColor: '#1a1a1a',
borderRadius: 14,
width: '85%',
maxHeight: '70%',
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 5,
}}>
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16
}}>
<MaterialIcons name="error" size={24} color="#ff4444" style={{ marginRight: 8 }} />
<Text style={{
fontSize: 18,
fontWeight: 'bold',
color: '#ffffff',
flex: 1
}}>Playback Error</Text>
<TouchableOpacity onPress={handleErrorExit}>
<MaterialIcons name="close" size={24} color="#ffffff" />
</TouchableOpacity>
</View>
<Text style={{
fontSize: 14,
color: '#cccccc',
marginBottom: 16,
lineHeight: 20
}}>The video player encountered an error and cannot continue playback:</Text>
<View style={{
backgroundColor: '#2a2a2a',
borderRadius: 8,
padding: 12,
marginBottom: 20,
maxHeight: 200
}}>
<Text style={{
fontSize: 12,
color: '#ff8888',
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace'
}}>{errorDetails}</Text>
</View>
<View style={{
flexDirection: 'row',
justifyContent: 'flex-end'
}}>
<TouchableOpacity
style={{
backgroundColor: '#ff4444',
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 20
}}
onPress={handleErrorExit}
>
<Text style={{
color: '#ffffff',
fontWeight: '600',
fontSize: 16
}}>Exit Player</Text>
</TouchableOpacity>
</View>
<Text style={{
fontSize: 12,
color: '#888888',
textAlign: 'center',
marginTop: 12
}}>This dialog will auto-close in 5 seconds</Text>
</View>
</View>
</Modal>
</View>
);
};
export default VideoPlayer;