diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 627265e..36b6c3d 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -4,26 +4,29 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { RootStackParamList } from '../../navigation/AppNavigator'; -// Hooks -import { usePlayerState } from './android/hooks/usePlayerState'; +// Shared Hooks (cross-platform) +import { + usePlayerState, + usePlayerModals, + useSpeedControl, + useOpeningAnimation +} from './hooks'; + +// Android-specific hooks (VLC integration, dual player support) import { usePlayerSetup } from './android/hooks/usePlayerSetup'; import { useVlcPlayer } from './android/hooks/useVlcPlayer'; import { usePlayerTracks } from './android/hooks/usePlayerTracks'; import { useWatchProgress } from './android/hooks/useWatchProgress'; import { usePlayerControls } from './android/hooks/usePlayerControls'; -import { useSpeedControl } from './android/hooks/useSpeedControl'; import { useNextEpisode } from './android/hooks/useNextEpisode'; -import { useOpeningAnimation } from './android/hooks/useOpeningAnimation'; -import { usePlayerModals } from './android/hooks/usePlayerModals'; + +// App-level Hooks import { useTraktAutosync } from '../../hooks/useTraktAutosync'; import { useMetadata } from '../../hooks/useMetadata'; import { usePlayerGestureControls } from '../../hooks/usePlayerGestureControls'; -// Components -import { VideoSurface } from './android/components/VideoSurface'; -import { GestureControls } from './android/components/GestureControls'; -import { PauseOverlay } from './android/components/PauseOverlay'; -import { SpeedActivatedOverlay } from './android/components/SpeedActivatedOverlay'; +// Shared Components +import { GestureControls, PauseOverlay, SpeedActivatedOverlay } from './components'; import LoadingOverlay from './modals/LoadingOverlay'; import PlayerControls from './controls/PlayerControls'; import { AudioTrackModal } from './modals/AudioTrackModal'; @@ -33,12 +36,14 @@ import { SourcesModal } from './modals/SourcesModal'; import { EpisodesModal } from './modals/EpisodesModal'; import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal'; +// Android-specific components +import { VideoSurface } from './android/components/VideoSurface'; + // Utils import { logger } from '../../utils/logger'; import { styles } from './utils/playerStyles'; import { formatTime, isHlsStream, processUrlForVLC, getHlsHeaders, defaultAndroidHeaders } from './utils/playerUtils'; import { storageService } from '../../services/storageService'; -// SelectedTrackType removed - using string literals instead const DEBUG_MODE = false; @@ -543,6 +548,7 @@ const AndroidVideoPlayer: React.FC = () => { onClose={() => modals.setShowEpisodeStreamsModal(false)} episode={modals.selectedEpisodeForStreams} onSelectStream={handleEpisodeStreamSelect} + metadata={{ id: id, name: title }} /> diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 9bcc9f3..399589b 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -1,1825 +1,238 @@ -import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; -import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState, InteractionManager } from 'react-native'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { View, StatusBar, StyleSheet, Animated, Dimensions } from 'react-native'; +import { useNavigation, useRoute } from '@react-navigation/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'; + +// 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 SubtitleModals from './modals/SubtitleModals'; +import SourcesModal from './modals/SourcesModal'; +import EpisodesModal from './modals/EpisodesModal'; +import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal'; +import CustomSubtitles from './subtitles/CustomSubtitles'; +import { SpeedActivatedOverlay, PauseOverlay, GestureControls } from './components'; + +// Platform-specific components +import { KSPlayerSurface } from './ios/components/KSPlayerSurface'; + +// Shared Hooks +import { + usePlayerState, + usePlayerModals, + useSpeedControl, + useOpeningAnimation, + usePlayerTracks, + useCustomSubtitles, + usePlayerControls, + usePlayerSetup +} 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 { logger } from '../../utils/logger'; + +// Utils +import { formatTime } from './utils/playerUtils'; +import { WyzieSubtitle } from './utils/playerTypes'; +import { parseSRT } from './utils/subtitleParser'; + +// Player route params interface +interface PlayerRouteParams { + uri: string; + title: string; + episodeTitle?: string; + season?: number; + episode?: number; + quality?: string; + year?: number; + streamProvider?: string; + streamName?: 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 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 params = route.params as PlayerRouteParams; + // Deconstruct params const { - title = 'Episode Name', + uri, title, episodeTitle, season, episode, id, type, quality, year, + episodeId, imdbId, backdrop, availableStreams, + headers, streamProvider, streamName, + initialPosition: routeInitialPosition + } = params; + + // --- 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, - episodeTitle, - quality, - year, - streamName, + title, 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 + year: year?.toString() || metadata?.year?.toString() || '' }); - // App settings - const { settings: appSettings } = useSettings(); + const openingAnim = useOpeningAnimation(backdrop, metadata); + const tracks = usePlayerTracks(); + const { ksPlayerRef, seek } = useKSPlayer(); + const customSubs = useCustomSubtitles(); - safeDebugLog("Component mounted with props", { - uri, title, season, episode, episodeTitle, quality, year, - streamProvider, id, type, episodeId, imdbId + const controls = usePlayerControls({ + playerRef: ksPlayerRef, + paused, + setPaused, + currentTime, + duration, + isSeeking, + isMounted }); - 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); + // Gestures 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); + // Controls timeout 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(() => { - // Defer prefetching until after navigation animation completes - const task = InteractionManager.runAfterInteractions(() => { - 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); - } - }); - return () => task.cancel(); - }, [backdrop]); - - useEffect(() => { - // Defer logo prefetch until after navigation animation - const task = InteractionManager.runAfterInteractions(() => { - 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 - } - } - }); - return () => task.cancel(); - }, [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; - } + const hideControls = useCallback(() => { + // Allow hiding controls even when paused (per user request) + setShowControls(false); Animated.timing(fadeAnim, { toValue: 0, duration: 300, useNativeDriver: true, - }).start(() => setShowControls(false)); - }; + }).start(); + }, [fadeAnim, setShowControls]); - const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => { - return { - position: 'absolute', - top: 0, - left: 0, - width: screenWidth, - height: screenHeight, - }; - }; + // Volume/Brightness State + const [volume, setVolumeState] = useState(1.0); + const [brightness, setBrightnessState] = useState(0.5); + const [isSliderDragging, setIsSliderDragging] = useState(false); - 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`); - } - }; + // Watch Progress State + const [initialPosition, setInitialPosition] = useState(routeInitialPosition || null); - 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`); - } - } - }; + // Shared Gesture Hook + const gestureControls = usePlayerGestureControls({ + volume: volume, + setVolume: (v) => setVolumeState(v), + brightness: brightness, + setBrightness: (b) => setBrightnessState(b), + }); - 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})`); - } - }; + // Setup Hook (Listeners, StatusBar, etc) + usePlayerSetup({ + setScreenDimensions, + setVolume: setVolumeState, + setBrightness: setBrightnessState, + isOpeningAnimationComplete: openingAnim.isOpeningAnimationComplete + }); - // Long press gesture handlers for speed boost - const onLongPressActivated = useCallback(() => { - if (!holdToSpeedEnabled) return; + // Refs for Logic + const isSyncingBeforeClose = useRef(false); - 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(() => { - // Defer orientation lock until after navigation animation to prevent sluggishness - if (isOpeningAnimationComplete) { - const task = InteractionManager.runAfterInteractions(() => { - ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE) - .then(() => { - if (__DEV__) logger.log('[VideoPlayer] Locked to landscape orientation'); - }) - .catch((error) => { - logger.warn('[VideoPlayer] Failed to lock orientation:', error); - }); - }); - return () => task.cancel(); - } - 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(); - } - }); - - // Immediate player setup - UI critical - StatusBar.setHidden(true, 'none'); - // Enable immersive mode after opening animation to prevent glitches - if (isOpeningAnimationComplete) { - enableImmersiveMode(); - } - startOpeningAnimation(); - - // Initialize volume immediately (no async) - setVolume(100); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Initial volume: 100 (KSPlayer native)`); - } - - // Defer brightness initialization until after navigation animation completes - // This prevents sluggish player entry - const brightnessTask = InteractionManager.runAfterInteractions(async () => { - 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); - } - }); - - return () => { - subscription?.remove(); - brightnessTask.cancel(); - 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]); - - // Use refs to track latest values for unmount cleanup without causing effect re-runs - const currentTimeRef = useRef(currentTime); - const durationRef = useRef(duration); - - // Keep refs updated with latest values - useEffect(() => { - currentTimeRef.current = currentTime; - }, [currentTime]); - - useEffect(() => { - durationRef.current = duration; - }, [duration]); - - // Cleanup effect - only runs on actual component unmount - useEffect(() => { - return () => { - if (id && type && durationRef.current > 0) { - saveWatchProgress(); - // Final Trakt sync on component unmount - traktAutosync.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'unmount'); - } - }; - }, [id, type]); // Only id and type - NOT currentTime or 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); - // Reset the wasPlayingBeforeDrag ref so that seeking while paused doesn't resume playback - wasPlayingBeforeDragRef.current = false; - - // 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); + // Toggle controls wrapper + const toggleControls = useCallback(() => { 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); - // Only resume playback if the video was playing before the drag AND is not currently paused - // This ensures that if the user paused during or before the drag, it stays paused - if (wasPlayingBeforeDragRef.current && !paused) { - setTimeout(() => { - if (isMounted.current) { - setPaused(false); - } - }, 350); + 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); } - 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); - }; + return next; + }); + }, [fadeAnim, hideControls, setShowControls, paused]); - // Ensure auto-hide resumes after drag ends + // Auto-hide controls when playback resumes useEffect(() => { - if (!isDragging && showControls) { + if (showControls && !paused) { + // Reset auto-hide timer when playback resumes 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'}`); + } else if (paused) { + // Clear timeout when paused - user controls when to hide + if (controlsTimeout.current) { + clearTimeout(controlsTimeout.current); + controlsTimeout.current = null; } } - - // Safety: if audio is advancing but onLoad didn't fire, dismiss opening overlay - if (!isOpeningAnimationComplete) { - setIsVideoLoaded(true); - setIsPlayerReady(true); - completeOpeningAnimation(); - } - - // 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 = () => { - // Fire orientation changes in background - don't await them - ScreenOrientation.unlockAsync() - .then(() => { - logger.log('[VideoPlayer] Orientation unlocked'); - // 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); - } - }) - .catch((orientationError: any) => { - logger.warn('[VideoPlayer] Failed to unlock orientation:', orientationError); - }); - - // Disable immersive mode (synchronous) - disableImmersiveMode(); - - // Navigate back IMMEDIATELY - don't wait for orientation - 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 }); + return () => { + if (controlsTimeout.current) { + clearTimeout(controlsTimeout.current); } }; + }, [paused, showControls, hideControls]); - // 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 (uses scrobble/stop which handles pause/scrobble) - 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); - } - }; - + // Subtitle Fetching Logic 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); + if (!targetImdbId) return; + + customSubs.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 => ({ + 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: '', @@ -1831,25 +244,12 @@ const KSPlayerCore: React.FC = () => { 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); + + customSubs.setAvailableSubtitles(subs); + if (autoSelectEnglish) { - const englishSubtitle = stremioSubs.find(sub => - sub.language.toLowerCase() === 'eng' || - sub.language.toLowerCase() === 'en' || - sub.display.toLowerCase().includes('english') + const englishSubtitle = subs.find(sub => + sub.language.includes('en') || sub.display.toLowerCase().includes('english') ); if (englishSubtitle) { loadWyzieSubtitle(englishSubtitle); @@ -1857,584 +257,139 @@ const KSPlayerCore: React.FC = () => { } } if (!autoSelectEnglish) { - // If no English found and not auto-selecting, still open the modal - setShowSubtitleLanguageModal(true); + modals.setShowSubtitleLanguageModal(true); } - } catch (error) { - logger.error('[VideoPlayer] Error fetching subtitles from OpenSubtitles addon:', error); + } catch (e) { + logger.error('[VideoPlayer] Error fetching subtitles', e); } finally { - setIsLoadingSubtitleList(false); + customSubs.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); + modals.setShowSubtitleLanguageModal(false); + customSubs.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); - } + 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(); } - logger.log(`[VideoPlayer] Fetching subtitle SRT done, size=${srtContent.length}`); const parsedCues = parseSRT(srtContent); - logger.log(`[VideoPlayer] Parsed cues count=${parsedCues.length}`); + customSubs.setCustomSubtitles(parsedCues); + customSubs.setUseCustomSubtitles(true); + tracks.selectTextTrack(-1); - // For KSPlayer on iOS: stop spinner early, then clear-apply and micro-seek nudge - setIsLoadingSubtitles(false); - logger.log('[VideoPlayer] isLoadingSubtitles -> false (early)'); + const adjustedTime = currentTime + (customSubs.subtitleOffsetSec || 0); + const cueNow = parsedCues.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end); + customSubs.setCurrentSubtitle(cueNow ? cueNow.text : ''); - // 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); + } catch (e) { + logger.error('[VideoPlayer] Error loading wyzie', e); + } finally { + customSubs.setIsLoadingSubtitles(false); } }; - const togglePlayback = () => { - setPaused(!paused); - }; + // Auto-fetch subtitles on load + useEffect(() => { + if (imdbId) { + fetchAvailableSubtitles(undefined, true); + } + }, [imdbId]); - // Handle next episode button press - const handlePlayNextEpisode = useCallback(async () => { - if (!nextEpisode || !id || isLoadingNextEpisode) return; + // Handlers + const onLoad = (data: any) => { + setDuration(data.duration); + if (data.audioTracks) tracks.setKsAudioTracks(data.audioTracks); + if (data.textTracks) tracks.setKsTextTracks(data.textTracks); - setIsLoadingNextEpisode(true); + setIsVideoLoaded(true); + setIsPlayerReady(true); + openingAnim.completeOpeningAnimation(); - 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 + // Initial Seek + if (initialPosition && initialPosition > 0) { 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); + controls.seekToTime(initialPosition); + }, 500); } - }, [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(); + // Start trakt session + if (data.duration > 0) { + traktAutosync.handlePlaybackStart(currentTime, data.duration); } }; - 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) { - const segmentsPerLine: SubtitleSegment[][] = []; - let currentLine: SubtitleSegment[] = []; - - currentCue.formattedSegments.forEach(seg => { - const parts = seg.text.split(/\r?\n/); - parts.forEach((part, index) => { - if (index > 0) { - // New line found - segmentsPerLine.push(currentLine); - currentLine = []; - } - if (part.length > 0) { - currentLine.push({ ...seg, text: part }); - } - }); - }); - - if (currentLine.length > 0) { - segmentsPerLine.push(currentLine); - } - - setCurrentFormattedSegments(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 handleError = (error: any) => { + modals.setErrorDetails(typeof error === 'string' ? error : error?.message || 'Unknown Error'); + modals.setShowErrorModal(true); }; - 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 handleClose = async () => { + if (isSyncingBeforeClose.current) return; + isSyncingBeforeClose.current = true; + + await traktAutosync.handleProgressUpdate(currentTime, duration, true); + await traktAutosync.handlePlaybackEnd(currentTime, duration, 'user_close'); + + navigation.goBack(); }; + // Stream selection handler const handleSelectStream = async (newStream: any) => { - if (newStream.url === currentStreamUrl) { - setShowSourcesModal(false); + if (newStream.url === uri) { + modals.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 + modals.setShowSourcesModal(false); setPaused(true); - // Navigate with replace to reload player with new source + 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.replace('PlayerIOS', { + (navigation as any).replace('PlayerIOS', { + ...params, 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, + headers: newStream.headers, + availableStreams: availableStreams }); }, 100); }; - const handleEpisodeSelect = (episode: Episode) => { - logger.log('[KSPlayerCore] Episode selected:', episode.name); - setSelectedEpisodeForStreams(episode); - setShowEpisodesModal(false); - setShowEpisodeStreamsModal(true); + // Episode selection handler - opens streams modal + const handleSelectEpisode = (ep: any) => { + modals.setSelectedEpisodeForStreams(ep); + modals.setShowEpisodesModal(false); + modals.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]); - + // Episode stream selection handler - navigates to new episode with selected stream const handleEpisodeStreamSelect = async (stream: any) => { - if (!selectedEpisodeForStreams) return; - - setShowEpisodeStreamsModal(false); + if (!modals.selectedEpisodeForStreams) return; + modals.setShowEpisodeStreamsModal(false); + setPaused(true); + const ep = modals.selectedEpisodeForStreams; const newQuality = stream.quality || (stream.title?.match(/(\d+)p/)?.[0]); const newProvider = stream.addonName || stream.name || stream.addon || 'Unknown'; const newStreamName = stream.name || stream.title || 'Unknown Stream'; - setPaused(true); - setTimeout(() => { - navigation.replace('PlayerIOS', { + (navigation as any).replace('PlayerIOS', { uri: stream.url, title: title, - episodeTitle: selectedEpisodeForStreams.name, - season: selectedEpisodeForStreams.season_number, - episode: selectedEpisodeForStreams.episode_number, + episodeTitle: ep.name, + season: ep.season_number, + episode: ep.episode_number, quality: newQuality, year: year, streamProvider: newProvider, @@ -2442,234 +397,122 @@ const KSPlayerCore: React.FC = () => { headers: stream.headers || undefined, id, type: 'series', - episodeId: selectedEpisodeForStreams.stremioId || `${id}:${selectedEpisodeForStreams.season_number}:${selectedEpisodeForStreams.episode_number}`, + episodeId: ep.stremioId || `${id}:${ep.season_number}:${ep.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]); + // Slider handlers + const onSliderValueChange = (value: number) => { + setCurrentTime(value); + }; + + const onSlidingStart = () => { + setIsSliderDragging(true); + }; + + const onSlidingComplete = (value: number) => { + setIsSliderDragging(false); + controls.seekToTime(value); + }; return ( - - {!DISABLE_OPENING_OVERLAY && ( - - )} + + ); }; diff --git a/src/components/player/android/components/VideoSurface.tsx b/src/components/player/android/components/VideoSurface.tsx index cf978bf..41273e4 100644 --- a/src/components/player/android/components/VideoSurface.tsx +++ b/src/components/player/android/components/VideoSurface.tsx @@ -11,11 +11,23 @@ const getVideoResizeMode = (resizeMode: ResizeModeType) => { switch (resizeMode) { case 'contain': return 'contain'; case 'cover': return 'cover'; + case 'stretch': return 'contain'; case 'none': return 'contain'; default: return 'contain'; } }; +// VLC only supports 'contain' | 'cover' | 'none' +const getVlcResizeMode = (resizeMode: ResizeModeType): 'contain' | 'cover' | 'none' => { + switch (resizeMode) { + case 'contain': return 'contain'; + case 'cover': return 'cover'; + case 'stretch': return 'cover'; // stretch is not supported, use cover + case 'none': return 'none'; + default: return 'contain'; + } +}; + interface VideoSurfaceProps { useVLC: boolean; forceVlcRemount: boolean; @@ -137,7 +149,7 @@ export const VideoSurface: React.FC = ({ volume={volume} playbackSpeed={playbackSpeed} zoomScale={zoomScale} - resizeMode={resizeMode} + resizeMode={getVlcResizeMode(resizeMode)} onLoad={(data) => { vlcLoadedRef.current = true; onLoad(data); diff --git a/src/components/player/components/GestureControls.tsx b/src/components/player/components/GestureControls.tsx new file mode 100644 index 0000000..a9b46ec --- /dev/null +++ b/src/components/player/components/GestureControls.tsx @@ -0,0 +1,199 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { + TapGestureHandler, + PanGestureHandler, + LongPressGestureHandler, + State +} from 'react-native-gesture-handler'; +import { MaterialIcons } from '@expo/vector-icons'; +import { styles as localStyles } from '../utils/playerStyles'; + +interface GestureControlsProps { + screenDimensions: { width: number, height: number }; + gestureControls: any; + onLongPressActivated: () => void; + onLongPressEnd: () => void; + onLongPressStateChange: (event: any) => void; + toggleControls: () => void; + showControls: boolean; + hideControls: () => void; + volume: number; + brightness: number; + controlsTimeout: React.MutableRefObject; +} + +export const GestureControls: React.FC = ({ + screenDimensions, + gestureControls, + onLongPressActivated, + onLongPressEnd, + onLongPressStateChange, + toggleControls, + showControls, + hideControls, + volume, + brightness, + controlsTimeout +}) => { + + const getVolumeIcon = (value: number) => { + if (value === 0) return 'volume-off'; + if (value < 0.3) return 'volume-mute'; + if (value < 0.6) return 'volume-down'; + return 'volume-up'; + }; + + const getBrightnessIcon = (value: number) => { + if (value < 0.3) return 'brightness-low'; + if (value < 0.7) return 'brightness-medium'; + return 'brightness-high'; + }; + + return ( + <> + {/* Left side gesture handler - brightness + tap + long press */} + + + + + + + + + {/* Right side gesture handler - volume + tap + long press */} + + + + + + + + + {/* Center area tap handler */} + { + if (showControls) { + const timeoutId = setTimeout(() => { + hideControls(); + }, 0); + if (controlsTimeout.current) { + clearTimeout(controlsTimeout.current); + } + controlsTimeout.current = timeoutId; + } else { + toggleControls(); + } + }} + shouldCancelWhenOutside={false} + simultaneousHandlers={[]} + > + + + + {/* Volume/Brightness Pill Overlay - Compact top design */} + {(gestureControls.showVolumeOverlay || gestureControls.showBrightnessOverlay) && ( + + + + + + + + {gestureControls.showVolumeOverlay && volume === 0 + ? "Muted" + : `${Math.round((gestureControls.showVolumeOverlay ? volume : brightness) * 100)}%` + } + + + + )} + + ); +}; diff --git a/src/components/player/components/PauseOverlay.tsx b/src/components/player/components/PauseOverlay.tsx new file mode 100644 index 0000000..31cd49a --- /dev/null +++ b/src/components/player/components/PauseOverlay.tsx @@ -0,0 +1,259 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { View, Text, TouchableOpacity, ScrollView, Animated, StyleSheet } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import FastImage from '@d11/react-native-fast-image'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +// Delay before showing pause overlay (in milliseconds) +const PAUSE_OVERLAY_DELAY = 5000; + +interface PauseOverlayProps { + visible: boolean; + onClose: () => void; + title: string; + episodeTitle?: string; + season?: number; + episode?: number; + year?: string | number; + type: string; + description: string; + cast: any[]; + screenDimensions: { width: number, height: number }; +} + +export const PauseOverlay: React.FC = ({ + visible, + onClose, + title, + episodeTitle, + season, + episode, + year, + type, + description, + cast, + screenDimensions +}) => { + const insets = useSafeAreaInsets(); + + // Internal state to track if overlay should actually be shown (after delay) + const [shouldShow, setShouldShow] = useState(false); + const delayTimerRef = useRef(null); + + // Handle delay logic - show overlay only after paused for 5 seconds + useEffect(() => { + if (visible) { + // Start timer to show overlay after delay + delayTimerRef.current = setTimeout(() => { + setShouldShow(true); + }, PAUSE_OVERLAY_DELAY); + } else { + // Immediately hide when not paused + if (delayTimerRef.current) { + clearTimeout(delayTimerRef.current); + delayTimerRef.current = null; + } + setShouldShow(false); + } + + return () => { + if (delayTimerRef.current) { + clearTimeout(delayTimerRef.current); + delayTimerRef.current = null; + } + }; + }, [visible]); + + // Internal Animation State + const pauseOverlayOpacity = useRef(new Animated.Value(shouldShow ? 1 : 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; + + // Cast Details 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; + + useEffect(() => { + Animated.timing(pauseOverlayOpacity, { + toValue: shouldShow ? 1 : 0, + duration: 250, + useNativeDriver: true + }).start(); + }, [shouldShow]); + + if (!shouldShow && !showCastDetails) return null; + + return ( + + + {/* Horizontal Fade */} + + + + + + + {showCastDetails && selectedCastMember ? ( + + + { + 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); + 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} + + )} + {selectedCastMember.biography && ( + + {selectedCastMember.biography} + + )} + + + + + ) : ( + + + You're watching + + {title} + + {!!year && ( + + {`${year}${type === 'series' && season && episode ? ` • S${season}E${episode}` : ''}`} + + )} + {!!episodeTitle && ( + + {episodeTitle} + + )} + {description && ( + + {description} + + )} + {cast && cast.length > 0 && ( + + Cast + + {cast.slice(0, 6).map((castMember: any, index: number) => ( + { + setSelectedCastMember(castMember); + Animated.parallel([ + Animated.timing(metadataOpacity, { toValue: 0, duration: 250, useNativeDriver: true }), + Animated.timing(metadataScale, { toValue: 0.95, duration: 250, useNativeDriver: true }) + ]).start(() => { + setShowCastDetails(true); + 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} + + + ))} + + + )} + + + )} + + + + ); +}; diff --git a/src/components/player/components/SpeedActivatedOverlay.tsx b/src/components/player/components/SpeedActivatedOverlay.tsx new file mode 100644 index 0000000..53a5131 --- /dev/null +++ b/src/components/player/components/SpeedActivatedOverlay.tsx @@ -0,0 +1,38 @@ +/** + * Shared Speed Activated Overlay Component + * Used by both Android (VLC) and iOS (KSPlayer) players + */ +import React from 'react'; +import { View, Text, Animated } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { styles } from '../utils/playerStyles'; + +interface SpeedActivatedOverlayProps { + visible: boolean; + opacity: Animated.Value; + speed: number; +} + +export const SpeedActivatedOverlay: React.FC = ({ + visible, + opacity, + speed +}) => { + if (!visible) return null; + + return ( + + + + {speed}x Speed + + + ); +}; + +export default SpeedActivatedOverlay; diff --git a/src/components/player/components/index.ts b/src/components/player/components/index.ts new file mode 100644 index 0000000..5f1c8e9 --- /dev/null +++ b/src/components/player/components/index.ts @@ -0,0 +1,8 @@ +/** + * Shared Player Components + * Export all reusable components for both Android and iOS players + */ + +export { SpeedActivatedOverlay } from './SpeedActivatedOverlay'; +export { PauseOverlay } from './PauseOverlay'; +export { GestureControls } from './GestureControls'; diff --git a/src/components/player/hooks/index.ts b/src/components/player/hooks/index.ts new file mode 100644 index 0000000..9c91138 --- /dev/null +++ b/src/components/player/hooks/index.ts @@ -0,0 +1,21 @@ +/** + * Shared Player Hooks + * Export all reusable hooks for both Android and iOS players + */ + +// State Management +export { usePlayerState, type PlayerResizeMode } from './usePlayerState'; +export { usePlayerModals } from './usePlayerModals'; +export { usePlayerTracks } from './usePlayerTracks'; +export { useCustomSubtitles } from './useCustomSubtitles'; + +// Controls & Playback +export { usePlayerControls } from './usePlayerControls'; +export { useSpeedControl } from './useSpeedControl'; + +// Animation & UI +export { useOpeningAnimation } from './useOpeningAnimation'; +export { usePlayerSetup } from './usePlayerSetup'; + +// Content +export { useNextEpisode } from './useNextEpisode'; diff --git a/src/components/player/hooks/useCustomSubtitles.ts b/src/components/player/hooks/useCustomSubtitles.ts new file mode 100644 index 0000000..5def5c9 --- /dev/null +++ b/src/components/player/hooks/useCustomSubtitles.ts @@ -0,0 +1,62 @@ +/** + * Shared Custom Subtitles Hook + * Used by both Android (VLC) and iOS (KSPlayer) players + */ +import { useState } from 'react'; +import { + DEFAULT_SUBTITLE_SIZE, + SubtitleCue, + SubtitleSegment, + WyzieSubtitle +} from '../utils/playerTypes'; + +export const useCustomSubtitles = () => { + // Data State + const [customSubtitles, setCustomSubtitles] = useState([]); + const [currentSubtitle, setCurrentSubtitle] = useState(''); + const [currentFormattedSegments, setCurrentFormattedSegments] = useState([]); + const [availableSubtitles, setAvailableSubtitles] = useState([]); + const [useCustomSubtitles, setUseCustomSubtitles] = useState(false); + + // Loading State + const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false); + const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false); + + // Styling State + const [subtitleSize, setSubtitleSize] = useState(DEFAULT_SUBTITLE_SIZE); + const [subtitleBackground, setSubtitleBackground] = useState(false); + const [subtitleTextColor, setSubtitleTextColor] = useState('#FFFFFF'); + const [subtitleBgOpacity, setSubtitleBgOpacity] = useState(0.7); + const [subtitleTextShadow, setSubtitleTextShadow] = useState(true); + const [subtitleOutline, setSubtitleOutline] = useState(true); + const [subtitleOutlineColor, setSubtitleOutlineColor] = useState('#000000'); + const [subtitleOutlineWidth, setSubtitleOutlineWidth] = useState(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); + + return { + customSubtitles, setCustomSubtitles, + currentSubtitle, setCurrentSubtitle, + currentFormattedSegments, setCurrentFormattedSegments, + availableSubtitles, setAvailableSubtitles, + useCustomSubtitles, setUseCustomSubtitles, + isLoadingSubtitles, setIsLoadingSubtitles, + isLoadingSubtitleList, setIsLoadingSubtitleList, + subtitleSize, setSubtitleSize, + subtitleBackground, setSubtitleBackground, + subtitleTextColor, setSubtitleTextColor, + subtitleBgOpacity, setSubtitleBgOpacity, + subtitleTextShadow, setSubtitleTextShadow, + subtitleOutline, setSubtitleOutline, + subtitleOutlineColor, setSubtitleOutlineColor, + subtitleOutlineWidth, setSubtitleOutlineWidth, + subtitleAlign, setSubtitleAlign, + subtitleBottomOffset, setSubtitleBottomOffset, + subtitleLetterSpacing, setSubtitleLetterSpacing, + subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier, + subtitleOffsetSec, setSubtitleOffsetSec + }; +}; diff --git a/src/components/player/hooks/useNextEpisode.ts b/src/components/player/hooks/useNextEpisode.ts new file mode 100644 index 0000000..d754daf --- /dev/null +++ b/src/components/player/hooks/useNextEpisode.ts @@ -0,0 +1,65 @@ +/** + * Shared Next Episode Hook + * Used by both Android (VLC) and iOS (KSPlayer) players + */ +import { useMemo } from 'react'; + +interface NextEpisodeConfig { + type: string | undefined; + season: number | undefined; + episode: number | undefined; + groupedEpisodes: Record | undefined; + episodeId?: string; +} + +export const useNextEpisode = (config: NextEpisodeConfig) => { + const { type, season, episode, groupedEpisodes, episodeId } = config; + + // Current description + const currentEpisodeDescription = useMemo(() => { + 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 ''; + } + }, [type, groupedEpisodes, episodeId, season, episode]); + + // Next Episode + const nextEpisode = useMemo(() => { + try { + if (type !== 'series' || !season || !episode) return null; + const sourceGroups = groupedEpisodes || {}; + + const allEpisodes = Object.values(sourceGroups).flat() as any[]; + if (!allEpisodes || allEpisodes.length === 0) return null; + + // Try to find 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 + ); + } + return nextEp; + } catch { + return null; + } + }, [type, season, episode, groupedEpisodes]); + + return { currentEpisodeDescription, nextEpisode }; +}; diff --git a/src/components/player/hooks/useOpeningAnimation.ts b/src/components/player/hooks/useOpeningAnimation.ts new file mode 100644 index 0000000..a292f45 --- /dev/null +++ b/src/components/player/hooks/useOpeningAnimation.ts @@ -0,0 +1,152 @@ +/** + * Shared Opening Animation Hook + * Used by both Android (VLC) and iOS (KSPlayer) players + */ +import { useRef, useState, useEffect } from 'react'; +import { Animated, InteractionManager } from 'react-native'; +import FastImage from '@d11/react-native-fast-image'; +import { logger } from '../../../utils/logger'; + +export const useOpeningAnimation = (backdrop: string | undefined, metadata: any) => { + // Animation Values + const fadeAnim = useRef(new Animated.Value(1)).current; + 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 backdropImageOpacityAnim = useRef(new Animated.Value(0)).current; + 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; + + const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false); + const [shouldHideOpeningOverlay, setShouldHideOpeningOverlay] = useState(false); + const [isBackdropLoaded, setIsBackdropLoaded] = useState(false); + + // Prefetch Background + useEffect(() => { + const task = InteractionManager.runAfterInteractions(() => { + if (backdrop && typeof backdrop === 'string') { + setIsBackdropLoaded(false); + backdropImageOpacityAnim.setValue(0); + try { + FastImage.preload([{ uri: backdrop }]); + setIsBackdropLoaded(true); + Animated.timing(backdropImageOpacityAnim, { + toValue: 1, + duration: 400, + useNativeDriver: true, + }).start(); + } catch (error) { + setIsBackdropLoaded(true); + backdropImageOpacityAnim.setValue(1); + } + } else { + setIsBackdropLoaded(true); + backdropImageOpacityAnim.setValue(0); + } + }); + return () => task.cancel(); + }, [backdrop]); + + // Prefetch Logo + useEffect(() => { + const task = InteractionManager.runAfterInteractions(() => { + const logoUrl = metadata?.logo; + if (logoUrl && typeof logoUrl === 'string') { + try { + FastImage.preload([{ uri: logoUrl }]); + } catch (error) { } + } + }); + return () => task.cancel(); + }, [metadata]); + + const startOpeningAnimation = () => { + Animated.parallel([ + Animated.timing(logoOpacityAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + Animated.spring(logoScaleAnim, { + toValue: 1, + tension: 80, + friction: 8, + useNativeDriver: true, + }), + ]).start(); + + const createPulseAnimation = () => { + return Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1.05, + duration: 800, + useNativeDriver: true, + }), + Animated.timing(pulseAnim, { + toValue: 1, + duration: 800, + useNativeDriver: true, + }), + ]); + }; + + const loopPulse = () => { + createPulseAnimation().start(() => { + if (!isOpeningAnimationComplete) { + loopPulse(); + } + }); + }; + loopPulse(); + }; + + const completeOpeningAnimation = () => { + pulseAnim.stopAnimation(); + + Animated.parallel([ + Animated.timing(openingFadeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(openingScaleAnim, { + toValue: 1, + duration: 350, + useNativeDriver: true, + }), + Animated.timing(backgroundFadeAnim, { + toValue: 0, + duration: 400, + useNativeDriver: true, + }), + ]).start(() => { + setIsOpeningAnimationComplete(true); + setTimeout(() => { + setShouldHideOpeningOverlay(true); + }, 450); + }); + + setTimeout(() => { + if (!isOpeningAnimationComplete) { + setIsOpeningAnimationComplete(true); + } + }, 1000); + }; + + return { + fadeAnim, + openingFadeAnim, + openingScaleAnim, + backgroundFadeAnim, + backdropImageOpacityAnim, + logoScaleAnim, + logoOpacityAnim, + pulseAnim, + isOpeningAnimationComplete, + shouldHideOpeningOverlay, + isBackdropLoaded, + startOpeningAnimation, + completeOpeningAnimation + }; +}; diff --git a/src/components/player/hooks/usePlayerControls.ts b/src/components/player/hooks/usePlayerControls.ts new file mode 100644 index 0000000..c94cc74 --- /dev/null +++ b/src/components/player/hooks/usePlayerControls.ts @@ -0,0 +1,81 @@ +/** + * Shared Player Controls Hook + * Used by both Android (VLC) and iOS (KSPlayer) players + */ +import { useRef, useCallback, MutableRefObject } from 'react'; +import { Platform } from 'react-native'; +import { logger } from '../../../utils/logger'; + +const DEBUG_MODE = false; +const END_EPSILON = 0.3; + +interface PlayerControlsConfig { + playerRef: MutableRefObject; + paused: boolean; + setPaused: (paused: boolean) => void; + currentTime: number; + duration: number; + isSeeking: MutableRefObject; + isMounted: MutableRefObject; +} + +export const usePlayerControls = (config: PlayerControlsConfig) => { + const { + playerRef, + paused, + setPaused, + currentTime, + duration, + isSeeking, + isMounted + } = config; + + // iOS seeking helpers + const iosWasPausedDuringSeekRef = useRef(null); + + const togglePlayback = useCallback(() => { + setPaused(!paused); + }, [paused, setPaused]); + + const seekToTime = useCallback((rawSeconds: number) => { + const timeInSeconds = Math.max(0, Math.min(rawSeconds, duration > 0 ? duration - END_EPSILON : rawSeconds)); + + if (playerRef.current && duration > 0 && !isSeeking.current) { + if (DEBUG_MODE) logger.log(`[usePlayerControls] Seeking to ${timeInSeconds}`); + + isSeeking.current = true; + + // iOS optimization: pause while seeking for smoother experience + if (Platform.OS === 'ios') { + iosWasPausedDuringSeekRef.current = paused; + if (!paused) setPaused(true); + } + + // Actually perform the seek + playerRef.current.seek(timeInSeconds); + + // Debounce the seeking state reset + setTimeout(() => { + if (isMounted.current && isSeeking.current) { + isSeeking.current = false; + // Resume if it was playing (iOS specific) + if (Platform.OS === 'ios' && iosWasPausedDuringSeekRef.current === false) { + setPaused(false); + iosWasPausedDuringSeekRef.current = null; + } + } + }, 500); + } + }, [duration, paused, setPaused, playerRef, isSeeking, isMounted]); + + const skip = useCallback((seconds: number) => { + seekToTime(currentTime + seconds); + }, [currentTime, seekToTime]); + + return { + togglePlayback, + seekToTime, + skip, + iosWasPausedDuringSeekRef + }; +}; diff --git a/src/components/player/hooks/usePlayerModals.ts b/src/components/player/hooks/usePlayerModals.ts new file mode 100644 index 0000000..1615515 --- /dev/null +++ b/src/components/player/hooks/usePlayerModals.ts @@ -0,0 +1,38 @@ +/** + * Shared Player Modals Hook + * Used by both Android (VLC) and iOS (KSPlayer) players + */ +import { useState } from 'react'; +import { Episode } from '../../../types/metadata'; + +export const usePlayerModals = () => { + const [showAudioModal, setShowAudioModal] = useState(false); + const [showSubtitleModal, setShowSubtitleModal] = useState(false); + const [showSpeedModal, setShowSpeedModal] = useState(false); + const [showSourcesModal, setShowSourcesModal] = useState(false); + const [showEpisodesModal, setShowEpisodesModal] = useState(false); + const [showEpisodeStreamsModal, setShowEpisodeStreamsModal] = useState(false); + const [showErrorModal, setShowErrorModal] = useState(false); + const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false); + const [showCastDetails, setShowCastDetails] = useState(false); + + // Some modals have associated data + const [selectedEpisodeForStreams, setSelectedEpisodeForStreams] = useState(null); + const [errorDetails, setErrorDetails] = useState(''); + const [selectedCastMember, setSelectedCastMember] = useState(null); + + return { + showAudioModal, setShowAudioModal, + showSubtitleModal, setShowSubtitleModal, + showSpeedModal, setShowSpeedModal, + showSourcesModal, setShowSourcesModal, + showEpisodesModal, setShowEpisodesModal, + showEpisodeStreamsModal, setShowEpisodeStreamsModal, + showErrorModal, setShowErrorModal, + showSubtitleLanguageModal, setShowSubtitleLanguageModal, + showCastDetails, setShowCastDetails, + selectedEpisodeForStreams, setSelectedEpisodeForStreams, + errorDetails, setErrorDetails, + selectedCastMember, setSelectedCastMember + }; +}; diff --git a/src/components/player/hooks/usePlayerSetup.ts b/src/components/player/hooks/usePlayerSetup.ts new file mode 100644 index 0000000..f293611 --- /dev/null +++ b/src/components/player/hooks/usePlayerSetup.ts @@ -0,0 +1,117 @@ +/** + * Shared Player Setup Hook + * Used by both Android (VLC) and iOS (KSPlayer) players + * Handles StatusBar, orientation, brightness, and app state + */ +import { useEffect, useRef, useCallback } from 'react'; +import { StatusBar, Dimensions, AppState, InteractionManager, Platform } from 'react-native'; +import * as Brightness from 'expo-brightness'; +import * as ScreenOrientation from 'expo-screen-orientation'; +import { logger } from '../../../utils/logger'; +import { useFocusEffect } from '@react-navigation/native'; + +interface PlayerSetupConfig { + setScreenDimensions: (dim: any) => void; + setVolume: (vol: number) => void; + setBrightness: (bri: number) => void; + isOpeningAnimationComplete: boolean; +} + +export const usePlayerSetup = (config: PlayerSetupConfig) => { + const { + setScreenDimensions, + setVolume, + setBrightness, + isOpeningAnimationComplete + } = config; + + const isAppBackgrounded = useRef(false); + + const enableImmersiveMode = () => { + StatusBar.setHidden(true, 'none'); + }; + + const disableImmersiveMode = () => { + StatusBar.setHidden(false, 'fade'); + }; + + useFocusEffect( + useCallback(() => { + if (isOpeningAnimationComplete) { + enableImmersiveMode(); + } + return () => { }; + }, [isOpeningAnimationComplete]) + ); + + useEffect(() => { + // Initial Setup + const subscription = Dimensions.addEventListener('change', ({ screen }) => { + setScreenDimensions(screen); + if (isOpeningAnimationComplete) { + enableImmersiveMode(); + } + }); + + StatusBar.setHidden(true, 'none'); + if (isOpeningAnimationComplete) { + enableImmersiveMode(); + } + + // Initialize volume (normalized 0-1 for cross-platform) + setVolume(1.0); + + // Initialize Brightness + const initBrightness = () => { + InteractionManager.runAfterInteractions(async () => { + try { + const currentBrightness = await Brightness.getBrightnessAsync(); + setBrightness(currentBrightness); + } catch (error) { + logger.warn('[usePlayerSetup] Error getting initial brightness:', error); + setBrightness(1.0); + } + }); + }; + initBrightness(); + + return () => { + subscription?.remove(); + disableImmersiveMode(); + }; + }, [isOpeningAnimationComplete]); + + // Handle Orientation (Lock to Landscape after opening) + useEffect(() => { + if (isOpeningAnimationComplete) { + const task = InteractionManager.runAfterInteractions(() => { + ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE) + .then(() => { + if (__DEV__) logger.log('[VideoPlayer] Locked to landscape orientation'); + }) + .catch((error) => { + logger.warn('[VideoPlayer] Failed to lock orientation:', error); + }); + }); + return () => task.cancel(); + } + }, [isOpeningAnimationComplete]); + + // Handle App State + useEffect(() => { + const onAppStateChange = (state: string) => { + if (state === 'active') { + isAppBackgrounded.current = false; + if (isOpeningAnimationComplete) { + enableImmersiveMode(); + } + } else if (state === 'background' || state === 'inactive') { + isAppBackgrounded.current = true; + } + }; + const sub = AppState.addEventListener('change', onAppStateChange); + return () => sub.remove(); + }, [isOpeningAnimationComplete]); + + return { isAppBackgrounded }; +}; diff --git a/src/components/player/hooks/usePlayerState.ts b/src/components/player/hooks/usePlayerState.ts new file mode 100644 index 0000000..828def5 --- /dev/null +++ b/src/components/player/hooks/usePlayerState.ts @@ -0,0 +1,88 @@ +/** + * Shared Player State Hook + * Used by both Android (VLC) and iOS (KSPlayer) players + */ +import { useState, useRef } from 'react'; +import { Dimensions, Platform } from 'react-native'; + +// Use only resize modes supported by all player backends +// (not all players support 'stretch' or 'none') +export type PlayerResizeMode = 'contain' | 'cover'; + +export const usePlayerState = () => { + // Playback State + const [paused, setPaused] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [buffered, setBuffered] = useState(0); + const [isBuffering, setIsBuffering] = useState(false); + const [isVideoLoaded, setIsVideoLoaded] = useState(false); + const [isPlayerReady, setIsPlayerReady] = useState(false); + + // UI State + const [showControls, setShowControls] = useState(true); + const [resizeMode, setResizeMode] = useState('contain'); + const [videoAspectRatio, setVideoAspectRatio] = useState(null); + const [is16by9Content, setIs16by9Content] = useState(false); + const screenData = Dimensions.get('screen'); + const [screenDimensions, setScreenDimensions] = useState(screenData); + + // Zoom State + 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); + + // AirPlay State (iOS only, but keeping it here for unified interface) + const [isAirPlayActive, setIsAirPlayActive] = useState(false); + const [allowsAirPlay, setAllowsAirPlay] = useState(true); + + // Logic State + const isSeeking = useRef(false); + const isDragging = useRef(false); + const isMounted = useRef(true); + const seekDebounceTimer = useRef(null); + const pendingSeekValue = useRef(null); + const lastSeekTime = useRef(0); + const wasPlayingBeforeDragRef = useRef(false); + + // Helper for iPad/macOS fullscreen + const isIPad = Platform.OS === 'ios' && (screenData.width > 1000 || screenData.height > 1000); + const isMacOS = Platform.OS === 'ios' && Platform.isPad === true; + const shouldUseFullscreen = isIPad || isMacOS; + const windowData = Dimensions.get('window'); + const effectiveDimensions = shouldUseFullscreen ? windowData : screenDimensions; + + return { + paused, setPaused, + currentTime, setCurrentTime, + duration, setDuration, + buffered, setBuffered, + isBuffering, setIsBuffering, + isVideoLoaded, setIsVideoLoaded, + isPlayerReady, setIsPlayerReady, + showControls, setShowControls, + resizeMode, setResizeMode, + videoAspectRatio, setVideoAspectRatio, + is16by9Content, setIs16by9Content, + screenDimensions, setScreenDimensions, + zoomScale, setZoomScale, + zoomTranslateX, setZoomTranslateX, + zoomTranslateY, setZoomTranslateY, + lastZoomScale, setLastZoomScale, + lastTranslateX, setLastTranslateX, + lastTranslateY, setLastTranslateY, + isAirPlayActive, setIsAirPlayActive, + allowsAirPlay, setAllowsAirPlay, + isSeeking, + isDragging, + isMounted, + seekDebounceTimer, + pendingSeekValue, + lastSeekTime, + wasPlayingBeforeDragRef, + effectiveDimensions + }; +}; diff --git a/src/components/player/hooks/usePlayerTracks.ts b/src/components/player/hooks/usePlayerTracks.ts new file mode 100644 index 0000000..1f19fec --- /dev/null +++ b/src/components/player/hooks/usePlayerTracks.ts @@ -0,0 +1,47 @@ +/** + * Shared Player Tracks Hook + * Used by both Android (VLC) and iOS (KSPlayer) players + */ +import { useState, useCallback } from 'react'; +import { AudioTrack, TextTrack } from '../utils/playerTypes'; + +export const usePlayerTracks = () => { + // React-native-video style tracks + const [audioTracks, setAudioTracks] = useState([]); + const [selectedAudioTrack, setSelectedAudioTrack] = useState(null); + const [textTracks, setTextTracks] = useState([]); + const [selectedTextTrack, setSelectedTextTrack] = useState(-1); + + // KS/VLC style tracks (simpler format) + const [ksAudioTracks, setKsAudioTracks] = useState>([]); + const [ksTextTracks, setKsTextTracks] = useState>([]); + + // Derived states + const hasAudioTracks = audioTracks.length > 0 || ksAudioTracks.length > 0; + const hasTextTracks = textTracks.length > 0 || ksTextTracks.length > 0; + + // Track selection functions + const selectAudioTrack = useCallback((trackId: number) => { + setSelectedAudioTrack(trackId); + }, []); + + const selectTextTrack = useCallback((trackId: number) => { + setSelectedTextTrack(trackId); + }, []); + + return { + // Standard tracks + audioTracks, setAudioTracks, + selectedAudioTrack, setSelectedAudioTrack, + textTracks, setTextTracks, + selectedTextTrack, setSelectedTextTrack, + // KS/VLC tracks + ksAudioTracks, setKsAudioTracks, + ksTextTracks, setKsTextTracks, + // Helpers + hasAudioTracks, + hasTextTracks, + selectAudioTrack, + selectTextTrack + }; +}; diff --git a/src/components/player/hooks/useSpeedControl.ts b/src/components/player/hooks/useSpeedControl.ts new file mode 100644 index 0000000..a8a0c02 --- /dev/null +++ b/src/components/player/hooks/useSpeedControl.ts @@ -0,0 +1,97 @@ +/** + * Shared Speed Control Hook + * Used by both Android (VLC) and iOS (KSPlayer) players + */ +import { useState, useRef, useCallback, useEffect } from 'react'; +import { Animated } from 'react-native'; +import { mmkvStorage } from '../../../services/mmkvStorage'; +import { logger } from '../../../utils/logger'; + +const SPEED_SETTINGS_KEY = '@nuvio_speed_settings'; + +export const useSpeedControl = (initialSpeed: number = 1.0) => { + const [playbackSpeed, setPlaybackSpeed] = useState(initialSpeed); + const [holdToSpeedEnabled, setHoldToSpeedEnabled] = useState(true); + const [holdToSpeedValue, setHoldToSpeedValue] = useState(2.0); + const [isSpeedBoosted, setIsSpeedBoosted] = useState(false); + const [originalSpeed, setOriginalSpeed] = useState(initialSpeed); + const [showSpeedActivatedOverlay, setShowSpeedActivatedOverlay] = useState(false); + + const speedActivatedOverlayOpacity = useRef(new Animated.Value(0)).current; + + // Load Settings + useEffect(() => { + const loadSettings = 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 (e) { + logger.warn('[useSpeedControl] Error loading settings', e); + } + }; + loadSettings(); + }, []); + + // Save Settings + useEffect(() => { + const saveSettings = async () => { + try { + await mmkvStorage.setItem(SPEED_SETTINGS_KEY, JSON.stringify({ + holdToSpeedEnabled, + holdToSpeedValue + })); + } catch (e) { } + }; + saveSettings(); + }, [holdToSpeedEnabled, holdToSpeedValue]); + + const activateSpeedBoost = useCallback(() => { + if (!holdToSpeedEnabled || isSpeedBoosted || playbackSpeed === holdToSpeedValue) return; + + setOriginalSpeed(playbackSpeed); + setPlaybackSpeed(holdToSpeedValue); + setIsSpeedBoosted(true); + setShowSpeedActivatedOverlay(true); + + Animated.timing(speedActivatedOverlayOpacity, { + toValue: 1, + duration: 200, + useNativeDriver: true + }).start(); + + setTimeout(() => { + Animated.timing(speedActivatedOverlayOpacity, { + toValue: 0, + duration: 300, + useNativeDriver: true + }).start(() => setShowSpeedActivatedOverlay(false)); + }, 2000); + + }, [holdToSpeedEnabled, isSpeedBoosted, playbackSpeed, holdToSpeedValue]); + + const deactivateSpeedBoost = useCallback(() => { + if (isSpeedBoosted) { + setPlaybackSpeed(originalSpeed); + setIsSpeedBoosted(false); + Animated.timing(speedActivatedOverlayOpacity, { toValue: 0, duration: 100, useNativeDriver: true }).start(); + } + }, [isSpeedBoosted, originalSpeed]); + + return { + playbackSpeed, + setPlaybackSpeed, + holdToSpeedEnabled, + setHoldToSpeedEnabled, + holdToSpeedValue, + setHoldToSpeedValue, + isSpeedBoosted, + activateSpeedBoost, + deactivateSpeedBoost, + showSpeedActivatedOverlay, + speedActivatedOverlayOpacity + }; +}; diff --git a/src/components/player/ios/components/GestureControls.tsx b/src/components/player/ios/components/GestureControls.tsx new file mode 100644 index 0000000..f0bb38d --- /dev/null +++ b/src/components/player/ios/components/GestureControls.tsx @@ -0,0 +1,333 @@ +import React from 'react'; +import { View, Text, Animated } from 'react-native'; +import { + TapGestureHandler, + PanGestureHandler, + LongPressGestureHandler, +} from 'react-native-gesture-handler'; +import { MaterialIcons } from '@expo/vector-icons'; + +interface GestureControlsProps { + screenDimensions: { width: number, height: number }; + gestureControls: any; + onLongPressActivated: () => void; + onLongPressEnd: () => void; + onLongPressStateChange: (event: any) => void; + toggleControls: () => void; + showControls: boolean; + hideControls: () => void; + volume: number; + brightness: number; + controlsTimeout: React.MutableRefObject; +} + +export const GestureControls: React.FC = ({ + screenDimensions, + gestureControls, + onLongPressActivated, + onLongPressEnd, + onLongPressStateChange, + toggleControls, + showControls, + hideControls, + volume, + brightness, + controlsTimeout +}) => { + // Helper to get dimensions (using passed screenDimensions) + const getDimensions = () => screenDimensions; + + return ( + <> + {/* Left side gesture handler - brightness + tap + long press */} + + + + + + + + + {/* Right side gesture handler - volume + tap + long press */} + + + + + + + + + {/* Center area tap handler */} + { + if (showControls) { + const timeoutId = setTimeout(() => { + hideControls(); + }, 0); + if (controlsTimeout.current) { + clearTimeout(controlsTimeout.current); + } + controlsTimeout.current = timeoutId; + } else { + toggleControls(); + } + }} + shouldCancelWhenOutside={false} + simultaneousHandlers={[]} + > + + + + {/* 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)}% + + + + )} + + ); +}; diff --git a/src/components/player/ios/components/KSPlayerSurface.tsx b/src/components/player/ios/components/KSPlayerSurface.tsx new file mode 100644 index 0000000..715e6b2 --- /dev/null +++ b/src/components/player/ios/components/KSPlayerSurface.tsx @@ -0,0 +1,140 @@ +import React, { useRef } from 'react'; +import { Animated } from 'react-native'; +import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler'; +import KSPlayerComponent, { KSPlayerRef, KSPlayerSource } from '../../KSPlayerComponent'; + +interface KSPlayerSurfaceProps { + ksPlayerRef: React.RefObject; + uri: string; + headers?: Record; + paused: boolean; + volume: number; + playbackSpeed: number; + resizeMode: 'contain' | 'cover' | 'stretch'; + zoomScale: number; + setZoomScale: (scale: number) => void; + lastZoomScale: number; + setLastZoomScale: (scale: number) => void; + + // Tracks - use number directly + audioTrack?: number; + textTrack?: number; + onAudioTracks: (data: any) => void; + onTextTracks: (data: any) => void; + + // Handlers + onLoad: (data: any) => void; + onProgress: (data: any) => void; + onEnd: () => void; + onError: (error: any) => void; + onBuffer: (isBuffering: boolean) => void; + onReadyForDisplay: () => void; + onPlaybackStalled: () => void; + onPlaybackResume: () => void; + + // Dimensions + screenWidth: number; + screenHeight: number; + customVideoStyles: any; +} + +export const KSPlayerSurface: React.FC = ({ + ksPlayerRef, + uri, + headers, + paused, + volume, + playbackSpeed, + resizeMode, + zoomScale, + setZoomScale, + lastZoomScale, + setLastZoomScale, + audioTrack, + textTrack, + onAudioTracks, + onTextTracks, + onLoad, + onProgress, + onEnd, + onError, + onBuffer, + onReadyForDisplay, + onPlaybackStalled, + onPlaybackResume, + screenWidth, + screenHeight, + customVideoStyles +}) => { + const pinchRef = useRef(null); + + const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => { + const { scale } = event.nativeEvent; + // Limit max zoom to 1.1x as per original logic, min 1 + const newScale = Math.max(1, Math.min(lastZoomScale * scale, 1.1)); + setZoomScale(newScale); + }; + + const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => { + if (event.nativeEvent.state === State.END) { + setLastZoomScale(zoomScale); + } + }; + + // Create source object for KSPlayerComponent + const source: KSPlayerSource = { + uri, + headers + }; + + // Handle buffering - KSPlayerComponent uses onBuffering callback + const handleBuffering = (data: any) => { + onBuffer(data?.isBuffering ?? false); + }; + + // Handle load - also extract tracks if available + const handleLoad = (data: any) => { + onLoad(data); + // Extract tracks if present in load data + if (data?.audioTracks) { + onAudioTracks({ audioTracks: data.audioTracks }); + } + if (data?.textTracks) { + onTextTracks({ textTracks: data.textTracks }); + } + // Notify ready for display + onReadyForDisplay(); + }; + + return ( + + + + + + ); +}; diff --git a/src/components/player/ios/components/PauseOverlay.tsx b/src/components/player/ios/components/PauseOverlay.tsx new file mode 100644 index 0000000..932089a --- /dev/null +++ b/src/components/player/ios/components/PauseOverlay.tsx @@ -0,0 +1,228 @@ +import React, { useState, useRef } from 'react'; +import { View, Text, TouchableOpacity, Animated, StyleSheet } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import FastImage from '@d11/react-native-fast-image'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +interface PauseOverlayProps { + visible: boolean; + onClose: () => void; + title: string; + episodeTitle?: string; + season?: number; + episode?: number; + year?: string | number; + type: string; + description: string; + cast: any[]; + screenDimensions: { width: number, height: number }; +} + +export const PauseOverlay: React.FC = ({ + visible, + onClose, + title, + episodeTitle, + season, + episode, + year, + type, + description, + cast, + screenDimensions +}) => { + const insets = useSafeAreaInsets(); + + // Internal Animation State + const pauseOverlayOpacity = useRef(new Animated.Value(visible ? 1 : 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; + + // Cast Details 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; + + React.useEffect(() => { + Animated.timing(pauseOverlayOpacity, { + toValue: visible ? 1 : 0, + duration: 250, + useNativeDriver: true + }).start(); + }, [visible]); + + if (!visible && !showCastDetails) return null; + + return ( + + + {/* Horizontal Fade */} + + + + + + + {showCastDetails && selectedCastMember ? ( + + + { + 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); + 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} + + )} + {selectedCastMember.biography && ( + + {selectedCastMember.biography} + + )} + + + + + ) : ( + + + You're watching + + {title} + + {!!year && ( + + {`${year}${type === 'series' && season && episode ? ` • S${season}E${episode}` : ''}`} + + )} + {!!episodeTitle && ( + + {episodeTitle} + + )} + {description && ( + + {description} + + )} + {cast && cast.length > 0 && ( + + Cast + + {cast.slice(0, 6).map((castMember: any, index: number) => ( + { + setSelectedCastMember(castMember); + Animated.parallel([ + Animated.timing(metadataOpacity, { toValue: 0, duration: 250, useNativeDriver: true }), + Animated.timing(metadataScale, { toValue: 0.95, duration: 250, useNativeDriver: true }) + ]).start(() => { + setShowCastDetails(true); + 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} + + + ))} + + + )} + + + )} + + + + ); +}; diff --git a/src/components/player/ios/components/SpeedActivatedOverlay.tsx b/src/components/player/ios/components/SpeedActivatedOverlay.tsx new file mode 100644 index 0000000..b835364 --- /dev/null +++ b/src/components/player/ios/components/SpeedActivatedOverlay.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { View, Text, Animated } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { styles } from '../../utils/playerStyles'; + +interface SpeedActivatedOverlayProps { + visible: boolean; + opacity: Animated.Value; + speed: number; +} + +export const SpeedActivatedOverlay: React.FC = ({ + visible, + opacity, + speed +}) => { + if (!visible) return null; + + return ( + + + + {speed}x Speed + + + ); +}; diff --git a/src/components/player/ios/hooks/useCustomSubtitles.ts b/src/components/player/ios/hooks/useCustomSubtitles.ts new file mode 100644 index 0000000..a7ff132 --- /dev/null +++ b/src/components/player/ios/hooks/useCustomSubtitles.ts @@ -0,0 +1,58 @@ +import { useState } from 'react'; +import { + DEFAULT_SUBTITLE_SIZE, + SubtitleCue, + SubtitleSegment, + WyzieSubtitle +} from '../../utils/playerTypes'; + +export const useCustomSubtitles = () => { + // Data State + const [customSubtitles, setCustomSubtitles] = useState([]); + const [currentSubtitle, setCurrentSubtitle] = useState(''); + const [currentFormattedSegments, setCurrentFormattedSegments] = useState([]); + const [availableSubtitles, setAvailableSubtitles] = useState([]); + const [useCustomSubtitles, setUseCustomSubtitles] = useState(false); + + // Loading State + const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false); + const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false); + + // Styling State + const [subtitleSize, setSubtitleSize] = useState(DEFAULT_SUBTITLE_SIZE); + const [subtitleBackground, setSubtitleBackground] = useState(false); + const [subtitleTextColor, setSubtitleTextColor] = useState('#FFFFFF'); + const [subtitleBgOpacity, setSubtitleBgOpacity] = useState(0.7); + const [subtitleTextShadow, setSubtitleTextShadow] = useState(true); + const [subtitleOutline, setSubtitleOutline] = useState(true); + const [subtitleOutlineColor, setSubtitleOutlineColor] = useState('#000000'); + const [subtitleOutlineWidth, setSubtitleOutlineWidth] = useState(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); + + return { + customSubtitles, setCustomSubtitles, + currentSubtitle, setCurrentSubtitle, + currentFormattedSegments, setCurrentFormattedSegments, + availableSubtitles, setAvailableSubtitles, + useCustomSubtitles, setUseCustomSubtitles, + isLoadingSubtitles, setIsLoadingSubtitles, + isLoadingSubtitleList, setIsLoadingSubtitleList, + subtitleSize, setSubtitleSize, + subtitleBackground, setSubtitleBackground, + subtitleTextColor, setSubtitleTextColor, + subtitleBgOpacity, setSubtitleBgOpacity, + subtitleTextShadow, setSubtitleTextShadow, + subtitleOutline, setSubtitleOutline, + subtitleOutlineColor, setSubtitleOutlineColor, + subtitleOutlineWidth, setSubtitleOutlineWidth, + subtitleAlign, setSubtitleAlign, + subtitleBottomOffset, setSubtitleBottomOffset, + subtitleLetterSpacing, setSubtitleLetterSpacing, + subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier, + subtitleOffsetSec, setSubtitleOffsetSec + }; +}; diff --git a/src/components/player/ios/hooks/useKSPlayer.ts b/src/components/player/ios/hooks/useKSPlayer.ts new file mode 100644 index 0000000..9432c06 --- /dev/null +++ b/src/components/player/ios/hooks/useKSPlayer.ts @@ -0,0 +1,15 @@ +import { useRef } from 'react'; +import { KSPlayerRef } from '../../KSPlayerComponent'; + +export const useKSPlayer = () => { + const ksPlayerRef = useRef(null); + + const seek = (time: number) => { + ksPlayerRef.current?.seek(time); + }; + + return { + ksPlayerRef, + seek + }; +}; diff --git a/src/components/player/ios/hooks/useNextEpisode.ts b/src/components/player/ios/hooks/useNextEpisode.ts new file mode 100644 index 0000000..63e2846 --- /dev/null +++ b/src/components/player/ios/hooks/useNextEpisode.ts @@ -0,0 +1,58 @@ +import { useMemo } from 'react'; + +export const useNextEpisode = ( + type: string | undefined, + season: number | undefined, + episode: number | undefined, + groupedEpisodes: any, + metadataGroupedEpisodes: any, + episodeId: string | undefined +) => { + // Current description + const currentEpisodeDescription = useMemo(() => { + 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 ''; + } + }, [type, groupedEpisodes, episodeId, season, episode]); + + // Next Episode + 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; + + let nextEp = allEpisodes.find((ep: any) => + ep.season_number === season && ep.episode_number === episode + 1 + ); + + if (!nextEp) { + nextEp = allEpisodes.find((ep: any) => + ep.season_number === season + 1 && ep.episode_number === 1 + ); + } + return nextEp; + } catch { + return null; + } + }, [type, season, episode, groupedEpisodes, metadataGroupedEpisodes]); + + return { currentEpisodeDescription, nextEpisode }; +}; diff --git a/src/components/player/ios/hooks/useOpeningAnimation.ts b/src/components/player/ios/hooks/useOpeningAnimation.ts new file mode 100644 index 0000000..8dd087e --- /dev/null +++ b/src/components/player/ios/hooks/useOpeningAnimation.ts @@ -0,0 +1,149 @@ +import { useRef, useState, useEffect } from 'react'; +import { Animated, InteractionManager } from 'react-native'; +import FastImage from '@d11/react-native-fast-image'; +import { logger } from '../../../../utils/logger'; + +export const useOpeningAnimation = (backdrop: string | undefined, metadata: any) => { + // Animation Values + const fadeAnim = useRef(new Animated.Value(1)).current; + 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 backdropImageOpacityAnim = useRef(new Animated.Value(0)).current; + 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; + + const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false); + const [shouldHideOpeningOverlay, setShouldHideOpeningOverlay] = useState(false); + const [isBackdropLoaded, setIsBackdropLoaded] = useState(false); + + // Prefetch Background + useEffect(() => { + const task = InteractionManager.runAfterInteractions(() => { + if (backdrop && typeof backdrop === 'string') { + setIsBackdropLoaded(false); + backdropImageOpacityAnim.setValue(0); + try { + FastImage.preload([{ uri: backdrop }]); + setIsBackdropLoaded(true); + Animated.timing(backdropImageOpacityAnim, { + toValue: 1, + duration: 400, + useNativeDriver: true, + }).start(); + } catch (error) { + setIsBackdropLoaded(true); + backdropImageOpacityAnim.setValue(1); + } + } else { + setIsBackdropLoaded(true); + backdropImageOpacityAnim.setValue(0); + } + }); + return () => task.cancel(); + }, [backdrop]); + + // Prefetch Logo + useEffect(() => { + const task = InteractionManager.runAfterInteractions(() => { + const logoUrl = metadata?.logo; + if (logoUrl && typeof logoUrl === 'string') { + try { + FastImage.preload([{ uri: logoUrl }]); + } catch (error) { } + } + }); + return () => task.cancel(); + }, [metadata]); + + const startOpeningAnimation = () => { + Animated.parallel([ + Animated.timing(logoOpacityAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + Animated.spring(logoScaleAnim, { + toValue: 1, + tension: 80, + friction: 8, + useNativeDriver: true, + }), + ]).start(); + + const createPulseAnimation = () => { + return Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1.05, + duration: 800, + useNativeDriver: true, + }), + Animated.timing(pulseAnim, { + toValue: 1, + duration: 800, + useNativeDriver: true, + }), + ]); + }; + + const loopPulse = () => { + createPulseAnimation().start(() => { + if (!isOpeningAnimationComplete) { + loopPulse(); + } + }); + }; + loopPulse(); + }; + + const completeOpeningAnimation = () => { + pulseAnim.stopAnimation(); + + Animated.parallel([ + Animated.timing(openingFadeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(openingScaleAnim, { + toValue: 1, + duration: 350, + useNativeDriver: true, + }), + Animated.timing(backgroundFadeAnim, { + toValue: 0, + duration: 400, + useNativeDriver: true, + }), + ]).start(() => { + setIsOpeningAnimationComplete(true); + setTimeout(() => { + setShouldHideOpeningOverlay(true); + }, 450); + }); + + setTimeout(() => { + if (!isOpeningAnimationComplete) { + // logger.warn('[VideoPlayer] Opening animation fallback triggered'); + setIsOpeningAnimationComplete(true); + } + }, 1000); + }; + + return { + fadeAnim, + openingFadeAnim, + openingScaleAnim, + backgroundFadeAnim, + backdropImageOpacityAnim, + logoScaleAnim, + logoOpacityAnim, + pulseAnim, + isOpeningAnimationComplete, + shouldHideOpeningOverlay, + isBackdropLoaded, + startOpeningAnimation, + completeOpeningAnimation + }; +}; diff --git a/src/components/player/ios/hooks/usePlayerControls.ts b/src/components/player/ios/hooks/usePlayerControls.ts new file mode 100644 index 0000000..1b92ddb --- /dev/null +++ b/src/components/player/ios/hooks/usePlayerControls.ts @@ -0,0 +1,63 @@ +import { useRef, useCallback } from 'react'; +import { Platform } from 'react-native'; +import { logger } from '../../../../utils/logger'; + +const DEBUG_MODE = false; +const END_EPSILON = 0.3; + +export const usePlayerControls = ( + ksPlayerRef: any, + paused: boolean, + setPaused: (paused: boolean) => void, + currentTime: number, + duration: number, + isSeeking: React.MutableRefObject, + isMounted: React.MutableRefObject +) => { + // iOS seeking helpers + const iosWasPausedDuringSeekRef = useRef(null); + + const togglePlayback = useCallback(() => { + setPaused(!paused); + }, [paused, setPaused]); + + const seekToTime = useCallback((rawSeconds: number) => { + const timeInSeconds = Math.max(0, Math.min(rawSeconds, duration > 0 ? duration - END_EPSILON : rawSeconds)); + + if (ksPlayerRef.current && duration > 0 && !isSeeking.current) { + if (DEBUG_MODE) logger.log(`[usePlayerControls] Seeking to ${timeInSeconds}`); + + isSeeking.current = true; + + // iOS optimization: pause while seeking for smoother experience + iosWasPausedDuringSeekRef.current = paused; + if (!paused) setPaused(true); + + // Actually perform the seek + ksPlayerRef.current.seek(timeInSeconds); + + // Debounce the seeking state reset + setTimeout(() => { + if (isMounted.current && isSeeking.current) { + isSeeking.current = false; + // Resume if it was playing + if (iosWasPausedDuringSeekRef.current === false) { + setPaused(false); + iosWasPausedDuringSeekRef.current = null; + } + } + }, 500); + } + }, [duration, paused, setPaused, ksPlayerRef, isSeeking, isMounted]); + + const skip = useCallback((seconds: number) => { + seekToTime(currentTime + seconds); + }, [currentTime, seekToTime]); + + return { + togglePlayback, + seekToTime, + skip, + iosWasPausedDuringSeekRef + }; +}; diff --git a/src/components/player/ios/hooks/usePlayerModals.ts b/src/components/player/ios/hooks/usePlayerModals.ts new file mode 100644 index 0000000..2f98c01 --- /dev/null +++ b/src/components/player/ios/hooks/usePlayerModals.ts @@ -0,0 +1,34 @@ +import { useState } from 'react'; +import { Episode } from '../../../../types/metadata'; + +export const usePlayerModals = () => { + const [showAudioModal, setShowAudioModal] = useState(false); + const [showSubtitleModal, setShowSubtitleModal] = useState(false); + const [showSpeedModal, setShowSpeedModal] = useState(false); + const [showSourcesModal, setShowSourcesModal] = useState(false); + const [showEpisodesModal, setShowEpisodesModal] = useState(false); + const [showEpisodeStreamsModal, setShowEpisodeStreamsModal] = useState(false); + const [showErrorModal, setShowErrorModal] = useState(false); + const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false); + const [showCastDetails, setShowCastDetails] = useState(false); + + // Some modals have associated data + const [selectedEpisodeForStreams, setSelectedEpisodeForStreams] = useState(null); + const [errorDetails, setErrorDetails] = useState(''); + const [selectedCastMember, setSelectedCastMember] = useState(null); + + return { + showAudioModal, setShowAudioModal, + showSubtitleModal, setShowSubtitleModal, + showSpeedModal, setShowSpeedModal, + showSourcesModal, setShowSourcesModal, + showEpisodesModal, setShowEpisodesModal, + showEpisodeStreamsModal, setShowEpisodeStreamsModal, + showErrorModal, setShowErrorModal, + showSubtitleLanguageModal, setShowSubtitleLanguageModal, + showCastDetails, setShowCastDetails, + selectedEpisodeForStreams, setSelectedEpisodeForStreams, + errorDetails, setErrorDetails, + selectedCastMember, setSelectedCastMember + }; +}; diff --git a/src/components/player/ios/hooks/usePlayerSetup.ts b/src/components/player/ios/hooks/usePlayerSetup.ts new file mode 100644 index 0000000..b09a2aa --- /dev/null +++ b/src/components/player/ios/hooks/usePlayerSetup.ts @@ -0,0 +1,103 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { StatusBar, Dimensions, AppState, InteractionManager } from 'react-native'; +import * as Brightness from 'expo-brightness'; +import * as ScreenOrientation from 'expo-screen-orientation'; +import { logger } from '../../../../utils/logger'; +import { useFocusEffect } from '@react-navigation/native'; + +export const usePlayerSetup = ( + setScreenDimensions: (dim: any) => void, + setVolume: (vol: number) => void, + setBrightness: (bri: number) => void, + isOpeningAnimationComplete: boolean +) => { + const isAppBackgrounded = useRef(false); + + const enableImmersiveMode = () => { + StatusBar.setHidden(true, 'none'); + }; + + const disableImmersiveMode = () => { + StatusBar.setHidden(false, 'fade'); + }; + + useFocusEffect( + useCallback(() => { + if (isOpeningAnimationComplete) { + enableImmersiveMode(); + } + return () => { }; + }, [isOpeningAnimationComplete]) + ); + + useEffect(() => { + // Initial Setup + const subscription = Dimensions.addEventListener('change', ({ screen }) => { + setScreenDimensions(screen); + if (isOpeningAnimationComplete) { + enableImmersiveMode(); + } + }); + + StatusBar.setHidden(true, 'none'); + if (isOpeningAnimationComplete) { + enableImmersiveMode(); + } + + // Initialize volume (KSPlayer uses 0-100) + setVolume(100); + + // Initialize Brightness + const initBrightness = () => { + InteractionManager.runAfterInteractions(async () => { + try { + const currentBrightness = await Brightness.getBrightnessAsync(); + setBrightness(currentBrightness); + } catch (error) { + logger.warn('[usePlayerSetup] Error getting initial brightness:', error); + setBrightness(1.0); + } + }); + }; + initBrightness(); + + return () => { + subscription?.remove(); + disableImmersiveMode(); + }; + }, [isOpeningAnimationComplete]); + + // Handle Orientation (Lock to Landscape after opening) + useEffect(() => { + if (isOpeningAnimationComplete) { + const task = InteractionManager.runAfterInteractions(() => { + ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE) + .then(() => { + if (__DEV__) logger.log('[VideoPlayer] Locked to landscape orientation'); + }) + .catch((error) => { + logger.warn('[VideoPlayer] Failed to lock orientation:', error); + }); + }); + return () => task.cancel(); + } + }, [isOpeningAnimationComplete]); + + // Handle App State + useEffect(() => { + const onAppStateChange = (state: string) => { + if (state === 'active') { + isAppBackgrounded.current = false; + if (isOpeningAnimationComplete) { + enableImmersiveMode(); + } + } else if (state === 'background' || state === 'inactive') { + isAppBackgrounded.current = true; + } + }; + const sub = AppState.addEventListener('change', onAppStateChange); + return () => sub.remove(); + }, [isOpeningAnimationComplete]); + + return { isAppBackgrounded }; +}; diff --git a/src/components/player/ios/hooks/usePlayerState.ts b/src/components/player/ios/hooks/usePlayerState.ts new file mode 100644 index 0000000..2cd2ab8 --- /dev/null +++ b/src/components/player/ios/hooks/usePlayerState.ts @@ -0,0 +1,83 @@ +import { useState, useRef } from 'react'; +import { Dimensions, Platform } from 'react-native'; + +// Use a specific type for resizeMode that matches what KSPlayerComponent supports +type PlayerResizeMode = 'contain' | 'cover' | 'stretch'; + +export const usePlayerState = () => { + // Playback State + const [paused, setPaused] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [buffered, setBuffered] = useState(0); + const [isBuffering, setIsBuffering] = useState(false); + const [isVideoLoaded, setIsVideoLoaded] = useState(false); + const [isPlayerReady, setIsPlayerReady] = useState(false); + + // UI State + const [showControls, setShowControls] = useState(true); + const [resizeMode, setResizeMode] = useState('contain'); + const [videoAspectRatio, setVideoAspectRatio] = useState(null); + const [is16by9Content, setIs16by9Content] = useState(false); + const screenData = Dimensions.get('screen'); + const [screenDimensions, setScreenDimensions] = useState(screenData); + + // Zoom State + 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); + + // AirPlay State + const [isAirPlayActive, setIsAirPlayActive] = useState(false); + const [allowsAirPlay, setAllowsAirPlay] = useState(true); + + // Logic State + const isSeeking = useRef(false); + const isDragging = useRef(false); + const isMounted = useRef(true); + const seekDebounceTimer = useRef(null); + const pendingSeekValue = useRef(null); + const lastSeekTime = useRef(0); + const wasPlayingBeforeDragRef = useRef(false); + + // Helper for iPad/macOS fullscreen + const isIPad = Platform.OS === 'ios' && (screenData.width > 1000 || screenData.height > 1000); + const isMacOS = Platform.OS === 'ios' && Platform.isPad === true; + const shouldUseFullscreen = isIPad || isMacOS; + const windowData = Dimensions.get('window'); + const effectiveDimensions = shouldUseFullscreen ? windowData : screenDimensions; + + return { + paused, setPaused, + currentTime, setCurrentTime, + duration, setDuration, + buffered, setBuffered, + isBuffering, setIsBuffering, + isVideoLoaded, setIsVideoLoaded, + isPlayerReady, setIsPlayerReady, + showControls, setShowControls, + resizeMode, setResizeMode, + videoAspectRatio, setVideoAspectRatio, + is16by9Content, setIs16by9Content, + screenDimensions, setScreenDimensions, + zoomScale, setZoomScale, + zoomTranslateX, setZoomTranslateX, + zoomTranslateY, setZoomTranslateY, + lastZoomScale, setLastZoomScale, + lastTranslateX, setLastTranslateX, + lastTranslateY, setLastTranslateY, + isAirPlayActive, setIsAirPlayActive, + allowsAirPlay, setAllowsAirPlay, + isSeeking, + isDragging, + isMounted, + seekDebounceTimer, + pendingSeekValue, + lastSeekTime, + wasPlayingBeforeDragRef, + effectiveDimensions + }; +}; diff --git a/src/components/player/ios/hooks/usePlayerTracks.ts b/src/components/player/ios/hooks/usePlayerTracks.ts new file mode 100644 index 0000000..76f0d21 --- /dev/null +++ b/src/components/player/ios/hooks/usePlayerTracks.ts @@ -0,0 +1,38 @@ +import { useState, useMemo, useCallback } from 'react'; +import { AudioTrack, TextTrack } from '../../utils/playerTypes'; + +export const usePlayerTracks = () => { + const [audioTracks, setAudioTracks] = useState([]); + const [selectedAudioTrack, setSelectedAudioTrack] = useState(null); + const [textTracks, setTextTracks] = useState([]); + const [selectedTextTrack, setSelectedTextTrack] = useState(-1); + + const [ksAudioTracks, setKsAudioTracks] = useState>([]); + const [ksTextTracks, setKsTextTracks] = useState>([]); + + // Derived states or logic + const hasAudioTracks = audioTracks.length > 0; + const hasTextTracks = textTracks.length > 0; + + // Track selection functions + const selectAudioTrack = useCallback((trackId: number) => { + setSelectedAudioTrack(trackId); + }, []); + + const selectTextTrack = useCallback((trackId: number) => { + setSelectedTextTrack(trackId); + }, []); + + return { + audioTracks, setAudioTracks, + selectedAudioTrack, setSelectedAudioTrack, + textTracks, setTextTracks, + selectedTextTrack, setSelectedTextTrack, + ksAudioTracks, setKsAudioTracks, + ksTextTracks, setKsTextTracks, + hasAudioTracks, + hasTextTracks, + selectAudioTrack, + selectTextTrack + }; +}; diff --git a/src/components/player/ios/hooks/useSpeedControl.ts b/src/components/player/ios/hooks/useSpeedControl.ts new file mode 100644 index 0000000..d185f0e --- /dev/null +++ b/src/components/player/ios/hooks/useSpeedControl.ts @@ -0,0 +1,93 @@ +import { useState, useRef, useCallback, useEffect } from 'react'; +import { Animated } from 'react-native'; +import { mmkvStorage } from '../../../../services/mmkvStorage'; +import { logger } from '../../../../utils/logger'; + +const SPEED_SETTINGS_KEY = '@nuvio_speed_settings'; + +export const useSpeedControl = (initialSpeed: number = 1.0) => { + const [playbackSpeed, setPlaybackSpeed] = useState(initialSpeed); + const [holdToSpeedEnabled, setHoldToSpeedEnabled] = useState(true); + const [holdToSpeedValue, setHoldToSpeedValue] = useState(2.0); + const [isSpeedBoosted, setIsSpeedBoosted] = useState(false); + const [originalSpeed, setOriginalSpeed] = useState(initialSpeed); + const [showSpeedActivatedOverlay, setShowSpeedActivatedOverlay] = useState(false); + + const speedActivatedOverlayOpacity = useRef(new Animated.Value(0)).current; + + // Load Settings + useEffect(() => { + const loadSettings = 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 (e) { + logger.warn('[useSpeedControl] Error loading settings', e); + } + }; + loadSettings(); + }, []); + + // Save Settings + useEffect(() => { + const saveSettings = async () => { + try { + await mmkvStorage.setItem(SPEED_SETTINGS_KEY, JSON.stringify({ + holdToSpeedEnabled, + holdToSpeedValue + })); + } catch (e) { } + }; + saveSettings(); + }, [holdToSpeedEnabled, holdToSpeedValue]); + + const activateSpeedBoost = useCallback(() => { + if (!holdToSpeedEnabled || isSpeedBoosted || playbackSpeed === holdToSpeedValue) return; + + setOriginalSpeed(playbackSpeed); + setPlaybackSpeed(holdToSpeedValue); + setIsSpeedBoosted(true); + setShowSpeedActivatedOverlay(true); + + Animated.timing(speedActivatedOverlayOpacity, { + toValue: 1, + duration: 200, + useNativeDriver: true + }).start(); + + setTimeout(() => { + Animated.timing(speedActivatedOverlayOpacity, { + toValue: 0, + duration: 300, + useNativeDriver: true + }).start(() => setShowSpeedActivatedOverlay(false)); + }, 2000); + + }, [holdToSpeedEnabled, isSpeedBoosted, playbackSpeed, holdToSpeedValue]); + + const deactivateSpeedBoost = useCallback(() => { + if (isSpeedBoosted) { + setPlaybackSpeed(originalSpeed); + setIsSpeedBoosted(false); + Animated.timing(speedActivatedOverlayOpacity, { toValue: 0, duration: 100, useNativeDriver: true }).start(); + } + }, [isSpeedBoosted, originalSpeed]); + + return { + playbackSpeed, + setPlaybackSpeed, + holdToSpeedEnabled, + setHoldToSpeedEnabled, + holdToSpeedValue, + setHoldToSpeedValue, + isSpeedBoosted, + activateSpeedBoost, + deactivateSpeedBoost, + showSpeedActivatedOverlay, + speedActivatedOverlayOpacity + }; +}; diff --git a/src/components/player/ios/hooks/useWatchProgress.ts b/src/components/player/ios/hooks/useWatchProgress.ts new file mode 100644 index 0000000..fa61406 --- /dev/null +++ b/src/components/player/ios/hooks/useWatchProgress.ts @@ -0,0 +1,120 @@ +import { useState, useEffect, useRef } from 'react'; +import { storageService } from '../../../../services/storageService'; +import { logger } from '../../../../utils/logger'; +import { useSettings } from '../../../../hooks/useSettings'; + +export const useWatchProgress = ( + id: string | undefined, + type: string | undefined, + episodeId: string | undefined, + currentTime: number, + duration: number, + paused: boolean, + traktAutosync: any, + seekToTime: (time: number) => void +) => { + const [resumePosition, setResumePosition] = useState(null); + const [savedDuration, setSavedDuration] = useState(null); + const [initialPosition, setInitialPosition] = useState(null); + const [showResumeOverlay, setShowResumeOverlay] = useState(false); + const [progressSaveInterval, setProgressSaveInterval] = useState(null); + + const { settings: appSettings } = useSettings(); + const initialSeekTargetRef = useRef(null); + + // Values refs for unmount cleanup + const currentTimeRef = useRef(currentTime); + const durationRef = useRef(duration); + + useEffect(() => { + currentTimeRef.current = currentTime; + }, [currentTime]); + + useEffect(() => { + durationRef.current = duration; + }, [duration]); + + // Load Watch Progress + useEffect(() => { + const loadWatchProgress = async () => { + if (id && type) { + try { + const savedProgress = await storageService.getWatchProgress(id, type, episodeId); + if (savedProgress) { + const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; + + if (progressPercent < 85) { + setResumePosition(savedProgress.currentTime); + setSavedDuration(savedProgress.duration); + + if (appSettings.alwaysResume) { + setInitialPosition(savedProgress.currentTime); + initialSeekTargetRef.current = savedProgress.currentTime; + seekToTime(savedProgress.currentTime); + } else { + setShowResumeOverlay(true); + } + } + } + } catch (error) { + logger.error('[useWatchProgress] Error loading watch progress:', error); + } + } + }; + loadWatchProgress(); + }, [id, type, episodeId, appSettings.alwaysResume]); + + const saveWatchProgress = async () => { + if (id && type && currentTimeRef.current > 0 && durationRef.current > 0) { + const progress = { + currentTime: currentTimeRef.current, + duration: durationRef.current, + lastUpdated: Date.now() + }; + try { + await storageService.setWatchProgress(id, type, progress, episodeId); + await traktAutosync.handleProgressUpdate(currentTimeRef.current, durationRef.current); + } catch (error) { + logger.error('[useWatchProgress] Error saving watch progress:', error); + } + } + }; + + // Save Interval + useEffect(() => { + if (id && type && !paused && duration > 0) { + if (progressSaveInterval) clearInterval(progressSaveInterval); + + const interval = setInterval(() => { + saveWatchProgress(); + }, 10000); + + setProgressSaveInterval(interval); + return () => { + clearInterval(interval); + setProgressSaveInterval(null); + }; + } + }, [id, type, paused, currentTime, duration]); + + // Unmount Save + useEffect(() => { + return () => { + if (id && type && durationRef.current > 0) { + saveWatchProgress(); + traktAutosync.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'unmount'); + } + }; + }, [id, type]); + + return { + resumePosition, + savedDuration, + initialPosition, + setInitialPosition, + showResumeOverlay, + setShowResumeOverlay, + saveWatchProgress, + initialSeekTargetRef + }; +}; diff --git a/src/components/player/modals/AudioTrackModal.tsx b/src/components/player/modals/AudioTrackModal.tsx index cd37b69..eb30433 100644 --- a/src/components/player/modals/AudioTrackModal.tsx +++ b/src/components/player/modals/AudioTrackModal.tsx @@ -110,3 +110,5 @@ export const AudioTrackModal: React.FC = ({ ); }; + +export default AudioTrackModal; diff --git a/src/components/player/modals/EpisodeStreamsModal.tsx b/src/components/player/modals/EpisodeStreamsModal.tsx index e41722b..747f611 100644 --- a/src/components/player/modals/EpisodeStreamsModal.tsx +++ b/src/components/player/modals/EpisodeStreamsModal.tsx @@ -374,3 +374,5 @@ export const EpisodeStreamsModal: React.FC = ({ ); }; + +export default EpisodeStreamsModal; diff --git a/src/components/player/modals/EpisodesModal.tsx b/src/components/player/modals/EpisodesModal.tsx index 1f6a5f3..2fd8b58 100644 --- a/src/components/player/modals/EpisodesModal.tsx +++ b/src/components/player/modals/EpisodesModal.tsx @@ -170,3 +170,5 @@ export const EpisodesModal: React.FC = ({ ); }; + +export default EpisodesModal; diff --git a/src/components/player/modals/SourcesModal.tsx b/src/components/player/modals/SourcesModal.tsx index 6bfac5d..d40896d 100644 --- a/src/components/player/modals/SourcesModal.tsx +++ b/src/components/player/modals/SourcesModal.tsx @@ -276,4 +276,6 @@ export const SourcesModal: React.FC = ({ ); -}; \ No newline at end of file +}; + +export default SourcesModal; \ No newline at end of file diff --git a/src/components/player/utils/playerStyles.ts b/src/components/player/utils/playerStyles.ts index bd429ef..a470e67 100644 --- a/src/components/player/utils/playerStyles.ts +++ b/src/components/player/utils/playerStyles.ts @@ -260,32 +260,37 @@ export const styles = StyleSheet.create({ }, gestureIndicatorContainer: { position: 'absolute', - top: '40%', - left: '50%', - transform: [{ translateX: -75 }, { translateY: -40 }], - width: 150, - height: 80, - backgroundColor: 'rgba(0, 0, 0, 0.8)', - borderRadius: 16, - flexDirection: 'row', + top: 40, + alignSelf: 'center', + left: 0, + right: 0, alignItems: 'center', - paddingHorizontal: 16, justifyContent: 'center', zIndex: 2000, }, + gestureIndicatorPill: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.75)', + borderRadius: 24, + paddingVertical: 8, + paddingHorizontal: 14, + gap: 8, + }, iconWrapper: { - width: 40, - height: 40, - borderRadius: 20, + width: 28, + height: 28, + borderRadius: 14, alignItems: 'center', justifyContent: 'center', - marginRight: 10, - backgroundColor: 'rgba(255, 255, 255, 0.1)', + backgroundColor: 'rgba(255, 255, 255, 0.15)', }, gestureText: { color: '#FFFFFF', - fontSize: 24, + fontSize: 16, fontWeight: '600', + minWidth: 48, + textAlign: 'center', }, bottomControls: { diff --git a/src/components/player/utils/playerTypes.ts b/src/components/player/utils/playerTypes.ts index bbfb577..9413c59 100644 --- a/src/components/player/utils/playerTypes.ts +++ b/src/components/player/utils/playerTypes.ts @@ -61,9 +61,9 @@ export interface TextTrack { type?: string | null; // Adjusting type based on linter error } -// Define the possible resize modes - force to stretch for absolute full screen -export type ResizeModeType = 'contain' | 'cover' | 'none'; -export const resizeModes: ResizeModeType[] = ['cover']; // Force cover mode for absolute full screen +// Define the possible resize modes +export type ResizeModeType = 'contain' | 'cover' | 'stretch' | 'none'; +export const resizeModes: ResizeModeType[] = ['cover', 'contain', 'stretch']; // Add VLC specific interface for their event structure export interface VlcMediaEvent {