import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native'; import FastImage from '@d11/react-native-fast-image'; import { RootStackParamList, RootStackNavigationProp } from '../../navigation/AppNavigator'; import { PinchGestureHandler, PanGestureHandler, TapGestureHandler, LongPressGestureHandler, State, PinchGestureHandlerGestureEvent, PanGestureHandlerGestureEvent, TapGestureHandlerGestureEvent, LongPressGestureHandlerGestureEvent } 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 { mmkvStorage } from '../../services/mmkvStorage'; import { MaterialIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import Slider from '@react-native-community/slider'; import KSPlayerComponent, { KSPlayerRef, KSPlayerSource } from './KSPlayerComponent'; import { useTraktAutosync } from '../../hooks/useTraktAutosync'; import { useTraktAutosyncSettings } from '../../hooks/useTraktAutosyncSettings'; import { useMetadata } from '../../hooks/useMetadata'; import { useSettings } from '../../hooks/useSettings'; import { usePlayerGestureControls } from '../../hooks/usePlayerGestureControls'; import { DEFAULT_SUBTITLE_SIZE, getDefaultSubtitleSize, AudioTrack, TextTrack, ResizeModeType, WyzieSubtitle, SubtitleCue, SubtitleSegment, 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'; // Speed settings storage key const SPEED_SETTINGS_KEY = '@nuvio_speed_settings'; import { SubtitleModals } from './modals/SubtitleModals'; import { AudioTrackModal } from './modals/AudioTrackModal'; import { SpeedModal } from './modals/SpeedModal'; // Removed ResumeOverlay usage when alwaysResume is enabled import PlayerControls from './controls/PlayerControls'; import CustomSubtitles from './subtitles/CustomSubtitles'; import { SourcesModal } from './modals/SourcesModal'; import UpNextButton from './common/UpNextButton'; import { EpisodesModal } from './modals/EpisodesModal'; import LoadingOverlay from './modals/LoadingOverlay'; import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal'; import { Episode } from '../../types/metadata'; import axios from 'axios'; import { stremioService } from '../../services/stremioService'; import * as Brightness from 'expo-brightness'; const KSPlayerCore: React.FC = () => { const insets = useSafeAreaInsets(); const route = useRoute>(); const { uri, headers, streamProvider } = route.params as any; const navigation = useNavigation(); // KSPlayer is active only on iOS for MKV streams const isKsPlayerActive = Platform.OS === 'ios'; const { title = 'Episode Name', season, episode, episodeTitle, quality, year, streamName, id, type, episodeId, imdbId, availableStreams: passedAvailableStreams, backdrop, groupedEpisodes } = 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 }); // App settings const { settings: appSettings } = useSettings(); 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/macOS-specific fullscreen handling const isIPad = Platform.OS === 'ios' && (screenData.width > 1000 || screenData.height > 1000); const isMacOS = Platform.OS === 'ios' && Platform.isPad === true; const shouldUseFullscreen = isIPad || isMacOS; // Use window dimensions for iPad instead of screen dimensions const windowData = Dimensions.get('window'); const effectiveDimensions = shouldUseFullscreen ? windowData : screenData; // Helper to get appropriate dimensions for gesture areas and overlays const getDimensions = () => ({ width: shouldUseFullscreen ? windowData.width : screenDimensions.width, height: shouldUseFullscreen ? windowData.height : screenDimensions.height, }); const [paused, setPaused] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [showControls, setShowControls] = useState(true); const [audioTracks, setAudioTracks] = useState([]); const [selectedAudioTrack, setSelectedAudioTrack] = useState(null); const [textTracks, setTextTracks] = useState([]); const [selectedTextTrack, setSelectedTextTrack] = useState(-1); const [resizeMode, setResizeMode] = useState('contain'); const [playerBackend, setPlayerBackend] = useState(''); const [buffered, setBuffered] = useState(0); const [seekPosition, setSeekPosition] = useState(null); const ksPlayerRef = useRef(null); const [showAudioModal, setShowAudioModal] = useState(false); const [showSubtitleModal, setShowSubtitleModal] = useState(false); const [showSpeedModal, setShowSpeedModal] = useState(false); const [initialPosition, setInitialPosition] = useState(null); const [progressSaveInterval, setProgressSaveInterval] = useState(null); const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false); const [showResumeOverlay, setShowResumeOverlay] = useState(false); const [resumePosition, setResumePosition] = useState(null); const [savedDuration, setSavedDuration] = useState(null); const initialSeekTargetRef = useRef(null); const initialSeekVerifiedRef = useRef(false); const isSourceSeekableRef = useRef(null); const fadeAnim = useRef(new Animated.Value(1)).current; const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false); const [shouldHideOpeningOverlay, setShouldHideOpeningOverlay] = useState(false); const DISABLE_OPENING_OVERLAY = false; // Enable opening overlay animation 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 [isBackdropLoaded, setIsBackdropLoaded] = useState(false); const backdropImageOpacityAnim = useRef(new Animated.Value(0)).current; const [isBuffering, setIsBuffering] = useState(false); const [ksAudioTracks, setKsAudioTracks] = useState>([]); const [ksTextTracks, setKsTextTracks] = useState>([]); 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(null); const pendingSeekValue = useRef(null); const lastSeekTime = useRef(0); const wasPlayingBeforeDragRef = useRef(false); const [isVideoLoaded, setIsVideoLoaded] = useState(false); const [videoAspectRatio, setVideoAspectRatio] = useState(null); const [is16by9Content, setIs16by9Content] = useState(false); const [customVideoStyles, setCustomVideoStyles] = useState({}); 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(null); const [customSubtitles, setCustomSubtitles] = useState([]); const [currentSubtitle, setCurrentSubtitle] = useState(''); const [currentFormattedSegments, setCurrentFormattedSegments] = useState([]); const [subtitleSize, setSubtitleSize] = useState(DEFAULT_SUBTITLE_SIZE); const [subtitleBackground, setSubtitleBackground] = useState(false); // External subtitle customization 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(4); const [subtitleAlign, setSubtitleAlign] = useState<'center' | 'left' | 'right'>('center'); const [subtitleBottomOffset, setSubtitleBottomOffset] = useState(10); const [subtitleLetterSpacing, setSubtitleLetterSpacing] = useState(0); const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState(1.2); const [subtitleOffsetSec, setSubtitleOffsetSec] = useState(0); const [useCustomSubtitles, setUseCustomSubtitles] = useState(false); const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false); const [availableSubtitles, setAvailableSubtitles] = useState([]); const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false); const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false); const [showSourcesModal, setShowSourcesModal] = useState(false); const [showEpisodesModal, setShowEpisodesModal] = useState(false); const [showEpisodeStreamsModal, setShowEpisodeStreamsModal] = useState(false); const [selectedEpisodeForStreams, setSelectedEpisodeForStreams] = useState(null); const [availableStreams, setAvailableStreams] = useState<{ [providerId: string]: { streams: any[]; addonName: string } }>(passedAvailableStreams || {}); // Playback speed controls required by PlayerControls const speedOptions = [0.5, 1.0, 1.25, 1.5, 2.0, 2.5]; const [playbackSpeed, setPlaybackSpeed] = useState(1.0); // Hold-to-speed-up feature state const [holdToSpeedEnabled, setHoldToSpeedEnabled] = useState(true); const [holdToSpeedValue, setHoldToSpeedValue] = useState(2.0); const [isSpeedBoosted, setIsSpeedBoosted] = useState(false); const [originalSpeed, setOriginalSpeed] = useState(1.0); const [showSpeedActivatedOverlay, setShowSpeedActivatedOverlay] = useState(false); const speedActivatedOverlayOpacity = useRef(new Animated.Value(0)).current; const cyclePlaybackSpeed = useCallback(() => { const idx = speedOptions.indexOf(playbackSpeed); const nextIdx = (idx + 1) % speedOptions.length; setPlaybackSpeed(speedOptions[nextIdx]); }, [playbackSpeed, speedOptions]); const [currentStreamUrl, setCurrentStreamUrl] = useState(uri); const [showErrorModal, setShowErrorModal] = useState(false); const [errorDetails, setErrorDetails] = useState(''); const errorTimeoutRef = useRef(null); const [currentQuality, setCurrentQuality] = useState(quality); const [currentStreamProvider, setCurrentStreamProvider] = useState(streamProvider); const [currentStreamName, setCurrentStreamName] = useState(streamName); const [lastAudioTrackCheck, setLastAudioTrackCheck] = useState(0); const [audioTrackFallbackAttempts, setAudioTrackFallbackAttempts] = useState(0); const isMounted = useRef(true); const controlsTimeout = useRef(null); const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false); // AirPlay state const [isAirPlayActive, setIsAirPlayActive] = useState(false); const [allowsAirPlay, setAllowsAirPlay] = useState(true); // Silent startup-timeout retry state const startupRetryCountRef = useRef(0); const startupRetryTimerRef = useRef(null); const MAX_STARTUP_RETRIES = 3; // Pause overlay state const [showPauseOverlay, setShowPauseOverlay] = useState(false); const pauseOverlayTimerRef = useRef(null); const pauseOverlayOpacity = useRef(new Animated.Value(0)).current; const pauseOverlayTranslateY = useRef(new Animated.Value(12)).current; const metadataOpacity = useRef(new Animated.Value(1)).current; const metadataScale = useRef(new Animated.Value(1)).current; // Next episode loading state const [isLoadingNextEpisode, setIsLoadingNextEpisode] = useState(false); const [nextLoadingProvider, setNextLoadingProvider] = useState(null); const [nextLoadingQuality, setNextLoadingQuality] = useState(null); const [nextLoadingTitle, setNextLoadingTitle] = useState(null); // Cast display state const [selectedCastMember, setSelectedCastMember] = useState(null); const [showCastDetails, setShowCastDetails] = useState(false); const castDetailsOpacity = useRef(new Animated.Value(0)).current; const castDetailsScale = useRef(new Animated.Value(0.95)).current; // Volume and brightness controls const [volume, setVolume] = useState(100); // KSPlayer uses 0-100 range const [brightness, setBrightness] = useState(1.0); const [subtitleSettingsLoaded, setSubtitleSettingsLoaded] = useState(false); // Use reusable gesture controls hook const gestureControls = usePlayerGestureControls({ volume, setVolume, brightness, setBrightness, volumeRange: { min: 0, max: 100 }, // KSPlayer uses 0-100 volumeSensitivity: 0.006, brightnessSensitivity: 0.004, debugMode: DEBUG_MODE, }); // Load speed settings from storage const loadSpeedSettings = useCallback(async () => { try { const saved = await mmkvStorage.getItem(SPEED_SETTINGS_KEY); if (saved) { const settings = JSON.parse(saved); if (typeof settings.holdToSpeedEnabled === 'boolean') { setHoldToSpeedEnabled(settings.holdToSpeedEnabled); } if (typeof settings.holdToSpeedValue === 'number') { setHoldToSpeedValue(settings.holdToSpeedValue); } } } catch (error) { logger.warn('[KSPlayerCore] Error loading speed settings:', error); } }, []); // Save speed settings to storage const saveSpeedSettings = useCallback(async () => { try { const settings = { holdToSpeedEnabled, holdToSpeedValue, }; await mmkvStorage.setItem(SPEED_SETTINGS_KEY, JSON.stringify(settings)); } catch (error) { logger.warn('[KSPlayerCore] Error saving speed settings:', error); } }, [holdToSpeedEnabled, holdToSpeedValue]); // Load speed settings on mount useEffect(() => { loadSpeedSettings(); }, [loadSpeedSettings]); // Save speed settings when they change useEffect(() => { saveSpeedSettings(); }, [saveSpeedSettings]); // 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, groupedEpisodes: metadataGroupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => {} }; 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; // Load custom backdrop on mount // Prefetch backdrop and title logo for faster loading screen appearance useEffect(() => { if (backdrop && typeof backdrop === 'string') { // Reset loading state setIsBackdropLoaded(false); backdropImageOpacityAnim.setValue(0); // Prefetch the image try { FastImage.preload([{ uri: backdrop }]); // Image prefetch initiated, fade it in smoothly setIsBackdropLoaded(true); Animated.timing(backdropImageOpacityAnim, { toValue: 1, duration: 400, useNativeDriver: true, }).start(); } catch (error) { // If prefetch fails, still show the image but without animation if (__DEV__) logger.warn('[VideoPlayer] Backdrop prefetch failed, showing anyway:', error); setIsBackdropLoaded(true); backdropImageOpacityAnim.setValue(1); } } else { // No backdrop provided, consider it "loaded" setIsBackdropLoaded(true); backdropImageOpacityAnim.setValue(0); } }, [backdrop]); useEffect(() => { const logoUrl = (metadata && (metadata as any).logo) as string | undefined; if (logoUrl && typeof logoUrl === 'string') { try { FastImage.preload([{ uri: logoUrl }]); } catch (error) { // Silently ignore logo prefetch errors } } }, [metadata]); // Log video source configuration with headers useEffect(() => { console.log('[KSPlayerCore] Video source configured with:', { uri: currentStreamUrl, hasHeaders: !!(headers && Object.keys(headers).length > 0), headers: headers && Object.keys(headers).length > 0 ? headers : undefined }); }, [currentStreamUrl, headers]); // Resolve current episode description for series const currentEpisodeDescription = (() => { try { if (type !== 'series') return ''; const allEpisodes = Object.values(groupedEpisodes || {}).flat() as any[]; if (!allEpisodes || allEpisodes.length === 0) return ''; let match: any | null = null; if (episodeId) { match = allEpisodes.find(ep => ep?.stremioId === episodeId || String(ep?.id) === String(episodeId)); } if (!match && season && episode) { match = allEpisodes.find(ep => ep?.season_number === season && ep?.episode_number === episode); } return (match?.overview || '').trim(); } catch { return ''; } })(); // Find next episode for series (fallback to metadataGroupedEpisodes when needed) const nextEpisode = useMemo(() => { try { if (type !== 'series' || !season || !episode) return null; const sourceGroups = groupedEpisodes && Object.keys(groupedEpisodes || {}).length > 0 ? groupedEpisodes : (metadataGroupedEpisodes || {}); const allEpisodes = Object.values(sourceGroups || {}).flat() as any[]; if (!allEpisodes || allEpisodes.length === 0) return null; // First try next episode in same season let nextEp = allEpisodes.find((ep: any) => ep.season_number === season && ep.episode_number === episode + 1 ); // If not found, try first episode of next season if (!nextEp) { nextEp = allEpisodes.find((ep: any) => ep.season_number === season + 1 && ep.episode_number === 1 ); } if (DEBUG_MODE) { logger.log('[KSPlayerCore] nextEpisode computation', { fromRouteGroups: !!(groupedEpisodes && Object.keys(groupedEpisodes || {}).length), fromMetadataGroups: !!(metadataGroupedEpisodes && Object.keys(metadataGroupedEpisodes || {}).length), allEpisodesCount: allEpisodes?.length || 0, currentSeason: season, currentEpisode: episode, found: !!nextEp, foundId: nextEp?.stremioId || nextEp?.id, foundName: nextEp?.name, }); } return nextEp; } catch { return null; } }, [type, season, episode, groupedEpisodes, metadataGroupedEpisodes]); // 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 = () => { // Do not hide while user is interacting with the slider if (isDragging) { return; } 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) { if (__DEV__) logger.log(`[VideoPlayer] Center Zoom: ${newScale.toFixed(2)}x`); } }; const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => { if (event.nativeEvent.state === State.END) { setLastZoomScale(zoomScale); if (DEBUG_MODE) { if (__DEV__) 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) { if (__DEV__) logger.log(`[VideoPlayer] Zoom reset to ${targetZoom}x (16:9: ${is16by9Content})`); } }; // Long press gesture handlers for speed boost const onLongPressActivated = useCallback(() => { if (!holdToSpeedEnabled) return; if (!isSpeedBoosted && playbackSpeed !== holdToSpeedValue) { setOriginalSpeed(playbackSpeed); setPlaybackSpeed(holdToSpeedValue); setIsSpeedBoosted(true); // Show "Activated" overlay setShowSpeedActivatedOverlay(true); Animated.spring(speedActivatedOverlayOpacity, { toValue: 1, tension: 100, friction: 8, useNativeDriver: true, }).start(); // Auto-hide after 2 seconds setTimeout(() => { Animated.timing(speedActivatedOverlayOpacity, { toValue: 0, duration: 300, useNativeDriver: true, }).start(() => { setShowSpeedActivatedOverlay(false); }); }, 2000); logger.log(`[KSPlayerCore] Speed boost activated: ${holdToSpeedValue}x`); } }, [isSpeedBoosted, playbackSpeed, holdToSpeedEnabled, holdToSpeedValue, speedActivatedOverlayOpacity]); const restoreSpeedSafely = useCallback(() => { if (isSpeedBoosted) { setPlaybackSpeed(originalSpeed); setIsSpeedBoosted(false); logger.log('[KSPlayerCore] Speed boost deactivated, restored to:', originalSpeed); } }, [isSpeedBoosted, originalSpeed]); const onLongPressEnd = useCallback(() => { restoreSpeedSafely(); }, [restoreSpeedSafely]); const onLongPressStateChange = useCallback((event: LongPressGestureHandlerGestureEvent) => { // Ensure restoration on cancel/fail/end as well // @ts-ignore - numeric State enum const state = event?.nativeEvent?.state; if (state === State.CANCELLED || state === State.FAILED || state === State.END) { restoreSpeedSafely(); } }, [restoreSpeedSafely]); // Safety: restore speed on unmount if still boosted useEffect(() => { return () => { if (isSpeedBoosted) { try { setPlaybackSpeed(originalSpeed); } catch {} } }; }, [isSpeedBoosted, originalSpeed]); useEffect(() => { if (videoAspectRatio && effectiveDimensions.width > 0 && effectiveDimensions.height > 0) { const styles = calculateVideoStyles( videoAspectRatio * 1000, 1000, effectiveDimensions.width, effectiveDimensions.height ); setCustomVideoStyles(styles); if (DEBUG_MODE) { if (__DEV__) logger.log(`[VideoPlayer] Screen dimensions changed, recalculated styles:`, styles); } } }, [effectiveDimensions, videoAspectRatio]); // Force landscape orientation after opening animation completes useEffect(() => { const lockOrientation = async () => { try { await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); if (__DEV__) logger.log('[VideoPlayer] Locked to landscape orientation'); } catch (error) { logger.warn('[VideoPlayer] Failed to lock orientation:', error); } }; // Lock orientation after opening animation completes to prevent glitches if (isOpeningAnimationComplete) { lockOrientation(); } return () => { // Do not unlock orientation here; we unlock explicitly on close to avoid mid-transition flips }; }, [isOpeningAnimationComplete]); useEffect(() => { const subscription = Dimensions.addEventListener('change', ({ screen }) => { setScreenDimensions(screen); // Re-apply immersive mode on layout changes (Android) - only after opening animation if (isOpeningAnimationComplete) { enableImmersiveMode(); } }); const initializePlayer = async () => { StatusBar.setHidden(true, 'none'); // Enable immersive mode after opening animation to prevent glitches if (isOpeningAnimationComplete) { enableImmersiveMode(); } startOpeningAnimation(); // Initialize current volume and brightness levels // Volume starts at 100 (full volume) for KSPlayer setVolume(100); if (DEBUG_MODE) { logger.log(`[VideoPlayer] Initial volume: 100 (KSPlayer native)`); } try { const currentBrightness = await Brightness.getBrightnessAsync(); setBrightness(currentBrightness); if (DEBUG_MODE) { logger.log(`[VideoPlayer] Initial brightness: ${currentBrightness}`); } } catch (error) { logger.warn('[VideoPlayer] Error getting initial brightness:', error); // Fallback to 1.0 if brightness API fails setBrightness(1.0); } }; initializePlayer(); return () => { subscription?.remove(); disableImmersiveMode(); }; }, [isOpeningAnimationComplete]); // Re-apply immersive mode when screen gains focus (Android) useFocusEffect( useCallback(() => { if (isOpeningAnimationComplete) { enableImmersiveMode(); } return () => {}; }, [isOpeningAnimationComplete]) ); // Re-apply immersive mode when app returns to foreground (Android) useEffect(() => { const onAppStateChange = (state: string) => { if (state === 'active' && isOpeningAnimationComplete) { enableImmersiveMode(); } }; const sub = AppState.addEventListener('change', onAppStateChange); return () => { sub.remove(); }; }, [isOpeningAnimationComplete]); 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 = () => { // Stop the pulse animation immediately pulseAnim.stopAnimation(); 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(() => { setIsOpeningAnimationComplete(true); // Delay hiding the overlay to allow background fade animation to complete setTimeout(() => { setShouldHideOpeningOverlay(true); }, 450); // Slightly longer than the background fade duration // Enable immersive mode and lock orientation now that animation is complete enableImmersiveMode(); }); }; useEffect(() => { const loadWatchProgress = async () => { if (id && type) { try { if (__DEV__) { logger.log(`[VideoPlayer] Loading watch progress for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`); } const savedProgress = await storageService.getWatchProgress(id, type, episodeId); if (__DEV__) { logger.log(`[VideoPlayer] Saved progress:`, savedProgress); } if (savedProgress) { const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; if (__DEV__) logger.log(`[VideoPlayer] Progress: ${progressPercent.toFixed(1)}% (${savedProgress.currentTime}/${savedProgress.duration})`); if (progressPercent < 85) { setResumePosition(savedProgress.currentTime); setSavedDuration(savedProgress.duration); if (__DEV__) logger.log(`[VideoPlayer] Set resume position to: ${savedProgress.currentTime} of ${savedProgress.duration}`); if (appSettings.alwaysResume) { // Only prepare auto-resume state and seek when AlwaysResume is enabled setInitialPosition(savedProgress.currentTime); initialSeekTargetRef.current = savedProgress.currentTime; if (__DEV__) logger.log(`[VideoPlayer] AlwaysResume enabled. Auto-seeking to ${savedProgress.currentTime}`); // Seek immediately after load seekToTime(savedProgress.currentTime); } else { // Do not set initialPosition; start from beginning with no auto-seek setShowResumeOverlay(true); if (__DEV__) logger.log(`[VideoPlayer] AlwaysResume disabled. Not auto-seeking; overlay shown (if enabled)`); } } else { if (__DEV__) 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 { if (__DEV__) logger.log(`[VideoPlayer] Missing id or type: id=${id}, type=${type}`); } }; loadWatchProgress(); }, [id, type, episodeId, appSettings.alwaysResume]); 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); } const syncInterval = 20000; // 20s to further reduce CPU load const interval = setInterval(() => { saveWatchProgress(); }, syncInterval); setProgressSaveInterval(interval); return () => { clearInterval(interval); setProgressSaveInterval(null); }; } }, [id, type, paused, 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); // IMMEDIATE: Send immediate pause update to Trakt when user pauses if (duration > 0) { traktAutosync.handleProgressUpdate(currentTime, duration, true); // force=true triggers immediate sync } } }; const seekToTime = (rawSeconds: number) => { // For KSPlayer, we need to wait for the player to be ready if (!ksPlayerRef.current || isSeeking.current) { if (DEBUG_MODE) { logger.error(`[VideoPlayer] Seek failed: ksPlayerRef=${!!ksPlayerRef.current}, seeking=${isSeeking.current}`); } return; } // Clamp to just before the end to avoid triggering onEnd when duration is known. const timeInSeconds = duration > 0 ? Math.max(0, Math.min(rawSeconds, duration - END_EPSILON)) : Math.max(0, rawSeconds); if (DEBUG_MODE) { if (__DEV__) logger.log(`[VideoPlayer] Seeking to ${timeInSeconds.toFixed(2)}s out of ${duration.toFixed(2)}s`); } isSeeking.current = true; // KSPlayer uses direct time seeking ksPlayerRef.current.seek(timeInSeconds); setTimeout(() => { if (isMounted.current) { isSeeking.current = false; if (DEBUG_MODE) { logger.log(`[VideoPlayer] KSPlayer seek completed to ${timeInSeconds.toFixed(2)}s`); } // IMMEDIATE SYNC: Update Trakt progress immediately after seeking traktAutosync.handleProgressUpdate(timeInSeconds, duration, true); // force=true for immediate sync } }, 500); }; // 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); // Remember if we were playing before the user started dragging wasPlayingBeforeDragRef.current = !paused; // Keep controls visible while dragging and cancel any hide timeout if (!showControls) setShowControls(true); if (controlsTimeout.current) { clearTimeout(controlsTimeout.current); controlsTimeout.current = null; } }; const handleSlidingComplete = (value: number) => { setIsDragging(false); if (duration > 0) { const seekTime = Math.min(value, duration - END_EPSILON); seekToTime(seekTime); // If the video was playing before the drag, ensure we remain in playing state after the seek if (wasPlayingBeforeDragRef.current) { setTimeout(() => { if (isMounted.current) { setPaused(false); } }, 350); } pendingSeekValue.current = null; } // Restart auto-hide timer after interaction finishes if (controlsTimeout.current) { clearTimeout(controlsTimeout.current); } if (!showControls) setShowControls(true); controlsTimeout.current = setTimeout(hideControls, 5000); }; // Ensure auto-hide resumes after drag ends useEffect(() => { if (!isDragging && showControls) { if (controlsTimeout.current) { clearTimeout(controlsTimeout.current); } controlsTimeout.current = setTimeout(hideControls, 5000); } }, [isDragging, showControls]); // Removed processProgressTouch - no longer needed with React Native Community Slider const handleProgress = (event: any) => { if (isDragging || isSeeking.current) return; // KSPlayer returns times in seconds directly const currentTimeInSeconds = event.currentTime; const durationInSeconds = event.duration; // Update duration if it's available and different if (durationInSeconds > 0 && durationInSeconds !== duration) { setDuration(durationInSeconds); } // Only update if there's a significant change to avoid unnecessary updates if (Math.abs(currentTimeInSeconds - currentTime) > 0.5) { safeSetState(() => setCurrentTime(currentTimeInSeconds)); // KSPlayer returns bufferTime in seconds const bufferedTime = event.bufferTime || currentTimeInSeconds; safeSetState(() => setBuffered(bufferedTime)); } // Update AirPlay state if available if (event.airPlayState) { const wasAirPlayActive = isAirPlayActive; setIsAirPlayActive(event.airPlayState.isExternalPlaybackActive); setAllowsAirPlay(event.airPlayState.allowsExternalPlayback); // Log AirPlay state changes for debugging if (wasAirPlayActive !== event.airPlayState.isExternalPlaybackActive) { if (__DEV__) logger.log(`[VideoPlayer] AirPlay state changed: ${event.airPlayState.isExternalPlaybackActive ? 'ACTIVE' : 'INACTIVE'}`); } } // Safety: if audio is advancing but onLoad didn't fire, dismiss opening overlay if (!isOpeningAnimationComplete) { setIsVideoLoaded(true); setIsPlayerReady(true); completeOpeningAnimation(); } // If time is advancing right after seek and we previously intended to play, // ensure paused state is false to keep UI in sync if (wasPlayingBeforeDragRef.current && paused && !isDragging) { setPaused(false); // Reset the intent once corrected wasPlayingBeforeDragRef.current = false; } // Periodic check for disabled audio track (every 3 seconds, max 3 attempts) const now = Date.now(); if (now - lastAudioTrackCheck > 3000 && !paused && duration > 0 && audioTrackFallbackAttempts < 3) { setLastAudioTrackCheck(now); // Check if audio track is disabled (-1) and we have available tracks if (selectedAudioTrack === -1 && ksAudioTracks.length > 1) { logger.warn('[VideoPlayer] Detected disabled audio track, attempting fallback'); // Find a fallback audio track (prefer stereo/standard formats) const fallbackTrack = ksAudioTracks.find((track, index) => { const trackName = (track.name || '').toLowerCase(); const trackLang = (track.language || '').toLowerCase(); // Prefer stereo, AAC, or standard audio formats, avoid heavy codecs return !trackName.includes('truehd') && !trackName.includes('dts') && !trackName.includes('dolby') && !trackName.includes('atmos') && !trackName.includes('7.1') && !trackName.includes('5.1') && index !== selectedAudioTrack; // Don't select the same track }); if (fallbackTrack) { const fallbackIndex = ksAudioTracks.indexOf(fallbackTrack); logger.warn(`[VideoPlayer] Switching to fallback audio track: ${fallbackTrack.name || 'Unknown'} (index: ${fallbackIndex})`); // Increment fallback attempts counter setAudioTrackFallbackAttempts(prev => prev + 1); // Switch to fallback audio track setSelectedAudioTrack(fallbackIndex); // Brief pause to allow track switching setPaused(true); setTimeout(() => { if (isMounted.current) { setPaused(false); } }, 500); } else { logger.warn('[VideoPlayer] No suitable fallback audio track found'); // Increment attempts even if no fallback found to prevent infinite checking setAudioTrackFallbackAttempts(prev => prev + 1); } } } }; const onLoad = (data: any) => { try { if (DEBUG_MODE) { logger.log('[VideoPlayer] Video loaded:', data); } // Clear any pending startup silent retry timers and counters on success if (startupRetryTimerRef.current) { clearTimeout(startupRetryTimerRef.current); startupRetryTimerRef.current = null; } startupRetryCountRef.current = 0; if (!isMounted.current) { logger.warn('[VideoPlayer] Component unmounted, skipping onLoad'); return; } if (!data) { logger.error('[VideoPlayer] onLoad called with null/undefined data'); return; } // Extract player backend information if (data.playerBackend) { const newPlayerBackend = data.playerBackend; setPlayerBackend(newPlayerBackend); if (DEBUG_MODE) { logger.log(`[VideoPlayer] Player backend: ${newPlayerBackend}`); } // Reset AirPlay state if switching to KSMEPlayer (which doesn't support AirPlay) if (newPlayerBackend === 'KSMEPlayer' && (isAirPlayActive || allowsAirPlay)) { setIsAirPlayActive(false); setAllowsAirPlay(false); if (DEBUG_MODE) { logger.log('[VideoPlayer] Reset AirPlay state for KSMEPlayer'); } } } // KSPlayer returns duration in seconds directly const videoDuration = data.duration; if (DEBUG_MODE) { logger.log(`[VideoPlayer] Setting duration to: ${videoDuration}`); } if (videoDuration > 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 from naturalSize (KSPlayer format) if (data.naturalSize && data.naturalSize.width && data.naturalSize.height) { setVideoAspectRatio(data.naturalSize.width / data.naturalSize.height); } else { // Fallback to 16:9 aspect ratio if naturalSize is not available setVideoAspectRatio(16 / 9); logger.warn('[VideoPlayer] naturalSize not available, using default 16:9 aspect ratio'); } if (data.audioTracks && data.audioTracks.length > 0) { // Enhanced debug logging to see all available fields if (DEBUG_MODE) { logger.log(`[VideoPlayer] Raw audio tracks data:`, data.audioTracks); data.audioTracks.forEach((track: any, idx: number) => { logger.log(`[VideoPlayer] Track ${idx} raw data:`, { id: track.id, name: track.name, language: track.language, languageCode: track.languageCode, isEnabled: track.isEnabled, bitRate: track.bitRate, bitDepth: track.bitDepth, allKeys: Object.keys(track), fullTrackObject: track }); }); } const formattedAudioTracks = data.audioTracks.map((track: any, index: number) => { const trackIndex = track.id !== undefined ? track.id : index; // Build comprehensive track name from available fields let trackName = ''; const parts = []; // Add language if available let language = track.language || track.languageCode; if (language && language !== 'Unknown' && language !== 'und' && language !== '') { parts.push(language.toUpperCase()); } // Add bitrate if available const bitrate = track.bitRate; if (bitrate && bitrate > 0) { parts.push(`${Math.round(bitrate / 1000)}kbps`); } // Add bit depth if available const bitDepth = track.bitDepth; if (bitDepth && bitDepth > 0) { parts.push(`${bitDepth}bit`); } // Add track name if available and not generic let title = track.name; if (title && !title.match(/^(Audio|Track)\s*\d*$/i) && title !== 'Unknown') { // Clean up title by removing language brackets and trailing punctuation title = title.replace(/\s*\[[^\]]+\]\s*[-–—]*\s*$/, '').trim(); if (title && title !== 'Unknown') { parts.push(title); } } // Combine parts or fallback to generic name if (parts.length > 0) { trackName = parts.join(' • '); } else { // For simple track names like "Track 1", "Audio 1", etc., use them as-is const simpleName = track.name; if (simpleName && simpleName.match(/^(Track|Audio)\s*\d*$/i)) { trackName = simpleName; } else { trackName = `Audio ${index + 1}`; } } const trackLanguage = language || 'Unknown'; if (DEBUG_MODE) { logger.log(`[VideoPlayer] Processed KSPlayer track ${index}:`, { id: trackIndex, name: trackName, language: trackLanguage, parts: parts, bitRate: bitrate, bitDepth: bitDepth }); } return { id: trackIndex, // Use the actual track ID from KSPlayer name: trackName, language: trackLanguage, }; }); setKsAudioTracks(formattedAudioTracks); // Auto-select English audio track if available, otherwise first track if (selectedAudioTrack === null && formattedAudioTracks.length > 0) { // Look for English track first const englishTrack = formattedAudioTracks.find((track: {id: number, name: string, language?: string}) => { const lang = (track.language || '').toLowerCase(); return lang === 'english' || lang === 'en' || lang === 'eng' || (track.name && track.name.toLowerCase().includes('english')); }); const selectedTrack = englishTrack || formattedAudioTracks[0]; setSelectedAudioTrack(selectedTrack.id); if (DEBUG_MODE) { if (englishTrack) { logger.log(`[VideoPlayer] Auto-selected English audio track: ${selectedTrack.name} (ID: ${selectedTrack.id})`); } else { logger.log(`[VideoPlayer] No English track found, auto-selected first audio track: ${selectedTrack.name} (ID: ${selectedTrack.id})`); } } } if (DEBUG_MODE) { logger.log(`[VideoPlayer] Formatted audio tracks:`, formattedAudioTracks); } } if (data.textTracks && data.textTracks.length > 0) { // Process KSPlayer text tracks const formattedTextTracks = data.textTracks.map((track: any, index: number) => ({ id: track.id !== undefined ? track.id : index, name: track.name || `Subtitle ${index + 1}`, language: track.language || track.languageCode || 'Unknown', isEnabled: track.isEnabled || false, isImageSubtitle: track.isImageSubtitle || false })); setKsTextTracks(formattedTextTracks); // Auto-select English subtitle track if available if (selectedTextTrack === -1 && !useCustomSubtitles && formattedTextTracks.length > 0) { if (DEBUG_MODE) { logger.log(`[VideoPlayer] Available KSPlayer subtitle tracks:`, formattedTextTracks); } // Look for English track first const englishTrack = formattedTextTracks.find((track: any) => { const lang = (track.language || '').toLowerCase(); const name = (track.name || '').toLowerCase(); return lang === 'english' || lang === 'en' || lang === 'eng' || name.includes('english') || name.includes('en'); }); if (englishTrack) { setSelectedTextTrack(englishTrack.id); if (DEBUG_MODE) { logger.log(`[VideoPlayer] Auto-selected English subtitle track: ${englishTrack.name} (ID: ${englishTrack.id})`); } } else if (DEBUG_MODE) { logger.log(`[VideoPlayer] No English subtitle track found, keeping subtitles disabled`); } } } setIsVideoLoaded(true); setIsPlayerReady(true); // Reset audio track fallback attempts when new video loads setAudioTrackFallbackAttempts(0); setLastAudioTrackCheck(0); // 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 (videoDuration > 0 && isMounted.current) { seekToTime(initialPosition); setIsInitialSeekComplete(true); logger.log(`[VideoPlayer] Initial seek completed to: ${initialPosition}s`); } else { logger.error(`[VideoPlayer] Initial seek failed: duration=${videoDuration}, mounted=${isMounted.current}`); } }, 500); } controlsTimeout.current = setTimeout(hideControls, 5000); // Auto-fetch and load English external subtitles if available if (imdbId) { fetchAvailableSubtitles(undefined, true); } } 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) => { 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 = () => { // iOS KSPlayer: toggle native resize mode so subtitles remain independent if (Platform.OS === 'ios') { setResizeMode((prev) => (prev === 'cover' ? 'contain' : 'cover')); return; } // Fallback (non‑iOS paths): keep legacy zoom behavior 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) { if (__DEV__) 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); } // On iOS tablets, keep rotation unlocked; on phones, return to portrait if (Platform.OS === 'ios') { const { width: dw, height: dh } = Dimensions.get('window'); const isTablet = (Platform as any).isPad === true || Math.min(dw, dh) >= 768; setTimeout(() => { if (isTablet) { ScreenOrientation.unlockAsync().catch(() => {}); } else { ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {}); } }, 50); } // Disable immersive mode disableImmersiveMode(); // Navigate back to previous screen (StreamsScreen expected to be below Player) try { if (navigation.canGoBack()) { navigation.goBack(); } else { // Fallback: navigate to Streams if stack was not set as expected (navigation as any).navigate('Streams', { id, type, episodeId, fromPlayer: true }); } logger.log('[VideoPlayer] Navigation completed'); } catch (navError) { logger.error('[VideoPlayer] Navigation error:', navError); // Last resort: try to navigate to Streams (navigation as any).navigate('Streams', { id, type, episodeId, fromPlayer: true }); } }; // Navigate immediately cleanup(); // Send Trakt sync in background (don't await) const backgroundSync = async () => { try { logger.log('[VideoPlayer] Starting background Trakt sync'); // IMMEDIATE: Force immediate progress update (scrobble/pause) with the exact time await traktAutosync.handleProgressUpdate(actualCurrentTime, duration, true); // IMMEDIATE: Use user_close reason to trigger immediate scrobble stop await traktAutosync.handlePlaybackEnd(actualCurrentTime, duration, 'user_close'); 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); } // Reinforce immersive mode after any UI toggle (Android) enableImmersiveMode(); return newShowControls; }); }; const handleError = (error: any) => { try { logger.error('[VideoPlayer] Playback Error:', error); // Detect KSPlayer startup timeout and silently retry without UI const errText = typeof error === 'string' ? error : (error?.message || error?.error?.message || error?.title || ''); const isStartupTimeout = /timeout/i.test(errText) && /stream.*ready/i.test(errText); if (isStartupTimeout && !isVideoLoaded) { // Suppress any error modal and retry silently if (errorTimeoutRef.current) { clearTimeout(errorTimeoutRef.current); errorTimeoutRef.current = null; } setShowErrorModal(false); const attempt = startupRetryCountRef.current; if (attempt < MAX_STARTUP_RETRIES) { const backoffMs = [4000, 8000, 12000][attempt] ?? 8000; startupRetryCountRef.current = attempt + 1; logger.warn(`[VideoPlayer] Startup timeout; retrying (${attempt + 1}/${MAX_STARTUP_RETRIES}) in ${backoffMs}ms`); if (startupRetryTimerRef.current) { clearTimeout(startupRetryTimerRef.current); } startupRetryTimerRef.current = setTimeout(() => { if (!ksPlayerRef.current) return; try { // Reload the same source silently using native bridge ksPlayerRef.current.setSource({ uri: currentStreamUrl, headers: headers && Object.keys(headers).length > 0 ? headers : undefined }); // Ensure playback resumes if not paused ksPlayerRef.current.setPaused(paused); logger.log('[VideoPlayer] Retried source load via KSPlayer.setSource'); } catch (e) { logger.error('[VideoPlayer] Error during silent retry setSource:', e); } }, backoffMs); return; // Exit handler; do not show UI } logger.error('[VideoPlayer] Max startup retries reached; proceeding to normal error handling'); } // Check for audio codec errors (TrueHD, DTS, Dolby, etc.) const isAudioCodecError = (error?.message && /(trhd|truehd|true\s?hd|dts|dolby|atmos|e-ac3|ac3)/i.test(error.message)) || (error?.error?.message && /(trhd|truehd|true\s?hd|dts|dolby|atmos|e-ac3|ac3)/i.test(error.error.message)) || (error?.title && /codec not supported/i.test(error.title)); // Handle audio codec errors with automatic fallback if (isAudioCodecError && ksAudioTracks.length > 1) { logger.warn('[VideoPlayer] Audio codec error detected, attempting audio track fallback'); // Find a fallback audio track (prefer stereo/standard formats) const fallbackTrack = ksAudioTracks.find((track, index) => { const trackName = (track.name || '').toLowerCase(); const trackLang = (track.language || '').toLowerCase(); // Prefer stereo, AAC, or standard audio formats, avoid heavy codecs return !trackName.includes('truehd') && !trackName.includes('dts') && !trackName.includes('dolby') && !trackName.includes('atmos') && !trackName.includes('7.1') && !trackName.includes('5.1') && index !== selectedAudioTrack; // Don't select the same track }); if (fallbackTrack) { const fallbackIndex = ksAudioTracks.indexOf(fallbackTrack); logger.warn(`[VideoPlayer] Switching to fallback audio track: ${fallbackTrack.name || 'Unknown'} (index: ${fallbackIndex})`); // Clear any existing error state if (errorTimeoutRef.current) { clearTimeout(errorTimeoutRef.current); errorTimeoutRef.current = null; } setShowErrorModal(false); // Switch to fallback audio track setSelectedAudioTrack(fallbackIndex); // Brief pause to allow track switching setPaused(true); setTimeout(() => { if (isMounted.current) { setPaused(false); } }, 500); return; // Don't show error UI, attempt recovery } } // Format error details for user display let errorMessage = 'An unknown error occurred'; if (error) { if (isAudioCodecError) { errorMessage = 'Audio codec compatibility issue detected. The video contains unsupported audio codec (TrueHD/DTS/Dolby). Please try selecting a different audio track or use an alternative video source.'; } else 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); } catch (handlerError) { // Fallback error handling to prevent crashes during error processing logger.error('[VideoPlayer] Error in error handler:', handlerError); if (isMounted.current) { // Minimal safe error handling setErrorDetails('A critical error occurred'); setShowErrorModal(true); // Force exit after 3 seconds if error handler itself fails setTimeout(() => { if (isMounted.current) { handleClose(); } }, 3000); } } }; 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 { // REGULAR: Use regular sync for natural video end (not immediate since it's not user-triggered) logger.log('[VideoPlayer] Video ended naturally, sending final progress update with 100%'); await traktAutosync.handleProgressUpdate(finalTime, duration, false); // force=false for regular sync // REGULAR: Use 'ended' reason for natural video end (uses regular queued method) 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) => { if (DEBUG_MODE) { logger.log(`[VideoPlayer] Selecting audio track: ${trackId}`); logger.log(`[VideoPlayer] Available tracks:`, ksAudioTracks); } // Validate that the track exists const trackExists = ksAudioTracks.some(track => track.id === trackId); if (!trackExists) { logger.error(`[VideoPlayer] Audio track ${trackId} not found in available tracks`); return; } // Get the selected track info for logging const selectedTrack = ksAudioTracks.find(track => track.id === trackId); if (selectedTrack && DEBUG_MODE) { logger.log(`[VideoPlayer] Switching to track: ${selectedTrack.name} (${selectedTrack.language})`); // Check if this is a multi-channel track that might need downmixing const trackName = selectedTrack.name.toLowerCase(); const isMultiChannel = trackName.includes('5.1') || trackName.includes('7.1') || trackName.includes('truehd') || trackName.includes('dts') || trackName.includes('dolby') || trackName.includes('atmos'); if (isMultiChannel) { logger.log(`[VideoPlayer] Multi-channel audio track detected: ${selectedTrack.name}`); logger.log(`[VideoPlayer] KSPlayer will apply downmixing to ensure dialogue is audible`); } } // If changing tracks, briefly pause to allow smooth transition const wasPlaying = !paused; if (wasPlaying) { setPaused(true); } // Set the new audio track setSelectedAudioTrack(trackId); if (DEBUG_MODE) { logger.log(`[VideoPlayer] Audio track changed to: ${trackId}`); } // Resume playback after a brief delay if it was playing if (wasPlaying) { setTimeout(() => { if (isMounted.current) { setPaused(false); if (DEBUG_MODE) { logger.log(`[VideoPlayer] Resumed playback after audio track change`); } } }, 300); } }; const selectTextTrack = (trackId: number) => { if (trackId === -999) { setUseCustomSubtitles(true); setSelectedTextTrack(-1); } else { setUseCustomSubtitles(false); setSelectedTextTrack(trackId); } }; const disableCustomSubtitles = () => { setUseCustomSubtitles(false); setCustomSubtitles([]); // Reset to first available built-in track or disable all tracks setSelectedTextTrack(ksTextTracks.length > 0 ? 0 : -1); }; // Ensure native KSPlayer text tracks are disabled when using custom (addon) subtitles // and re-applied when switching back to built-in tracks. This prevents double-rendering. useEffect(() => { try { if (useCustomSubtitles) { // -1 disables native subtitle rendering in KSPlayer setSelectedTextTrack(-1); } else if (typeof selectedTextTrack === 'number' && selectedTextTrack >= 0) { // KSPlayer picks it up via prop } } catch (e) { // no-op: defensive guard in case ref methods are unavailable momentarily } }, [useCustomSubtitles, selectedTextTrack]); const loadSubtitleSize = async () => { try { // Prefer scoped subtitle settings const saved = await storageService.getSubtitleSettings(); if (saved && typeof saved.subtitleSize === 'number') { setSubtitleSize(saved.subtitleSize); return; } // One-time migrate legacy key if present const legacy = await mmkvStorage.getItem(SUBTITLE_SIZE_KEY); if (legacy) { const migrated = parseInt(legacy, 10); if (!Number.isNaN(migrated) && migrated > 0) { setSubtitleSize(migrated); try { const merged = { ...(saved || {}), subtitleSize: migrated }; await storageService.saveSubtitleSettings(merged); } catch {} } try { await mmkvStorage.removeItem(SUBTITLE_SIZE_KEY); } catch {} return; } // If no saved settings, use responsive default const screenWidth = Dimensions.get('window').width; setSubtitleSize(getDefaultSubtitleSize(screenWidth)); } catch (error) { logger.error('[VideoPlayer] Error loading subtitle size:', error); // Fallback to responsive default on error const screenWidth = Dimensions.get('window').width; setSubtitleSize(getDefaultSubtitleSize(screenWidth)); } }; const saveSubtitleSize = async (size: number) => { try { setSubtitleSize(size); // Persist via scoped subtitle settings so it survives restarts and account switches const saved = await storageService.getSubtitleSettings(); const next = { ...(saved || {}), subtitleSize: size }; await storageService.saveSubtitleSettings(next); } catch (error) { logger.error('[VideoPlayer] Error saving subtitle size:', error); } }; const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = true) => { const targetImdbId = imdbIdParam || imdbId; if (!targetImdbId) { logger.error('[VideoPlayer] No IMDb ID available for subtitle search'); return; } setIsLoadingSubtitleList(true); try { // Fetch from all installed subtitle-capable addons via Stremio const stremioType = type === 'series' ? 'series' : 'movie'; const stremioVideoId = stremioType === 'series' && season && episode ? `series:${targetImdbId}:${season}:${episode}` : undefined; const stremioResults = await stremioService.getSubtitles(stremioType, targetImdbId, stremioVideoId); const stremioSubs: WyzieSubtitle[] = (stremioResults || []).map(sub => ({ 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', })); // Sort with English languages first, then alphabetical over full list const isEnglish = (s: WyzieSubtitle) => { const lang = (s.language || '').toLowerCase(); const disp = (s.display || '').toLowerCase(); return lang === 'en' || lang === 'eng' || /^en([-_]|$)/.test(lang) || disp.includes('english'); }; stremioSubs.sort((a, b) => { const aIsEn = isEnglish(a); const bIsEn = isEnglish(b); if (aIsEn && !bIsEn) return -1; if (!aIsEn && bIsEn) return 1; return (a.display || '').localeCompare(b.display || ''); }); setAvailableSubtitles(stremioSubs); if (autoSelectEnglish) { const englishSubtitle = stremioSubs.find(sub => sub.language.toLowerCase() === 'eng' || sub.language.toLowerCase() === 'en' || sub.display.toLowerCase().includes('english') ); if (englishSubtitle) { loadWyzieSubtitle(englishSubtitle); return; } } if (!autoSelectEnglish) { // If no English found and not auto-selecting, still open the modal setShowSubtitleLanguageModal(true); } } catch (error) { logger.error('[VideoPlayer] Error fetching subtitles from OpenSubtitles addon:', error); } finally { setIsLoadingSubtitleList(false); } }; const loadWyzieSubtitle = async (subtitle: WyzieSubtitle) => { logger.log(`[VideoPlayer] Subtitle click received: id=${subtitle.id}, lang=${subtitle.language}, url=${subtitle.url}`); setShowSubtitleLanguageModal(false); setIsLoadingSubtitles(true); try { logger.log('[VideoPlayer] Fetching subtitle SRT start'); let srtContent = ''; try { const axiosResp = await axios.get(subtitle.url, { timeout: 10000, headers: { 'Accept': 'text/plain, */*', 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Nuvio/1.0' }, responseType: 'text', transitional: { clarifyTimeoutError: true } }); srtContent = typeof axiosResp.data === 'string' ? axiosResp.data : String(axiosResp.data || ''); } catch (axiosErr: any) { logger.warn('[VideoPlayer] Axios subtitle fetch failed, falling back to fetch()', { message: axiosErr?.message, code: axiosErr?.code }); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); try { const resp = await fetch(subtitle.url, { signal: controller.signal }); srtContent = await resp.text(); } finally { clearTimeout(timeoutId); } } logger.log(`[VideoPlayer] Fetching subtitle SRT done, size=${srtContent.length}`); const parsedCues = parseSRT(srtContent); logger.log(`[VideoPlayer] Parsed cues count=${parsedCues.length}`); // For KSPlayer on iOS: stop spinner early, then clear-apply and micro-seek nudge setIsLoadingSubtitles(false); logger.log('[VideoPlayer] isLoadingSubtitles -> false (early)'); // Clear existing state setUseCustomSubtitles(false); logger.log('[VideoPlayer] useCustomSubtitles -> false'); setCustomSubtitles([]); logger.log('[VideoPlayer] customSubtitles -> []'); setSelectedTextTrack(-1); logger.log('[VideoPlayer] selectedTextTrack -> -1'); // Apply immediately setCustomSubtitles(parsedCues); logger.log('[VideoPlayer] customSubtitles <- parsedCues'); setUseCustomSubtitles(true); logger.log('[VideoPlayer] useCustomSubtitles -> true'); setSelectedTextTrack(-1); logger.log('[VideoPlayer] selectedTextTrack -> -1 (disable native while using custom)'); // Immediately set current subtitle text try { const adjustedTime = currentTime + (subtitleOffsetSec || 0); const cueNow = parsedCues.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end); const textNow = cueNow ? cueNow.text : ''; setCurrentSubtitle(textNow); logger.log('[VideoPlayer] currentSubtitle set immediately after apply'); } catch (e) { logger.error('[VideoPlayer] Error setting immediate subtitle', e); } // Removed micro-seek nudge } catch (error) { logger.error('[VideoPlayer] Error loading Wyzie subtitle:', error); setIsLoadingSubtitles(false); } }; const togglePlayback = () => { setPaused(!paused); }; // Handle next episode button press const handlePlayNextEpisode = useCallback(async () => { if (!nextEpisode || !id || isLoadingNextEpisode) return; setIsLoadingNextEpisode(true); try { logger.log('[VideoPlayer] Loading next episode:', nextEpisode); // Create episode ID for next episode using stremioId if available, otherwise construct it const nextEpisodeId = nextEpisode.stremioId || `${id}:${nextEpisode.season_number}:${nextEpisode.episode_number}`; logger.log('[VideoPlayer] Fetching streams for next episode:', nextEpisodeId); // Import stremio service const stremioService = require('../../services/stremioService').default; let bestStream: any = null; let streamFound = false; let completedProviders = 0; const expectedProviders = new Set(); // Get installed addons to know how many providers to expect const installedAddons = stremioService.getInstalledAddons(); const streamAddons = installedAddons.filter((addon: any) => addon.resources && addon.resources.includes('stream') ); streamAddons.forEach((addon: any) => expectedProviders.add(addon.id)); // Collect all streams from all providers for the sources modal const allStreams: { [providerId: string]: { streams: any[]; addonName: string } } = {}; let hasNavigated = false; // Fetch streams for next episode await stremioService.getStreams('series', nextEpisodeId, (streams: any, addonId: any, addonName: any, error: any) => { completedProviders++; // Always collect streams from this provider for sources modal (even after navigation) if (streams && streams.length > 0) { allStreams[addonId] = { streams: streams, addonName: addonName || addonId }; } // Navigate with first good stream found, but continue collecting streams in background if (!hasNavigated && !streamFound && streams && streams.length > 0) { // Sort streams by quality and cache status (prefer cached/debrid streams) const sortedStreams = streams.sort((a: any, b: any) => { const aQuality = parseInt(a.title?.match(/(\d+)p/)?.[1] || '0', 10); const bQuality = parseInt(b.title?.match(/(\d+)p/)?.[1] || '0', 10); const aCached = a.behaviorHints?.cached || false; const bCached = b.behaviorHints?.cached || false; // Prioritize cached streams first if (aCached !== bCached) { return aCached ? -1 : 1; } // Then sort by quality (higher quality first) return bQuality - aQuality; }); bestStream = sortedStreams[0]; streamFound = true; hasNavigated = true; // Update loading details for the chip const qualityText = (bestStream.title?.match(/(\d+)p/) || [])[1] || null; setNextLoadingProvider(addonName || addonId || null); setNextLoadingQuality(qualityText); setNextLoadingTitle(bestStream.name || bestStream.title || null); logger.log('[VideoPlayer] Found stream for next episode:', bestStream); // Pause current playback to ensure no background player remains active setPaused(true); // Start navigation immediately but let stream fetching continue in background setTimeout(() => { navigation.replace('PlayerIOS', { uri: bestStream.url, title: metadata?.name || '', episodeTitle: nextEpisode.name, season: nextEpisode.season_number, episode: nextEpisode.episode_number, quality: (bestStream.title?.match(/(\d+)p/) || [])[1] || undefined, year: metadata?.year, streamProvider: addonName, streamName: bestStream.name || bestStream.title, headers: bestStream.headers || undefined, id, type: 'series', episodeId: nextEpisodeId, imdbId: imdbId ?? undefined, backdrop: backdrop || undefined, availableStreams: allStreams, // Pass current available streams (more will be added) }); setIsLoadingNextEpisode(false); }, 100); // Small delay to ensure smooth transition } // If we've checked all providers and no stream found if (completedProviders >= expectedProviders.size && !streamFound) { logger.warn('[VideoPlayer] No streams found for next episode after checking all providers'); setIsLoadingNextEpisode(false); } }); // Fallback timeout in case providers don't respond setTimeout(() => { if (!streamFound) { logger.warn('[VideoPlayer] Timeout: No streams found for next episode'); setIsLoadingNextEpisode(false); } }, 8000); } catch (error) { logger.error('[VideoPlayer] Error loading next episode:', error); setIsLoadingNextEpisode(false); } }, [nextEpisode, id, isLoadingNextEpisode, navigation, metadata, imdbId, backdrop]); // Function to hide pause overlay and show controls const hidePauseOverlay = useCallback(() => { if (showPauseOverlay) { // Reset cast details state when hiding overlay if (showCastDetails) { Animated.parallel([ Animated.timing(castDetailsOpacity, { toValue: 0, duration: 200, useNativeDriver: true, }), Animated.timing(castDetailsScale, { toValue: 0.95, duration: 200, useNativeDriver: true, }) ]).start(() => { setShowCastDetails(false); setSelectedCastMember(null); // Reset metadata animations metadataOpacity.setValue(1); metadataScale.setValue(1); }); } else { setShowCastDetails(false); setSelectedCastMember(null); // Reset metadata animations metadataOpacity.setValue(1); metadataScale.setValue(1); } Animated.parallel([ Animated.timing(pauseOverlayOpacity, { toValue: 0, duration: 220, useNativeDriver: true, }), Animated.timing(pauseOverlayTranslateY, { toValue: 8, duration: 220, useNativeDriver: true, }) ]).start(() => setShowPauseOverlay(false)); // Show controls when overlay is touched if (!showControls) { setShowControls(true); Animated.timing(fadeAnim, { toValue: 1, duration: 300, useNativeDriver: true, }).start(); // Auto-hide controls after 5 seconds if (controlsTimeout.current) { clearTimeout(controlsTimeout.current); } controlsTimeout.current = setTimeout(hideControls, 5000); } } }, [showPauseOverlay, pauseOverlayOpacity, pauseOverlayTranslateY, showControls, fadeAnim, controlsTimeout, hideControls]); // Handle paused overlay after 5 seconds of being paused useEffect(() => { if (paused) { if (pauseOverlayTimerRef.current) { clearTimeout(pauseOverlayTimerRef.current); } pauseOverlayTimerRef.current = setTimeout(() => { setShowPauseOverlay(true); pauseOverlayOpacity.setValue(0); pauseOverlayTranslateY.setValue(12); Animated.parallel([ Animated.timing(pauseOverlayOpacity, { toValue: 1, duration: 550, useNativeDriver: true, }), Animated.timing(pauseOverlayTranslateY, { toValue: 0, duration: 450, useNativeDriver: true, }) ]).start(); }, 5000); } else { if (pauseOverlayTimerRef.current) { clearTimeout(pauseOverlayTimerRef.current); pauseOverlayTimerRef.current = null; } hidePauseOverlay(); } return () => { if (pauseOverlayTimerRef.current) { clearTimeout(pauseOverlayTimerRef.current); pauseOverlayTimerRef.current = null; } }; }, [paused]); // Up Next visibility handled inside reusable component useEffect(() => { isMounted.current = true; return () => { isMounted.current = false; if (seekDebounceTimer.current) { clearTimeout(seekDebounceTimer.current); } if (errorTimeoutRef.current) { clearTimeout(errorTimeoutRef.current); } // Cleanup gesture controls gestureControls.cleanup(); if (startupRetryTimerRef.current) { clearTimeout(startupRetryTimerRef.current); startupRetryTimerRef.current = null; } }; }, []); const safeSetState = (setter: any) => { if (isMounted.current) { setter(); } }; useEffect(() => { if (!useCustomSubtitles || customSubtitles.length === 0) { if (currentSubtitle !== '') { setCurrentSubtitle(''); } if (currentFormattedSegments.length > 0) { setCurrentFormattedSegments([]); } return; } const adjustedTime = currentTime + (subtitleOffsetSec || 0) - 0.2; const currentCue = customSubtitles.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end ); const newSubtitle = currentCue ? currentCue.text : ''; setCurrentSubtitle(newSubtitle); // Extract formatted segments from current cue if (currentCue?.formattedSegments) { // Split by newlines to get per-line segments const lines = (currentCue.text || '').split(/\r?\n/); const segmentsPerLine: SubtitleSegment[][] = []; let segmentIndex = 0; for (const line of lines) { const lineSegments: SubtitleSegment[] = []; const words = line.split(/(\s+)/); for (const word of words) { if (word.trim()) { if (segmentIndex < currentCue.formattedSegments.length) { lineSegments.push(currentCue.formattedSegments[segmentIndex]); segmentIndex++; } else { // Fallback if segment count doesn't match lineSegments.push({ text: word }); } } } if (lineSegments.length > 0) { segmentsPerLine.push(lineSegments); } } setCurrentFormattedSegments(segmentsPerLine.length > 0 ? segmentsPerLine : []); } else { setCurrentFormattedSegments([]); } }, [currentTime, customSubtitles, useCustomSubtitles, subtitleOffsetSec]); // Load global subtitle settings useEffect(() => { (async () => { try { const saved = await storageService.getSubtitleSettings(); if (saved) { if (typeof saved.subtitleSize === 'number') setSubtitleSize(saved.subtitleSize); if (typeof saved.subtitleBackground === 'boolean') setSubtitleBackground(saved.subtitleBackground); if (typeof saved.subtitleTextColor === 'string') setSubtitleTextColor(saved.subtitleTextColor); if (typeof saved.subtitleBgOpacity === 'number') setSubtitleBgOpacity(saved.subtitleBgOpacity); if (typeof saved.subtitleTextShadow === 'boolean') setSubtitleTextShadow(saved.subtitleTextShadow); if (typeof saved.subtitleOutline === 'boolean') setSubtitleOutline(saved.subtitleOutline); if (typeof saved.subtitleOutlineColor === 'string') setSubtitleOutlineColor(saved.subtitleOutlineColor); if (typeof saved.subtitleOutlineWidth === 'number') setSubtitleOutlineWidth(saved.subtitleOutlineWidth); if (typeof saved.subtitleAlign === 'string') setSubtitleAlign(saved.subtitleAlign as 'center' | 'left' | 'right'); if (typeof saved.subtitleBottomOffset === 'number') setSubtitleBottomOffset(saved.subtitleBottomOffset); if (typeof saved.subtitleLetterSpacing === 'number') setSubtitleLetterSpacing(saved.subtitleLetterSpacing); if (typeof saved.subtitleLineHeightMultiplier === 'number') setSubtitleLineHeightMultiplier(saved.subtitleLineHeightMultiplier); if (typeof saved.subtitleOffsetSec === 'number') setSubtitleOffsetSec(saved.subtitleOffsetSec); } } catch {} finally { // Mark subtitle settings as loaded so we can safely persist subsequent changes try { setSubtitleSettingsLoaded(true); } catch {} } })(); }, []); // Persist global subtitle settings on change useEffect(() => { if (!subtitleSettingsLoaded) return; storageService.saveSubtitleSettings({ subtitleSize, subtitleBackground, subtitleTextColor, subtitleBgOpacity, subtitleTextShadow, subtitleOutline, subtitleOutlineColor, subtitleOutlineWidth, subtitleAlign, subtitleBottomOffset, subtitleLetterSpacing, subtitleLineHeightMultiplier, subtitleOffsetSec, }); }, [ subtitleSize, subtitleBackground, subtitleTextColor, subtitleBgOpacity, subtitleTextShadow, subtitleOutline, subtitleOutlineColor, subtitleOutlineWidth, subtitleAlign, subtitleBottomOffset, subtitleLetterSpacing, subtitleLineHeightMultiplier, subtitleOffsetSec, subtitleSettingsLoaded, ]); useEffect(() => { loadSubtitleSize(); }, []); // Handle audio track changes with proper logging useEffect(() => { if (selectedAudioTrack !== null && ksAudioTracks.length > 0) { const selectedTrack = ksAudioTracks.find(track => track.id === selectedAudioTrack); if (selectedTrack) { if (DEBUG_MODE) { logger.log(`[VideoPlayer] Audio track selected: ${selectedTrack.name} (${selectedTrack.language}) - ID: ${selectedAudioTrack}`); } } else { logger.warn(`[VideoPlayer] Selected audio track ${selectedAudioTrack} not found in available tracks`); } } }, [selectedAudioTrack, ksAudioTracks]); const increaseSubtitleSize = () => { const newSize = Math.min(subtitleSize + 2, 80); saveSubtitleSize(newSize); }; const decreaseSubtitleSize = () => { const newSize = Math.max(subtitleSize - 2, 8); saveSubtitleSize(newSize); }; const toggleSubtitleBackground = () => { setSubtitleBackground(prev => !prev); }; // AirPlay handler const handleAirPlayPress = async () => { if (!ksPlayerRef.current) return; try { // First ensure AirPlay is enabled if (!allowsAirPlay) { ksPlayerRef.current.setAllowsExternalPlayback(true); setAllowsAirPlay(true); logger.log(`[VideoPlayer] AirPlay enabled before showing picker`); } // Show the AirPlay picker ksPlayerRef.current.showAirPlayPicker(); logger.log(`[VideoPlayer] AirPlay picker triggered - check console for native logs`); } catch (error) { logger.error('[VideoPlayer] Error showing AirPlay picker:', error); } }; const handleSelectStream = async (newStream: any) => { if (newStream.url === currentStreamUrl) { setShowSourcesModal(false); return; } setShowSourcesModal(false); // Extract quality and provider information let newQuality = newStream.quality; if (!newQuality && newStream.title) { const qualityMatch = newStream.title.match(/(\d+)p/); newQuality = qualityMatch ? qualityMatch[0] : undefined; } const newProvider = newStream.addonName || newStream.name || newStream.addon || 'Unknown'; const newStreamName = newStream.name || newStream.title || 'Unknown Stream'; // Pause current playback setPaused(true); // Navigate with replace to reload player with new source setTimeout(() => { navigation.replace('PlayerIOS', { uri: newStream.url, title: title, episodeTitle: episodeTitle, season: season, episode: episode, quality: newQuality, year: year, streamProvider: newProvider, streamName: newStreamName, headers: newStream.headers || undefined, id, type, episodeId, imdbId: imdbId ?? undefined, backdrop: backdrop || undefined, availableStreams: availableStreams, }); }, 100); }; const handleEpisodeSelect = (episode: Episode) => { logger.log('[KSPlayerCore] Episode selected:', episode.name); setSelectedEpisodeForStreams(episode); setShowEpisodesModal(false); setShowEpisodeStreamsModal(true); }; // Debug: Log when modal state changes useEffect(() => { if (showEpisodesModal) { logger.log('[KSPlayerCore] Episodes modal opened, groupedEpisodes:', groupedEpisodes); logger.log('[KSPlayerCore] type:', type, 'season:', season, 'episode:', episode); } }, [showEpisodesModal, groupedEpisodes, type]); const handleEpisodeStreamSelect = async (stream: any) => { if (!selectedEpisodeForStreams) return; setShowEpisodeStreamsModal(false); 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'; setPaused(true); setTimeout(() => { navigation.replace('PlayerIOS', { uri: stream.url, title: title, episodeTitle: selectedEpisodeForStreams.name, season: selectedEpisodeForStreams.season_number, episode: selectedEpisodeForStreams.episode_number, quality: newQuality, year: year, streamProvider: newProvider, streamName: newStreamName, headers: stream.headers || undefined, id, type: 'series', episodeId: selectedEpisodeForStreams.stremioId || `${id}:${selectedEpisodeForStreams.season_number}:${selectedEpisodeForStreams.episode_number}`, imdbId: imdbId ?? undefined, backdrop: backdrop || undefined, availableStreams: {}, groupedEpisodes: groupedEpisodes, }); }, 100); }; useEffect(() => { if (isVideoLoaded && initialPosition && !isInitialSeekComplete && duration > 0) { logger.log(`[VideoPlayer] Post-load initial seek to: ${initialPosition}s`); seekToTime(initialPosition); setIsInitialSeekComplete(true); // Verify whether the seek actually took effect (detect non-seekable sources) if (!initialSeekVerifiedRef.current) { initialSeekVerifiedRef.current = true; const target = initialSeekTargetRef.current ?? initialPosition; setTimeout(() => { const delta = Math.abs(currentTime - (target || 0)); if (target && (currentTime < target - 1.5)) { logger.warn(`[VideoPlayer] Initial seek appears ignored (delta=${delta.toFixed(2)}). Treating source as non-seekable; starting from 0`); isSourceSeekableRef.current = false; // Reset resume intent and continue from 0 setInitialPosition(null); setResumePosition(null); setShowResumeOverlay(false); } else { isSourceSeekableRef.current = true; } }, 1200); } } }, [isVideoLoaded, initialPosition, duration]); return ( {!DISABLE_OPENING_OVERLAY && ( )} {/* Combined gesture handler for left side - brightness + tap + long press */} {/* Combined gesture handler for right side - volume + tap + long press */} {/* Center area tap handler - handles both show and hide */} { if (showControls) { // If controls are visible, hide them const timeoutId = setTimeout(() => { hideControls(); }, 0); // Clear any existing timeout if (controlsTimeout.current) { clearTimeout(controlsTimeout.current); } controlsTimeout.current = timeoutId; } else { // If controls are hidden, show them toggleControls(); } }} shouldCancelWhenOutside={false} simultaneousHandlers={[]} > 0 ? headers : undefined }} paused={paused} volume={volume / 100} rate={playbackSpeed} audioTrack={selectedAudioTrack ?? undefined} textTrack={useCustomSubtitles ? -1 : selectedTextTrack} allowsExternalPlayback={allowsAirPlay} usesExternalPlaybackWhileExternalScreenIsActive={true} subtitleBottomOffset={subtitleBottomOffset} subtitleFontSize={subtitleSize} resizeMode={resizeMode === 'none' ? 'contain' : resizeMode} onProgress={handleProgress} onLoad={onLoad} onEnd={onEnd} onError={handleError} onBuffering={onBuffering} /> {showPauseOverlay && ( {/* Strong horizontal fade from left side */} {showCastDetails && selectedCastMember ? ( // Cast Detail View with fade transition { // Animate cast details out, then metadata back in Animated.parallel([ Animated.timing(castDetailsOpacity, { toValue: 0, duration: 250, useNativeDriver: true, }), Animated.timing(castDetailsScale, { toValue: 0.95, duration: 250, useNativeDriver: true, }) ]).start(() => { setShowCastDetails(false); setSelectedCastMember(null); // Animate metadata back in Animated.parallel([ Animated.timing(metadataOpacity, { toValue: 1, duration: 400, useNativeDriver: true, }), Animated.spring(metadataScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true, }) ]).start(); }); }} > Back to details {selectedCastMember.profile_path && ( )} {selectedCastMember.name} {selectedCastMember.character && ( as {selectedCastMember.character} )} {/* Biography if available */} {selectedCastMember.biography && ( {selectedCastMember.biography} )} ) : ( // Default Metadata View You're watching {title} {!!year && ( {`${year}${type === 'series' && season && episode ? ` • S${season}E${episode}` : ''}`} )} {!!episodeTitle && ( {episodeTitle} )} {(currentEpisodeDescription || metadata?.description) && ( {type === 'series' ? (currentEpisodeDescription || metadata?.description || '') : (metadata?.description || '')} )} {cast && cast.length > 0 && ( Cast {cast.slice(0, 6).map((castMember: any, index: number) => ( { setSelectedCastMember(castMember); // Animate metadata out, then cast details in Animated.parallel([ Animated.timing(metadataOpacity, { toValue: 0, duration: 250, useNativeDriver: true, }), Animated.timing(metadataScale, { toValue: 0.95, duration: 250, useNativeDriver: true, }) ]).start(() => { setShowCastDetails(true); // Animate cast details in Animated.parallel([ Animated.timing(castDetailsOpacity, { toValue: 1, duration: 400, useNativeDriver: true, }), Animated.spring(castDetailsScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true, }) ]).start(); }); }} > {castMember.name} ))} )} )} )} {/* Next Episode Button (reusable) */} = 768 ? 126 : 106} /> = 768 ? 126 : 106} /> {/* Volume Overlay */} {gestureControls.showVolumeOverlay && ( {/* Horizontal Dotted Progress Bar */} {/* Dotted background */} {Array.from({ length: 16 }, (_, i) => ( ))} {/* Progress fill */} {Math.round(volume)}% )} {/* Brightness Overlay */} {gestureControls.showBrightnessOverlay && ( {/* Horizontal Dotted Progress Bar */} {/* Dotted background */} {Array.from({ length: 16 }, (_, i) => ( ))} {/* Progress fill */} {Math.round(brightness * 100)}% )} {/* Speed Activated Overlay */} {showSpeedActivatedOverlay && ( {holdToSpeedValue}x Speed Activated )} {/* Resume overlay removed when AlwaysResume is enabled; overlay component omitted */} {type === 'series' && ( <> { setShowEpisodeStreamsModal(false); setShowEpisodesModal(true); }} onSelectStream={handleEpisodeStreamSelect} metadata={metadata ? { id: metadata.id, name: metadata.name } : undefined} /> )} {/* Error Modal */} Playback Error The video player encountered an error and cannot continue playback: {errorDetails} Exit Player This dialog will auto-close in 5 seconds ); }; export default KSPlayerCore;