import React, { useEffect, useRef, useState, useCallback } from 'react'; import { View, StatusBar, StyleSheet, Animated, Dimensions, ActivityIndicator } from 'react-native'; import { useNavigation, useRoute } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import axios from 'axios'; // Shared Components import LoadingOverlay from './modals/LoadingOverlay'; import UpNextButton from './common/UpNextButton'; import { PlayerControls } from './controls/PlayerControls'; import AudioTrackModal from './modals/AudioTrackModal'; import SpeedModal from './modals/SpeedModal'; import { SubmitIntroModal } from './modals/SubmitIntroModal'; import SubtitleModals from './modals/SubtitleModals'; import { SubtitleSyncModal } from './modals/SubtitleSyncModal'; import SourcesModal from './modals/SourcesModal'; import EpisodesModal from './modals/EpisodesModal'; import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal'; import { ErrorModal } from './modals/ErrorModal'; import CustomSubtitles from './subtitles/CustomSubtitles'; import ResumeOverlay from './modals/ResumeOverlay'; import ParentalGuideOverlay from './overlays/ParentalGuideOverlay'; import SkipIntroButton from './overlays/SkipIntroButton'; import { SpeedActivatedOverlay, PauseOverlay, GestureControls } from './components'; // Platform-specific components import { KSPlayerSurface } from './ios/components/KSPlayerSurface'; import { usePlayerState, usePlayerModals, useSpeedControl, useOpeningAnimation, usePlayerTracks, useCustomSubtitles, usePlayerControls, usePlayerSetup, useWatchProgress, useNextEpisode, useSkipSegments } from './hooks'; // Platform-specific hooks import { useKSPlayer } from './ios/hooks/useKSPlayer'; // App-level Hooks import { useTraktAutosync } from '../../hooks/useTraktAutosync'; import { useMetadata } from '../../hooks/useMetadata'; import { usePlayerGestureControls } from '../../hooks/usePlayerGestureControls'; import stremioService from '../../services/stremioService'; import { storageService } from '../../services/storageService'; import { logger } from '../../utils/logger'; // Utils import { formatTime } from './utils/playerUtils'; import { WyzieSubtitle } from './utils/playerTypes'; import { parseSRT } from './utils/subtitleParser'; import { findBestSubtitleTrack, autoSelectAudioTrack, findBestAudioTrack } from './utils/trackSelectionUtils'; import { useSettings } from '../../hooks/useSettings'; import { useTheme } from '../../contexts/ThemeContext'; // Player route params interface interface PlayerRouteParams { uri: string; title: string; episodeTitle?: string; season?: number; episode?: number; quality?: string; year?: number; streamProvider?: string; streamName?: string; videoType?: string; id: string; type: string; episodeId?: string; imdbId?: string; backdrop?: string; availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; headers?: Record; initialPosition?: number; } const KSPlayerCore: React.FC = () => { // Navigation & Route const navigation = useNavigation(); const route = useRoute(); const insets = useSafeAreaInsets(); const params = route.params as PlayerRouteParams; // Deconstruct params const { uri, title, episodeTitle, season, episode, id, type, quality, year, episodeId, imdbId, backdrop, availableStreams, headers, streamProvider, streamName, initialPosition: routeInitialPosition } = params; const videoType = (params as any)?.videoType as string | undefined; useEffect(() => { if (!__DEV__) return; const headerKeys = Object.keys(headers || {}); logger.log('[KSPlayerCore] route params', { uri: typeof uri === 'string' ? uri.slice(0, 240) : uri, id, type, episodeId, imdbId, title, episodeTitle, season, episode, quality, year, streamProvider, streamName, videoType, headersKeys: headerKeys, headersCount: headerKeys.length, }); }, [uri, episodeId]); useEffect(() => { if (!__DEV__) return; const headerKeys = Object.keys(headers || {}); logger.log('[KSPlayerCore] source update', { uri: typeof uri === 'string' ? uri.slice(0, 240) : uri, videoType, headersCount: headerKeys.length, headersKeys: headerKeys, }); }, [uri, headers, videoType]); // --- Hooks --- const playerState = usePlayerState(); const { paused, setPaused, currentTime, setCurrentTime, duration, setDuration, buffered, setBuffered, isBuffering, setIsBuffering, isVideoLoaded, setIsVideoLoaded, isPlayerReady, setIsPlayerReady, showControls, setShowControls, resizeMode, setResizeMode, screenDimensions, setScreenDimensions, zoomScale, setZoomScale, lastZoomScale, setLastZoomScale, isAirPlayActive, allowsAirPlay, isSeeking, isMounted, } = playerState; const modals = usePlayerModals(); const speedControl = useSpeedControl(1.0); // Metadata Hook const { metadata, groupedEpisodes, cast } = useMetadata({ id, type: type as 'movie' | 'series' }); // Trakt Autosync const traktAutosync = useTraktAutosync({ type: type as 'movie' | 'series', imdbId: imdbId || (id?.startsWith('tt') ? id : ''), season, episode, title, id, year: year?.toString() || metadata?.year?.toString() || '' }); const openingAnim = useOpeningAnimation(backdrop, metadata); const tracks = usePlayerTracks(); const { ksPlayerRef, seek } = useKSPlayer(); const customSubs = useCustomSubtitles(); const { settings } = useSettings(); const { currentTheme } = useTheme(); // Subtitle sync modal state const [showSyncModal, setShowSyncModal] = useState(false); // Track auto-selection refs to prevent duplicate selections const hasAutoSelectedTracks = useRef(false); // Track previous video session to reset subtitle offset only when video actually changes const previousVideoRef = useRef<{ uri?: string; episodeId?: string }>({}); // Reset subtitle offset when starting a new video session useEffect(() => { const currentVideo = { uri, episodeId }; const previousVideo = previousVideoRef.current; // Only reset if this is actually a new video (uri or episodeId changed) if (previousVideo.uri !== undefined && (previousVideo.uri !== currentVideo.uri || previousVideo.episodeId !== currentVideo.episodeId)) { customSubs.setSubtitleOffsetSec(0); } // Update the ref for next comparison previousVideoRef.current = currentVideo; // eslint-disable-next-line react-hooks/exhaustive-deps }, [uri, episodeId]); // Next Episode Hook const { nextEpisode, currentEpisodeDescription } = useNextEpisode({ type, season, episode, groupedEpisodes: groupedEpisodes as any, episodeId }); const { segments: skipIntervals, outroSegment } = useSkipSegments({ imdbId: imdbId || (id?.startsWith('tt') ? id : undefined), type, season, episode, malId: (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id, kitsuId: id?.startsWith('kitsu:') ? id.split(':')[1] : undefined, enabled: settings.skipIntroEnabled }); const controls = usePlayerControls({ playerRef: ksPlayerRef, paused, setPaused, currentTime, duration, isSeeking, isMounted, onSeekComplete: (timeInSeconds) => { if (!id || !type || duration <= 0) return; void storageService.setWatchProgress(id, type, { currentTime: timeInSeconds, duration, lastUpdated: Date.now() }, episodeId); } }); const watchProgress = useWatchProgress( id, type, episodeId, currentTime, duration, paused, traktAutosync, controls.seekToTime ); // Gestures const fadeAnim = useRef(new Animated.Value(1)).current; // Controls timeout const controlsTimeout = useRef(null); const hideControls = useCallback(() => { // Allow hiding controls even when paused (per user request) setShowControls(false); Animated.timing(fadeAnim, { toValue: 0, duration: 300, useNativeDriver: true, }).start(); }, [fadeAnim, setShowControls]); // Volume/Brightness State const [volume, setVolumeState] = useState(1.0); const [brightness, setBrightnessState] = useState(0.5); const [isSliderDragging, setIsSliderDragging] = useState(false); // Shared Gesture Hook const gestureControls = usePlayerGestureControls({ volume: volume, setVolume: (v) => setVolumeState(v), brightness: brightness, setBrightness: (b) => setBrightnessState(b), }); // Setup Hook (Listeners, StatusBar, etc) usePlayerSetup({ setScreenDimensions, setVolume: setVolumeState, setBrightness: setBrightnessState, isOpeningAnimationComplete: openingAnim.isOpeningAnimationComplete, paused: paused }); // Refs for Logic const isSyncingBeforeClose = useRef(false); // Toggle controls wrapper const toggleControls = useCallback(() => { if (controlsTimeout.current) { clearTimeout(controlsTimeout.current); controlsTimeout.current = null; } setShowControls(prev => { const next = !prev; Animated.timing(fadeAnim, { toValue: next ? 1 : 0, duration: 300, useNativeDriver: true, }).start(); // Start auto-hide timer if showing controls and not paused if (next && !paused) { controlsTimeout.current = setTimeout(hideControls, 5000); } return next; }); }, [fadeAnim, hideControls, setShowControls, paused]); // Auto-hide controls when playback resumes useEffect(() => { if (showControls && !paused) { // Reset auto-hide timer when playback resumes if (controlsTimeout.current) { clearTimeout(controlsTimeout.current); } controlsTimeout.current = setTimeout(hideControls, 5000); } else if (paused) { // Clear timeout when paused - user controls when to hide if (controlsTimeout.current) { clearTimeout(controlsTimeout.current); controlsTimeout.current = null; } } return () => { if (controlsTimeout.current) { clearTimeout(controlsTimeout.current); } }; }, [paused, showControls, hideControls]); // Subtitle Fetching Logic const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = true) => { const targetImdbId = imdbIdParam || imdbId; if (!targetImdbId) return; customSubs.setIsLoadingSubtitleList(true); try { const stremioType = type === 'series' ? 'series' : 'movie'; const stremioVideoId = stremioType === 'series' && season && episode ? `series:${targetImdbId}:${season}:${episode}` : undefined; const results = await stremioService.getSubtitles(stremioType, targetImdbId, stremioVideoId); const subs: WyzieSubtitle[] = (results || []).map((sub: any) => ({ id: sub.id || `${sub.lang}-${sub.url}`, url: sub.url, flagUrl: '', format: 'srt', encoding: 'utf-8', media: sub.addonName || sub.addon || '', display: sub.lang || 'Unknown', language: (sub.lang || '').toLowerCase(), isHearingImpaired: false, source: sub.addonName || sub.addon || 'Addon', })); customSubs.setAvailableSubtitles(subs); // Auto-selection is now handled by useEffect that waits for internal tracks // This ensures internal tracks are considered before falling back to external } catch (e) { logger.error('[VideoPlayer] Error fetching subtitles', e); } finally { customSubs.setIsLoadingSubtitleList(false); } }; const loadWyzieSubtitle = async (subtitle: WyzieSubtitle) => { modals.setShowSubtitleLanguageModal(false); customSubs.setIsLoadingSubtitles(true); try { let srtContent = ''; try { const resp = await axios.get(subtitle.url, { timeout: 10000 }); srtContent = typeof resp.data === 'string' ? resp.data : String(resp.data); } catch { const resp = await fetch(subtitle.url); srtContent = await resp.text(); } const parsedCues = parseSRT(srtContent); customSubs.setCustomSubtitles(parsedCues); customSubs.setUseCustomSubtitles(true); customSubs.setSelectedExternalSubtitleId(subtitle.id); // Track the selected external subtitle tracks.selectTextTrack(-1); const adjustedTime = currentTime + (customSubs.subtitleOffsetSec || 0); const cueNow = parsedCues.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end); customSubs.setCurrentSubtitle(cueNow ? cueNow.text : ''); } catch (e) { logger.error('[VideoPlayer] Error loading wyzie', e); } finally { customSubs.setIsLoadingSubtitles(false); } }; // Auto-fetch subtitles on load useEffect(() => { if (imdbId) { fetchAvailableSubtitles(undefined, true); } }, [imdbId]); // Auto-select subtitles when both internal tracks and video are loaded // This ensures we wait for internal tracks before falling back to external useEffect(() => { if (!isVideoLoaded || hasAutoSelectedTracks.current || !settings?.enableSubtitleAutoSelect) { return; } const internalTracks = tracks.ksTextTracks; const externalSubs = customSubs.availableSubtitles; // Wait a short delay to ensure tracks are fully populated const timeoutId = setTimeout(() => { if (hasAutoSelectedTracks.current) return; const subtitleSelection = findBestSubtitleTrack( internalTracks, externalSubs, { preferredSubtitleLanguage: settings?.preferredSubtitleLanguage || 'en', subtitleSourcePreference: settings?.subtitleSourcePreference || 'internal', enableSubtitleAutoSelect: true } ); // Trust the findBestSubtitleTrack function's decision - it already implements priority logic if (subtitleSelection.type === 'internal' && subtitleSelection.internalTrackId !== undefined) { logger.debug(`[KSPlayerCore] Auto-selecting internal subtitle track ${subtitleSelection.internalTrackId}`); tracks.selectTextTrack(subtitleSelection.internalTrackId); hasAutoSelectedTracks.current = true; } else if (subtitleSelection.type === 'external' && subtitleSelection.externalSubtitle) { logger.debug(`[KSPlayerCore] Auto-selecting external subtitle: ${subtitleSelection.externalSubtitle.display}`); loadWyzieSubtitle(subtitleSelection.externalSubtitle); hasAutoSelectedTracks.current = true; } }, 500); // Short delay to ensure tracks are populated return () => clearTimeout(timeoutId); }, [isVideoLoaded, tracks.ksTextTracks, customSubs.availableSubtitles, settings]); // Sync custom subtitle text with current playback time useEffect(() => { if (!customSubs.useCustomSubtitles || customSubs.customSubtitles.length === 0) return; const adjustedTime = currentTime + (customSubs.subtitleOffsetSec || 0); const cueNow = customSubs.customSubtitles.find( cue => adjustedTime >= cue.start && adjustedTime <= cue.end ); const newText = cueNow ? cueNow.text : ''; // Only update state if the text has changed to avoid unnecessary re-renders if (newText !== customSubs.currentSubtitle) { customSubs.setCurrentSubtitle(newText); } }, [currentTime, customSubs.useCustomSubtitles, customSubs.customSubtitles, customSubs.subtitleOffsetSec, customSubs.currentSubtitle]); // Handlers const onLoad = (data: any) => { if (__DEV__) { logger.log('[KSPlayerCore] onLoad', { uri: typeof uri === 'string' ? uri.slice(0, 240) : uri, duration: data?.duration, audioTracksCount: Array.isArray(data?.audioTracks) ? data.audioTracks.length : 0, textTracksCount: Array.isArray(data?.textTracks) ? data.textTracks.length : 0, videoType, headersKeys: Object.keys(headers || {}), }); } setDuration(data.duration); if (data.audioTracks) tracks.setKsAudioTracks(data.audioTracks); if (data.textTracks) tracks.setKsTextTracks(data.textTracks); setIsVideoLoaded(true); setIsPlayerReady(true); openingAnim.completeOpeningAnimation(); // Auto-select audio track based on preferences if (data.audioTracks && data.audioTracks.length > 0 && settings?.preferredAudioLanguage) { const bestAudioTrack = findBestAudioTrack(data.audioTracks, settings.preferredAudioLanguage); if (bestAudioTrack !== null) { logger.debug(`[KSPlayerCore] Auto-selecting audio track ${bestAudioTrack} for language: ${settings.preferredAudioLanguage}`); tracks.selectAudioTrack(bestAudioTrack); if (ksPlayerRef.current) { ksPlayerRef.current.setAudioTrack(bestAudioTrack); } } } // Auto-select subtitle track based on preferences // Only auto-select internal tracks here if preference is 'internal' or 'any' // If preference is 'external', we wait for the useEffect to handle selection after external subs load if (data.textTracks && data.textTracks.length > 0 && !hasAutoSelectedTracks.current && settings?.enableSubtitleAutoSelect) { const sourcePreference = settings?.subtitleSourcePreference || 'internal'; // Only pre-select internal if preference is internal or any if (sourcePreference === 'internal' || sourcePreference === 'any') { const subtitleSelection = findBestSubtitleTrack( data.textTracks, [], // External subtitles not yet loaded { preferredSubtitleLanguage: settings?.preferredSubtitleLanguage || 'en', subtitleSourcePreference: sourcePreference, enableSubtitleAutoSelect: true } ); if (subtitleSelection.type === 'internal' && subtitleSelection.internalTrackId !== undefined) { logger.debug(`[KSPlayerCore] Auto-selecting internal subtitle track ${subtitleSelection.internalTrackId} on load`); tracks.selectTextTrack(subtitleSelection.internalTrackId); hasAutoSelectedTracks.current = true; } } // If preference is 'external', don't select anything here - useEffect will handle it } // Initial Seek const resumeTarget = routeInitialPosition || watchProgress.initialPosition || watchProgress.initialSeekTargetRef?.current; if (resumeTarget && resumeTarget > 0 && !watchProgress.showResumeOverlay && data.duration > 0) { setTimeout(() => { if (ksPlayerRef.current) { logger.debug(`[KSPlayerCore] Auto-resuming to ${resumeTarget}`); ksPlayerRef.current.seek(resumeTarget); } }, 500); } // Start trakt session if (data.duration > 0) { traktAutosync.handlePlaybackStart(currentTime, data.duration); } }; const handleError = (error: any) => { let msg = 'Unknown Error'; try { if (typeof error === 'string') { msg = error; } else if (error?.error?.localizedDescription) { msg = error.error.localizedDescription; } else if (error?.error?.message) { msg = error.error.message; } else if (error?.message) { msg = error.message; } else if (error?.error) { msg = typeof error.error === 'string' ? error.error : JSON.stringify(error.error); } else { msg = JSON.stringify(error); } } catch (e) { msg = 'Error parsing error details'; } if (__DEV__) { logger.error('[KSPlayerCore] onError', { msg, uri: typeof uri === 'string' ? uri.slice(0, 240) : uri, videoType, streamProvider, streamName, headersKeys: Object.keys(headers || {}), rawError: error, }); } modals.setErrorDetails(msg); modals.setShowErrorModal(true); }; const handleClose = useCallback(() => { if (isSyncingBeforeClose.current) return; isSyncingBeforeClose.current = true; // Fire and forget - don't block navigation on async operations // The useWatchProgress and useTraktAutosync hooks handle cleanup on unmount traktAutosync.handleProgressUpdate(currentTime, duration, true); traktAutosync.handlePlaybackEnd(currentTime, duration, 'user_close'); navigation.goBack(); }, [navigation, currentTime, duration, traktAutosync]); // Track selection handlers - update state, prop change triggers native update const handleSelectTextTrack = useCallback((trackId: number) => { console.log('[KSPlayerCore] handleSelectTextTrack called with trackId:', trackId); // Disable custom subtitles when selecting a built-in track // This ensures the textTrack prop is actually passed to the native player if (trackId !== -1) { customSubs.setUseCustomSubtitles(false); } // Just update state - the textTrack prop change will trigger native update tracks.selectTextTrack(trackId); }, [tracks, customSubs]); const handleSelectAudioTrack = useCallback((trackId: number) => { tracks.selectAudioTrack(trackId); if (ksPlayerRef.current) { ksPlayerRef.current.setAudioTrack(trackId); } }, [tracks, ksPlayerRef]); // Stream selection handler const handleSelectStream = async (newStream: any) => { if (newStream.url === uri) { modals.setShowSourcesModal(false); return; } if (__DEV__) { logger.log('[KSPlayerCore] switching stream', { fromUri: typeof uri === 'string' ? uri.slice(0, 240) : uri, toUri: typeof newStream?.url === 'string' ? newStream.url.slice(0, 240) : newStream?.url, newStreamHeadersKeys: Object.keys(newStream?.headers || {}), newProvider: newStream?.addonName || newStream?.name || newStream?.addon || 'Unknown', newName: newStream?.name || newStream?.title || 'Unknown', }); } modals.setShowSourcesModal(false); setPaused(true); const newQuality = newStream.quality || newStream.title?.match(/(\d+)p/)?.[0]; const newProvider = newStream.addonName || newStream.name || newStream.addon || 'Unknown'; const newStreamName = newStream.name || newStream.title || 'Unknown'; setTimeout(() => { (navigation as any).replace('PlayerIOS', { ...params, uri: newStream.url, quality: newQuality, streamProvider: newProvider, streamName: newStreamName, headers: newStream.headers, availableStreams: availableStreams }); }, 100); }; // Episode selection handler - opens streams modal const handleSelectEpisode = (ep: any) => { modals.setSelectedEpisodeForStreams(ep); modals.setShowEpisodesModal(false); modals.setShowEpisodeStreamsModal(true); }; // Episode stream selection handler - navigates to new episode with selected stream const handleEpisodeStreamSelect = async (stream: any) => { if (!modals.selectedEpisodeForStreams) return; modals.setShowEpisodeStreamsModal(false); setPaused(true); const ep = modals.selectedEpisodeForStreams; if (__DEV__) { logger.log('[KSPlayerCore] switching episode stream', { toUri: typeof stream?.url === 'string' ? stream.url.slice(0, 240) : stream?.url, streamHeadersKeys: Object.keys(stream?.headers || {}), ep: { season: ep?.season_number, episode: ep?.episode_number, name: ep?.name, stremioId: ep?.stremioId, }, }); } const newQuality = stream.quality || (stream.title?.match(/(\d+)p/)?.[0]); const newProvider = stream.addonName || stream.name || stream.addon || 'Unknown'; const newStreamName = stream.name || stream.title || 'Unknown Stream'; setTimeout(() => { (navigation as any).replace('PlayerIOS', { uri: stream.url, title: title, episodeTitle: ep.name, season: ep.season_number, episode: ep.episode_number, quality: newQuality, year: year, streamProvider: newProvider, streamName: newStreamName, headers: stream.headers || undefined, id, type: 'series', episodeId: ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number} `, imdbId: imdbId ?? undefined, backdrop: backdrop || undefined, }); }, 100); }; // Slider handlers const onSliderValueChange = (value: number) => { setCurrentTime(value); }; const onSlidingStart = () => { setIsSliderDragging(true); }; const onSlidingComplete = (value: number) => { setIsSliderDragging(false); controls.seekToTime(value); }; const handleProgress = useCallback((d: any) => { if (!isSliderDragging) { setCurrentTime(d.currentTime); } // Only update buffered if it changed by more than 0.5s to reduce re-renders const newBuffered = d.buffered || 0; setBuffered(prevBuffered => { if (Math.abs(newBuffered - prevBuffered) > 0.5) { return newBuffered; } return prevBuffered; }); }, [isSliderDragging, setCurrentTime, setBuffered]); return ( ); }; export default KSPlayerCore;