diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 93594606..627265ed 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -1,186 +1,112 @@ -import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; -import { View, TouchableOpacity, TouchableWithoutFeedback, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState, Image, InteractionManager } from 'react-native'; +import React, { useRef, useEffect, useMemo, useCallback, useState } from 'react'; +import { View, StyleSheet, Platform, Animated } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import Video, { VideoRef, SelectedTrack, SelectedTrackType, BufferingStrategyType, ViewType } from 'react-native-video'; -import FastImage from '@d11/react-native-fast-image'; -import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { RootStackParamList } 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'; + +// Hooks +import { usePlayerState } from './android/hooks/usePlayerState'; +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'; 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'; - -// Speed settings storage key -const SPEED_SETTINGS_KEY = '@nuvio_speed_settings'; -import { safeDebugLog, parseSRT, DEBUG_MODE, formatTime } from './utils/playerUtils'; -import { styles } from './utils/playerStyles'; -import { SubtitleModals } from './modals/SubtitleModals'; -import { AudioTrackModal } from './modals/AudioTrackModal'; +// 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'; import LoadingOverlay from './modals/LoadingOverlay'; -import SpeedModal from './modals/SpeedModal'; -// Removed ResumeOverlay usage when alwaysResume is enabled import PlayerControls from './controls/PlayerControls'; -import CustomSubtitles from './subtitles/CustomSubtitles'; +import { AudioTrackModal } from './modals/AudioTrackModal'; +import { SubtitleModals } from './modals/SubtitleModals'; +import SpeedModal from './modals/SpeedModal'; import { SourcesModal } from './modals/SourcesModal'; import { EpisodesModal } from './modals/EpisodesModal'; -import UpNextButton from './common/UpNextButton'; import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal'; -import VlcVideoPlayer, { VlcPlayerRef } from './VlcVideoPlayer'; -import { stremioService } from '../../services/stremioService'; -import { Episode } from '../../types/metadata'; -import { shouldUseKSPlayer } from '../../utils/playerSelection'; -import axios from 'axios'; -import * as Brightness from 'expo-brightness'; -// Do not statically import Android-only native modules; resolve at runtime on Android -// Map VLC resize modes to react-native-video resize modes -const getVideoResizeMode = (resizeMode: ResizeModeType) => { - switch (resizeMode) { - case 'contain': return 'contain'; - case 'cover': return 'cover'; - case 'none': return 'contain'; - default: return 'contain'; - } -}; +// 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; const AndroidVideoPlayer: React.FC = () => { const navigation = useNavigation(); - const insets = useSafeAreaInsets(); const route = useRoute>(); + const insets = useSafeAreaInsets(); const { - uri, - title = 'Episode Name', - season, - episode, - episodeTitle, - quality, - year, - streamProvider, - streamName, - headers, - id, - type, - episodeId, - imdbId, - availableStreams: passedAvailableStreams, - backdrop, - groupedEpisodes + uri, title = 'Episode Name', season, episode, episodeTitle, quality, year, + streamProvider, streamName, headers, id, type, episodeId, imdbId, + availableStreams: passedAvailableStreams, backdrop, groupedEpisodes } = route.params; - // Opt-in flag to use VLC backend + // --- State & Custom Hooks --- + + const playerState = usePlayerState(); + const modals = usePlayerModals(); + const speedControl = useSpeedControl(); + const forceVlc = useMemo(() => { const rp: any = route.params || {}; const v = rp.forceVlc !== undefined ? rp.forceVlc : rp.forceVLC; return typeof v === 'string' ? v.toLowerCase() === 'true' : Boolean(v); }, [route.params]); - // TEMP: force React Native Video for testing (disable VLC) - const TEMP_FORCE_RNV = false; - const TEMP_FORCE_VLC = false; - const useVLC = Platform.OS === 'android' && !TEMP_FORCE_RNV && (TEMP_FORCE_VLC || forceVlc); - // Log player selection - useEffect(() => { - const playerType = useVLC ? 'VLC (expo-libvlc-player)' : 'React Native Video'; - const reason = useVLC - ? (TEMP_FORCE_VLC ? 'TEMP_FORCE_VLC=true' : `forceVlc=${forceVlc} from route params`) - : (TEMP_FORCE_RNV ? 'TEMP_FORCE_RNV=true' : 'default react-native-video'); - logger.log(`[AndroidVideoPlayer] Player selection: ${playerType} (${reason})`); - }, [useVLC, forceVlc]); + const useVLC = (Platform.OS === 'android' && forceVlc); + const videoRef = useRef(null); + const vlcHook = useVlcPlayer(useVLC, playerState.paused, playerState.currentTime); + const tracksHook = usePlayerTracks( + useVLC, + vlcHook.vlcAudioTracks, + vlcHook.vlcSubtitleTracks, + vlcHook.vlcSelectedAudioTrack, + vlcHook.vlcSelectedSubtitleTrack + ); + const [currentStreamUrl, setCurrentStreamUrl] = useState(uri); + const [currentVideoType, setCurrentVideoType] = useState((route.params as any).videoType); + const processedStreamUrl = useMemo(() => useVLC ? processUrlForVLC(currentStreamUrl) : currentStreamUrl, [currentStreamUrl, useVLC]); - // Check if the stream is HLS (m3u8 playlist) - const isHlsStream = (url: string) => { - return url.includes('.m3u8') || url.includes('m3u8') || - url.includes('hls') || url.includes('playlist') || - (currentVideoType && currentVideoType.toLowerCase() === 'm3u8'); - }; + const [availableStreams, setAvailableStreams] = useState(passedAvailableStreams || {}); + const [currentQuality, setCurrentQuality] = useState(quality); + const [currentStreamProvider, setCurrentStreamProvider] = useState(streamProvider); + const [currentStreamName, setCurrentStreamName] = useState(streamName); - // HLS-specific headers for better ExoPlayer compatibility - const getHlsHeaders = () => { - return { - 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; SM-G975F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36', - 'Accept': 'application/vnd.apple.mpegurl, application/x-mpegurl, application/vnd.apple.mpegurl, video/mp2t, video/mp4, */*', - 'Accept-Language': 'en-US,en;q=0.9', - 'Accept-Encoding': 'identity', - 'Connection': 'keep-alive', - 'Cache-Control': 'no-cache', - 'Pragma': 'no-cache' - } as any; - }; + const metadataResult = useMetadata({ id: id || 'placeholder', type: (type as any) }); + const { metadata, cast } = Boolean(id && type) ? (metadataResult as any) : { metadata: null, cast: [] }; + const hasLogo = metadata && metadata.logo; + const openingAnimation = useOpeningAnimation(backdrop, metadata); - // Helper to get dynamic volume icon - 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 [volume, setVolume] = useState(1.0); + const [brightness, setBrightness] = useState(1.0); + const setupHook = usePlayerSetup(playerState.setScreenDimensions, setVolume, setBrightness, playerState.paused); - // Helper to get dynamic brightness icon - const getBrightnessIcon = (value: number) => { - if (value < 0.3) return 'brightness-low'; - if (value < 0.7) return 'brightness-medium'; - return 'brightness-high'; - }; + const controlsHook = usePlayerControls( + videoRef, + vlcHook.vlcPlayerRef, + useVLC, + playerState.paused, + playerState.setPaused, + playerState.currentTime, + playerState.duration, + playerState.isSeeking, + playerState.isMounted + ); - // Get appropriate headers based on stream type - const getStreamHeaders = () => { - // Use HLS headers for HLS streams, default headers for everything else - if (isHlsStream(currentStreamUrl)) { - logger.log('[AndroidVideoPlayer] Detected HLS stream, applying HLS headers'); - return getHlsHeaders(); - } - return Platform.OS === 'android' ? defaultAndroidHeaders() : defaultIosHeaders(); - }; - - // Optional hint not yet in typed navigator params - const videoType = (route.params as any).videoType as string | undefined; - - const defaultAndroidHeaders = () => { - if (Platform.OS !== 'android') return {} as any; - return { - 'User-Agent': 'ExoPlayerLib/2.19.1 (Linux;Android) Nuvio/1.0', - 'Accept': '*/*', - 'Connection': 'keep-alive', - } as any; - }; - - const defaultIosHeaders = () => { - if (Platform.OS !== 'ios') return {} as any; - return { - 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1 Nuvio/1.0', - 'Accept': '*/*', - 'Accept-Language': 'en-US,en;q=0.9', - 'Connection': 'keep-alive', - } as any; - }; - - // Initialize Trakt autosync const traktAutosync = useTraktAutosync({ id: id || '', type: type === 'series' ? 'series' : 'movie', @@ -195,412 +121,15 @@ const AndroidVideoPlayer: React.FC = () => { episodeId: episodeId }); - // Get the Trakt autosync settings to use the user-configured sync frequency - const { settings: traktSettings } = useTraktAutosyncSettings(); - - safeDebugLog("Android Component mounted with props", { - uri, title, season, episode, episodeTitle, quality, year, - streamProvider, id, type, episodeId, imdbId - }); - - const screenData = Dimensions.get('screen'); - const [screenDimensions, setScreenDimensions] = useState(screenData); - - 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({ type: SelectedTrackType.SYSTEM }); - const [textTracks, setTextTracks] = useState([]); - const [selectedTextTrack, setSelectedTextTrack] = useState(-1); - const [resizeMode, setResizeMode] = useState('contain'); - const speedOptions = [0.5, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0]; - const [playbackSpeed, setPlaybackSpeed] = useState(1.0); - const [buffered, setBuffered] = useState(0); - const [seekTime, setSeekTime] = useState(null); - const videoRef = useRef(null); - const [showAudioModal, setShowAudioModal] = useState(false); - const [showSubtitleModal, setShowSubtitleModal] = useState(false); - const [initialPosition, setInitialPosition] = useState(null); - const [progressSaveInterval, setProgressSaveInterval] = useState(null); - const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false); - const [showResumeOverlay, setShowResumeOverlay] = useState(false); - const [resumePosition, setResumePosition] = useState(null); - const [savedDuration, setSavedDuration] = useState(null); - const initialSeekTargetRef = useRef(null); - const initialSeekVerifiedRef = useRef(false); - const isSourceSeekableRef = useRef(null); - const fadeAnim = useRef(new Animated.Value(1)).current; - const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false); - const [shouldHideOpeningOverlay, setShouldHideOpeningOverlay] = useState(false); - const 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 [rnVideoAudioTracks, setRnVideoAudioTracks] = useState>([]); - const [rnVideoTextTracks, setRnVideoTextTracks] = useState>([]); - - // Speed boost state for hold-to-speed-up feature - const [isSpeedBoosted, setIsSpeedBoosted] = useState(false); - const [originalSpeed, setOriginalSpeed] = useState(1.0); - const [showSpeedActivatedOverlay, setShowSpeedActivatedOverlay] = useState(false); - const speedActivatedOverlayOpacity = useRef(new Animated.Value(0)).current; - - // Speed modal state - const [showSpeedModal, setShowSpeedModal] = useState(false); - const [holdToSpeedEnabled, setHoldToSpeedEnabled] = useState(true); - const [holdToSpeedValue, setHoldToSpeedValue] = useState(2.0); - - // 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('[AndroidVideoPlayer] 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('[AndroidVideoPlayer] Error saving speed settings:', error); - } - }, [holdToSpeedEnabled, holdToSpeedValue]); - - // Load speed settings on mount - useEffect(() => { - loadSpeedSettings(); - }, [loadSpeedSettings]); - - // Save speed settings when they change - useEffect(() => { - saveSpeedSettings(); - }, [saveSpeedSettings]); - - // Debounce track updates to prevent excessive processing - const trackUpdateTimeoutRef = useRef(null); - - // Debounce resize operations to prevent rapid successive clicks - const resizeTimeoutRef = useRef(null); - - - - - // Process URL for VLC compatibility - const processUrlForVLC = useCallback((url: string): string => { - if (!url || typeof url !== 'string') { - logger.warn('[AndroidVideoPlayer][VLC] Invalid URL provided:', url); - return url || ''; - } - - try { - // Check if URL is already properly formatted - const urlObj = new URL(url); - - // Handle special characters in the pathname that might cause issues - const pathname = urlObj.pathname; - const search = urlObj.search; - const hash = urlObj.hash; - - // Decode and re-encode the pathname to handle double-encoding - const decodedPathname = decodeURIComponent(pathname); - const encodedPathname = encodeURI(decodedPathname); - - // Reconstruct the URL - const processedUrl = `${urlObj.protocol}//${urlObj.host}${encodedPathname}${search}${hash}`; - - logger.log(`[AndroidVideoPlayer][VLC] URL processed: ${url} -> ${processedUrl}`); - return processedUrl; - } catch (error) { - logger.warn(`[AndroidVideoPlayer][VLC] URL processing failed, using original: ${error}`); - return url; - } - }, []); - - - // VLC track state - will be managed by VlcVideoPlayer component - const [vlcAudioTracks, setVlcAudioTracks] = useState>([]); - const [vlcSubtitleTracks, setVlcSubtitleTracks] = useState>([]); - const [vlcSelectedAudioTrack, setVlcSelectedAudioTrack] = useState(undefined); - const [vlcSelectedSubtitleTrack, setVlcSelectedSubtitleTrack] = useState(undefined); - const [vlcRestoreTime, setVlcRestoreTime] = useState(undefined); // Time to restore after remount - const [forceVlcRemount, setForceVlcRemount] = useState(false); // Force complete unmount/remount - - // VLC player ref for imperative methods - const vlcPlayerRef = useRef(null); - - // Track if VLC has loaded and needs initial play command - const vlcLoadedRef = useRef(false); - - // Handle VLC pause/play state changes - useEffect(() => { - if (useVLC && vlcLoadedRef.current && vlcPlayerRef.current) { - if (paused) { - vlcPlayerRef.current.pause(); - } else { - vlcPlayerRef.current.play(); - } - } - }, [useVLC, paused]); - - // Memoized computed props for child components - const ksAudioTracks = useMemo(() => - useVLC ? vlcAudioTracks : rnVideoAudioTracks, - [useVLC, vlcAudioTracks, rnVideoAudioTracks] + const watchProgress = useWatchProgress( + id, type, episodeId, + playerState.currentTime, + playerState.duration, + playerState.paused, + traktAutosync, + controlsHook.seekToTime ); - const computedSelectedAudioTrack = useMemo(() => - useVLC - ? (vlcSelectedAudioTrack ?? null) - : (selectedAudioTrack?.type === SelectedTrackType.INDEX && selectedAudioTrack.value !== undefined - ? Number(selectedAudioTrack.value) - : null), - [useVLC, vlcSelectedAudioTrack, selectedAudioTrack] - ); - - const ksTextTracks = useMemo(() => - useVLC ? vlcSubtitleTracks : rnVideoTextTracks, - [useVLC, vlcSubtitleTracks, rnVideoTextTracks] - ); - - const computedSelectedTextTrack = useMemo(() => - useVLC ? (vlcSelectedSubtitleTrack ?? -1) : selectedTextTrack, - [useVLC, vlcSelectedSubtitleTrack, selectedTextTrack] - ); - - // Clean up timeouts on unmount - useEffect(() => { - return () => { - if (trackUpdateTimeoutRef.current) { - clearTimeout(trackUpdateTimeoutRef.current); - trackUpdateTimeoutRef.current = null; - } - if (resizeTimeoutRef.current) { - clearTimeout(resizeTimeoutRef.current); - resizeTimeoutRef.current = null; - } - }; - }, []); - - // Reset forceVlcRemount when VLC becomes inactive - useEffect(() => { - if (!useVLC && forceVlcRemount) { - setForceVlcRemount(false); - } - }, [useVLC, forceVlcRemount]); - - // VLC track selection handlers - const selectVlcAudioTrack = useCallback((trackId: number | null) => { - setVlcSelectedAudioTrack(trackId ?? undefined); - logger.log('[AndroidVideoPlayer][VLC] Audio track selected:', trackId); - }, []); - - const selectVlcSubtitleTrack = useCallback((trackId: number | null) => { - setVlcSelectedSubtitleTrack(trackId ?? undefined); - logger.log('[AndroidVideoPlayer][VLC] Subtitle track selected:', trackId); - }, []); - - 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 [isVideoLoaded, setIsVideoLoaded] = useState(false); - const [videoAspectRatio, setVideoAspectRatio] = useState(null); - const [is16by9Content, setIs16by9Content] = useState(false); - - const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => { - return { - position: 'absolute', - top: 0, - left: 0, - width: screenWidth, - height: screenHeight, - }; - }; - - // Memoize expensive video style calculations - const videoStyles = useMemo(() => { - if (videoAspectRatio && screenDimensions.width > 0 && screenDimensions.height > 0) { - return calculateVideoStyles( - videoAspectRatio * 1000, - 1000, - screenDimensions.width, - screenDimensions.height - ); - } - return {}; - }, [videoAspectRatio, screenDimensions.width, screenDimensions.height]); - - // Memoize zoom factor calculations to prevent expensive recalculations - const zoomFactor = useMemo(() => { - // Zoom disabled - return 1; - }, [resizeMode, videoAspectRatio, screenDimensions.width, screenDimensions.height]); - 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 [customSubtitleVersion, setCustomSubtitleVersion] = useState(0); - const [subtitleSize, setSubtitleSize] = useState(DEFAULT_SUBTITLE_SIZE); - const [subtitleBackground, setSubtitleBackground] = useState(false); - // iOS seeking helpers - const iosWasPausedDuringSeekRef = useRef(null); - const wasPlayingBeforeDragRef = useRef(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 || {}); - const [currentStreamUrl, setCurrentStreamUrl] = useState(uri); - const [currentVideoType, setCurrentVideoType] = useState(videoType); - - // Memoized processed URL for VLC to prevent infinite loops - const processedStreamUrl = useMemo(() => { - return useVLC ? processUrlForVLC(currentStreamUrl) : currentStreamUrl; - }, [currentStreamUrl, useVLC, processUrlForVLC]); - // Track a single silent retry per source to avoid loops - const retryAttemptRef = useRef(0); - const [currentQuality, setCurrentQuality] = useState(quality); - const [currentStreamProvider, setCurrentStreamProvider] = useState(streamProvider); - const [currentStreamName, setCurrentStreamName] = useState(streamName); - const isMounted = useRef(true); - const isAppBackgrounded = useRef(false); // Track if app is backgrounded to prevent prop updates on detached views - const controlsTimeout = useRef(null); - const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false); - const [showErrorModal, setShowErrorModal] = useState(false); - const [errorDetails, setErrorDetails] = useState(''); - const errorTimeoutRef = useRef(null); - const vlcFallbackAttemptedRef = useRef(false); - - // VLC key for forcing remounts - const [vlcKey, setVlcKey] = useState('vlc-initial'); // Force remount key - - // Handler for VLC track updates - const handleVlcTracksUpdate = useCallback((tracks: { audio: any[], subtitle: any[] }) => { - if (!tracks) return; - - // Clear any pending updates - if (trackUpdateTimeoutRef.current) { - clearTimeout(trackUpdateTimeoutRef.current); - } - - // Debounce track updates to prevent excessive processing - trackUpdateTimeoutRef.current = setTimeout(() => { - const { audio = [], subtitle = [] } = tracks; - let hasUpdates = false; - - // Process audio tracks - if (Array.isArray(audio) && audio.length > 0) { - const formattedAudio = audio.map(track => ({ - id: track.id, - name: track.name || `Track ${track.id + 1}`, - language: track.language - })); - - // Simple comparison - check if tracks changed - const audioChanged = formattedAudio.length !== vlcAudioTracks.length || - formattedAudio.some((track, index) => { - const existing = vlcAudioTracks[index]; - return !existing || track.id !== existing.id || track.name !== existing.name; - }); - - if (audioChanged) { - setVlcAudioTracks(formattedAudio); - hasUpdates = true; - if (DEBUG_MODE) { - logger.log(`[VLC] Audio tracks updated:`, formattedAudio.length); - } - } - } - - // Process subtitle tracks - if (Array.isArray(subtitle) && subtitle.length > 0) { - const formattedSubs = subtitle.map(track => ({ - id: track.id, - name: track.name || `Track ${track.id + 1}`, - language: track.language - })); - - const subsChanged = formattedSubs.length !== vlcSubtitleTracks.length || - formattedSubs.some((track, index) => { - const existing = vlcSubtitleTracks[index]; - return !existing || track.id !== existing.id || track.name !== existing.name; - }); - - if (subsChanged) { - setVlcSubtitleTracks(formattedSubs); - hasUpdates = true; - if (DEBUG_MODE) { - logger.log(`[VLC] Subtitle tracks updated:`, formattedSubs.length); - } - } - } - - if (hasUpdates && DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer][VLC] Track processing complete. Audio: ${vlcAudioTracks.length}, Subs: ${vlcSubtitleTracks.length}`); - } - - trackUpdateTimeoutRef.current = null; - }, 100); // 100ms debounce - }, [vlcAudioTracks, vlcSubtitleTracks]); - - - // Volume and brightness controls - const [volume, setVolume] = useState(1.0); - const [brightness, setBrightness] = useState(1.0); - // Store Android system brightness state to restore on exit/unmount - const originalSystemBrightnessRef = useRef(null); - const originalSystemBrightnessModeRef = useRef(null); - const [subtitleSettingsLoaded, setSubtitleSettingsLoaded] = useState(false); - const lastVolumeChange = useRef(0); - const lastBrightnessChange = useRef(0); - - // Use reusable gesture controls hook const gestureControls = usePlayerGestureControls({ volume, setVolume, @@ -612,2484 +141,137 @@ const AndroidVideoPlayer: React.FC = () => { debugMode: DEBUG_MODE, }); - // iOS startup timing diagnostics + const nextEpisodeHook = useNextEpisode(type, season, episode, groupedEpisodes, (metadataResult as any)?.groupedEpisodes, episodeId); + + const fadeAnim = useRef(new Animated.Value(1)).current; + + useEffect(() => { + Animated.timing(fadeAnim, { + toValue: playerState.showControls ? 1 : 0, + duration: 300, + useNativeDriver: true + }).start(); + }, [playerState.showControls]); + + useEffect(() => { + openingAnimation.startOpeningAnimation(); + }, []); + + const handleLoad = useCallback((data: any) => { + if (!playerState.isMounted.current) return; + + const videoDuration = data.duration; + if (videoDuration > 0) { + playerState.setDuration(videoDuration); + if (id && type) { + storageService.setContentDuration(id, type, videoDuration, episodeId); + storageService.updateProgressDuration(id, type, videoDuration, episodeId); + } + } + + if (data.naturalSize) { + playerState.setVideoAspectRatio(data.naturalSize.width / data.naturalSize.height); + } else { + playerState.setVideoAspectRatio(16 / 9); + } + + if (!useVLC) { + if (data.audioTracks) { + const formatted = data.audioTracks.map((t: any, i: number) => ({ + id: t.index !== undefined ? t.index : i, + name: t.title || t.name || `Track ${i + 1}`, + language: t.language + })); + tracksHook.setRnVideoAudioTracks(formatted); + } + if (data.textTracks) { + const formatted = data.textTracks.map((t: any, i: number) => ({ + id: t.index !== undefined ? t.index : i, + name: t.title || t.name || `Track ${i + 1}`, + language: t.language + })); + tracksHook.setRnVideoTextTracks(formatted); + } + } + + playerState.setIsVideoLoaded(true); + openingAnimation.completeOpeningAnimation(); + + // Handle Resume + if (watchProgress.initialPosition && !watchProgress.showResumeOverlay) { + controlsHook.seekToTime(watchProgress.initialPosition); + } + }, [id, type, episodeId, useVLC, playerState.isMounted, watchProgress.initialPosition]); + + const handleProgress = useCallback((data: any) => { + if (playerState.isDragging.current || playerState.isSeeking.current || !playerState.isMounted.current || setupHook.isAppBackgrounded.current) return; + const currentTimeInSeconds = data.currentTime; + if (Math.abs(currentTimeInSeconds - playerState.currentTime) > 0.5) { + playerState.setCurrentTime(currentTimeInSeconds); + playerState.setBuffered(data.playableDuration || currentTimeInSeconds); + } + }, [playerState.currentTime, playerState.isDragging, playerState.isSeeking, setupHook.isAppBackgrounded]); + + const toggleControls = useCallback(() => { + playerState.setShowControls(prev => !prev); + }, []); + + const hideControls = useCallback(() => { + if (playerState.isDragging.current) return; + playerState.setShowControls(false); + }, []); + const loadStartAtRef = useRef(null); const firstFrameAtRef = useRef(null); + const controlsTimeout = useRef(null); - // iOS playback state tracking for system interruptions - const wasPlayingBeforeIOSInterruptionRef = useRef(false); - - // 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; - - // 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 as any) }); - const { settings: appSettings } = useSettings(); - const { metadata, loading: metadataLoading, groupedEpisodes: metadataGroupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => { } }; - - // 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; - - // 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('[AndroidVideoPlayer] 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]); - - // Resolve current episode description for series - const currentEpisodeDescription = useMemo(() => { - try { - if ((type as any) !== '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]); - - // Find next episode for series (use groupedEpisodes or fallback to metadataGroupedEpisodes) - const nextEpisode = useMemo(() => { - try { - if ((type as any) !== 'series' || !season || !episode) return null; - // Prefer groupedEpisodes from route, else metadataGroupedEpisodes - 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('[AndroidVideoPlayer] nextEpisode computation', { - fromRouteGroups: !!(groupedEpisodes && Object.keys(groupedEpisodes || {}).length), - fromMetadataGroups: !!(metadataGroupedEpisodes && Object.keys(metadataGroupedEpisodes || {}).length), - allEpisodesCount: allEpisodes?.length || 0, - currentSeason: season, - currentEpisode: episode, - found: !!nextEp, - foundId: nextEp?.stremioId || nextEp?.id, - foundName: nextEp?.name, - }); - } - return nextEp; - } catch { - return null; - } - }, [type, season, episode, groupedEpisodes, metadataGroupedEpisodes]); - - // Small offset (in seconds) used to avoid seeking to the *exact* end of the - // file which triggers the `onEnd` callback and causes playback to restart. - const END_EPSILON = 0.3; - - const hideControls = () => { - // Do not hide while user is interacting with the slider - if (isDragging) { - return; - } - Animated.timing(fadeAnim, { - toValue: 0, - duration: 300, - useNativeDriver: true, - }).start(() => setShowControls(false)); - }; - - - const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => { - // Zoom disabled - return; - }; - - const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => { - // Zoom disabled - return; - }; - - // Long press gesture handlers for speed boost - const onLongPressActivated = useCallback(() => { - if (!holdToSpeedEnabled) return; - - if (!isSpeedBoosted && playbackSpeed !== holdToSpeedValue) { - setOriginalSpeed(playbackSpeed); - setPlaybackSpeed(holdToSpeedValue); - setIsSpeedBoosted(true); - - // Show "Activated" overlay - setShowSpeedActivatedOverlay(true); - Animated.spring(speedActivatedOverlayOpacity, { - toValue: 1, - tension: 100, - friction: 8, - useNativeDriver: true, - }).start(); - - // Auto-hide after 2 seconds - setTimeout(() => { - Animated.timing(speedActivatedOverlayOpacity, { - toValue: 0, - duration: 300, - useNativeDriver: true, - }).start(() => { - setShowSpeedActivatedOverlay(false); - }); - }, 2000); - - logger.log(`[AndroidVideoPlayer] Speed boost activated: ${holdToSpeedValue}x`); - } - }, [isSpeedBoosted, playbackSpeed, holdToSpeedEnabled, holdToSpeedValue, speedActivatedOverlayOpacity]); - - const restoreSpeedSafely = useCallback(() => { - if (isSpeedBoosted) { - setPlaybackSpeed(originalSpeed); - setIsSpeedBoosted(false); - logger.log('[AndroidVideoPlayer] Speed boost deactivated, restored to:', originalSpeed); - } - }, [isSpeedBoosted, originalSpeed]); - - const onLongPressEnd = useCallback(() => { - restoreSpeedSafely(); - }, [restoreSpeedSafely]); - - const onLongPressStateChange = useCallback((event: LongPressGestureHandlerGestureEvent) => { - // Fallback: ensure we restore on cancel/fail transitions as well - // @ts-ignore - event.nativeEvent.state uses numeric State enum - const state = event?.nativeEvent?.state; - if (state === State.CANCELLED || state === State.FAILED || state === State.END) { - restoreSpeedSafely(); - } - }, [restoreSpeedSafely]); - - // Safety: if component unmounts while boosted, restore speed - useEffect(() => { - return () => { - if (isSpeedBoosted) { - // best-effort restoration on unmount - try { setPlaybackSpeed(originalSpeed); } catch { } - } - }; - }, [isSpeedBoosted, originalSpeed]); - - const resetZoom = () => { - const targetZoom = is16by9Content ? 1.1 : 1; - setZoomScale(targetZoom); - setLastZoomScale(targetZoom); - if (DEBUG_MODE) { - if (__DEV__) logger.log(`[AndroidVideoPlayer] Zoom reset to ${targetZoom}x (16:9: ${is16by9Content})`); - } - }; - - // Apply memoized calculations to state - useEffect(() => { - setCustomVideoStyles(videoStyles); - setZoomScale(zoomFactor); - - if (DEBUG_MODE && resizeMode === 'cover') { - logger.log(`[AndroidVideoPlayer] Cover zoom updated: ${zoomFactor.toFixed(2)}x (video AR: ${videoAspectRatio?.toFixed(2)})`); - } - }, [videoStyles, zoomFactor, resizeMode, videoAspectRatio]); - - useEffect(() => { - const subscription = Dimensions.addEventListener('change', ({ screen }) => { - setScreenDimensions(screen); - // Re-apply immersive mode on layout changes to keep system bars hidden - enableImmersiveMode(); - }); - - // Immediate player setup - UI critical - StatusBar.setHidden(true, 'none'); - enableImmersiveMode(); - startOpeningAnimation(); - - // Initialize volume immediately (no async) - setVolume(1.0); - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Initial volume: 1.0 (native)`); - } - - // Defer brightness initialization until after navigation animation completes - // This prevents sluggish player entry - const brightnessTask = InteractionManager.runAfterInteractions(async () => { - try { - // Capture Android system brightness and mode to restore later - if (Platform.OS === 'android') { - try { - const [sysBright, sysMode] = await Promise.all([ - (Brightness as any).getSystemBrightnessAsync?.(), - (Brightness as any).getSystemBrightnessModeAsync?.() - ]); - originalSystemBrightnessRef.current = typeof sysBright === 'number' ? sysBright : null; - originalSystemBrightnessModeRef.current = typeof sysMode === 'number' ? sysMode : null; - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Captured system brightness=${originalSystemBrightnessRef.current}, mode=${originalSystemBrightnessModeRef.current}`); - } - } catch (e) { - if (__DEV__) logger.warn('[AndroidVideoPlayer] Failed to capture system brightness state:', e); - } - } - const currentBrightness = await Brightness.getBrightnessAsync(); - setBrightness(currentBrightness); - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Initial brightness: ${currentBrightness}`); - } - } catch (error) { - logger.warn('[AndroidVideoPlayer] Error getting initial brightness:', error); - // Fallback to 1.0 if brightness API fails - setBrightness(1.0); - } - }); - - return () => { - subscription?.remove(); - brightnessTask.cancel(); - disableImmersiveMode(); - }; - }, []); - - // Re-apply immersive mode when screen gains focus - useFocusEffect( - useCallback(() => { - enableImmersiveMode(); - // Workaround for VLC surface detach: force complete remount VLC view on focus - if (useVLC) { - logger.log('[VLC] Forcing complete remount due to focus gain'); - setVlcRestoreTime(currentTime); // Save current time for restoration - setForceVlcRemount(true); - vlcLoadedRef.current = false; // Reset loaded state - // Re-enable after a brief moment - setTimeout(() => { - setForceVlcRemount(false); - setVlcKey(`vlc-focus-${Date.now()}`); - }, 100); - } - return () => { }; - }, [useVLC]) - ); - - // Re-apply immersive mode when app returns to foreground - useEffect(() => { - const onAppStateChange = (state: string) => { - if (state === 'active') { - isAppBackgrounded.current = false; - enableImmersiveMode(); - if (useVLC) { - // Force complete remount VLC view when app returns to foreground - logger.log('[VLC] Forcing complete remount due to app foreground'); - setVlcRestoreTime(currentTime); // Save current time for restoration - setForceVlcRemount(true); - vlcLoadedRef.current = false; // Reset loaded state - // Re-enable after a brief moment - setTimeout(() => { - setForceVlcRemount(false); - setVlcKey(`vlc-foreground-${Date.now()}`); - }, 100); - } - // On iOS, if we were playing before system interruption and the app becomes active again, - // ensure playback resumes (handles status bar pull-down case) - if (Platform.OS === 'ios' && wasPlayingBeforeIOSInterruptionRef.current && isPlayerReady) { - logger.log('[AndroidVideoPlayer] iOS app active - resuming playback after system interruption'); - // Small delay to allow system UI to settle - setTimeout(() => { - if (isMounted.current && wasPlayingBeforeIOSInterruptionRef.current) { - setPaused(false); // Resume playback - wasPlayingBeforeIOSInterruptionRef.current = false; // Reset flag - } - }, 300); // Slightly longer delay for iOS - } - } else if (state === 'background' || state === 'inactive') { - // Mark app as backgrounded to prevent prop updates on detached native views - isAppBackgrounded.current = true; - // On iOS, when app goes inactive (like status bar pull), track if we were playing - if (Platform.OS === 'ios') { - wasPlayingBeforeIOSInterruptionRef.current = !paused; - if (!paused) { - logger.log('[AndroidVideoPlayer] iOS app inactive - tracking playing state for resume'); - setPaused(true); - } - } - } - }; - const sub = AppState.addEventListener('change', onAppStateChange); - return () => { - sub.remove(); - }; - }, [paused, isPlayerReady]); - - 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 - }); - - // Fallback: ensure animation completes even if something goes wrong - setTimeout(() => { - if (!isOpeningAnimationComplete) { - if (__DEV__) logger.warn('[AndroidVideoPlayer] Opening animation fallback triggered'); - setIsOpeningAnimationComplete(true); - } - }, 1000); // 1 second fallback - }; - - useEffect(() => { - const loadWatchProgress = async () => { - if (id && type) { - try { - if (__DEV__) logger.log(`[AndroidVideoPlayer] Loading watch progress for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`); - const savedProgress = await storageService.getWatchProgress(id, type, episodeId); - if (__DEV__) logger.log(`[AndroidVideoPlayer] Saved progress:`, savedProgress); - - if (savedProgress) { - const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; - if (__DEV__) logger.log(`[AndroidVideoPlayer] Progress: ${progressPercent.toFixed(1)}% (${savedProgress.currentTime}/${savedProgress.duration})`); - - if (progressPercent < 85) { - setResumePosition(savedProgress.currentTime); - setSavedDuration(savedProgress.duration); - if (__DEV__) logger.log(`[AndroidVideoPlayer] 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(`[AndroidVideoPlayer] AlwaysResume enabled. Auto-seeking to ${savedProgress.currentTime}`); - seekToTime(savedProgress.currentTime); - } else { - // Do not set initialPosition; start from beginning with no auto-seek - setShowResumeOverlay(true); - if (__DEV__) logger.log(`[AndroidVideoPlayer] AlwaysResume disabled. Not auto-seeking; overlay shown (if enabled)`); - } - } else { - if (__DEV__) logger.log(`[AndroidVideoPlayer] Progress too high (${progressPercent.toFixed(1)}%), not showing resume overlay`); - } - } else { - if (__DEV__) logger.log(`[AndroidVideoPlayer] No saved progress found`); - } - } catch (error) { - logger.error('[AndroidVideoPlayer] Error loading watch progress:', error); - } - } else { - if (__DEV__) logger.log(`[AndroidVideoPlayer] 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('[AndroidVideoPlayer] Error saving watch progress:', error); - } - } - }; - - useEffect(() => { - if (id && type && !paused && duration > 0) { - if (progressSaveInterval) { - clearInterval(progressSaveInterval); - } - - // Sync interval for progress updates - increased from 5s to 10s to reduce overhead - const syncInterval = 10000; // 10 seconds for better performance - - const interval = setInterval(() => { - saveWatchProgress(); - }, syncInterval); - - setProgressSaveInterval(interval); - return () => { - clearInterval(interval); - setProgressSaveInterval(null); - }; - } - }, [id, type, paused, currentTime, 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 seekToTime = (rawSeconds: number) => { - // Clamp to just before the end of the media. - const timeInSeconds = Math.max(0, Math.min(rawSeconds, duration > 0 ? duration - END_EPSILON : rawSeconds)); - - if (useVLC) { - // Use VLC imperative method - if (vlcPlayerRef.current && duration > 0) { - if (DEBUG_MODE) { - if (__DEV__) logger.log(`[AndroidVideoPlayer][VLC] Seeking to ${timeInSeconds.toFixed(2)}s out of ${duration.toFixed(2)}s`); - } - vlcPlayerRef.current.seek(timeInSeconds); - } else { - if (DEBUG_MODE) { - logger.error(`[AndroidVideoPlayer][VLC] Seek failed: vlcRef=${!!vlcPlayerRef.current}, duration=${duration}`); - } - } - } else { - // Use react-native-video method - if (videoRef.current && duration > 0 && !isSeeking.current) { - if (DEBUG_MODE) { - if (__DEV__) logger.log(`[AndroidVideoPlayer] Seeking to ${timeInSeconds.toFixed(2)}s out of ${duration.toFixed(2)}s`); - } - - isSeeking.current = true; - setSeekTime(timeInSeconds); - if (Platform.OS === 'ios') { - iosWasPausedDuringSeekRef.current = paused; - if (!paused) setPaused(true); - } - - // Clear seek state handled in onSeek; keep a fallback timeout - setTimeout(() => { - if (isMounted.current && isSeeking.current) { - setSeekTime(null); - isSeeking.current = false; - if (DEBUG_MODE) logger.log('[AndroidVideoPlayer] Seek fallback timeout cleared seeking state'); - if (Platform.OS === 'ios' && iosWasPausedDuringSeekRef.current === false) { - setPaused(false); - iosWasPausedDuringSeekRef.current = null; - } - } - }, 1200); - } else { - if (DEBUG_MODE) { - logger.error(`[AndroidVideoPlayer] Seek failed: videoRef=${!!videoRef.current}, duration=${duration}, seeking=${isSeeking.current}`); - } - } - } - }; - - // Handle seeking when seekTime changes - useEffect(() => { - if (seekTime !== null && videoRef.current && duration > 0) { - // Use tolerance on iOS for more reliable seeks - if (Platform.OS === 'ios') { - try { - (videoRef.current as any).seek(seekTime, 1); - } catch { - videoRef.current.seek(seekTime); - } - } else { - videoRef.current.seek(seekTime); - } - } - }, [seekTime, duration]); - - const onSeek = (data: any) => { - if (DEBUG_MODE) logger.log('[AndroidVideoPlayer] onSeek', data); - if (isMounted.current) { - setSeekTime(null); - isSeeking.current = false; - - // IMMEDIATE SYNC: Update Trakt progress immediately after seeking - if (duration > 0 && data?.currentTime !== undefined) { - traktAutosync.handleProgressUpdate(data.currentTime, duration, true); // force=true for immediate sync - } - - // Resume playback on iOS if we paused for seeking - if (Platform.OS === 'ios') { - const shouldResume = wasPlayingBeforeDragRef.current || iosWasPausedDuringSeekRef.current === false || isDragging; - // Aggressively resume on iOS after seek if user was playing or this was a drag - if (shouldResume) { - logger.log('[AndroidVideoPlayer] onSeek: resuming after seek (iOS)'); - setPaused(false); - } else { - logger.log('[AndroidVideoPlayer] onSeek: staying paused (iOS)'); - } - // Reset flags - wasPlayingBeforeDragRef.current = false; - iosWasPausedDuringSeekRef.current = null; - } - } - }; - - // Slider callback functions for React Native Community Slider - const handleSliderValueChange = useCallback((value: number) => { - if (isDragging && duration > 0) { - const seekTime = Math.min(value, duration - END_EPSILON); - - pendingSeekValue.current = seekTime; - } - }, [isDragging, duration]); - - const handleSlidingStart = useCallback(() => { - setIsDragging(true); - // Keep controls visible while dragging and cancel any hide timeout - if (!showControls) setShowControls(true); - if (controlsTimeout.current) { - clearTimeout(controlsTimeout.current); - controlsTimeout.current = null; - } - // On iOS, pause during drag for more reliable seeks - if (Platform.OS === 'ios') { - wasPlayingBeforeDragRef.current = !paused; - if (!paused) setPaused(true); - logger.log('[AndroidVideoPlayer] handleSlidingStart: pausing for iOS drag'); - } - }, [showControls, paused]); - - const handleSlidingComplete = useCallback((value: number) => { - setIsDragging(false); - if (duration > 0) { - const seekTime = Math.min(value, duration - END_EPSILON); - seekToTime(seekTime); - pendingSeekValue.current = null; - // iOS safety: if the user was playing before drag, ensure resume shortly after seek - if (Platform.OS === 'ios' && wasPlayingBeforeDragRef.current) { - setTimeout(() => { - logger.log('[AndroidVideoPlayer] handleSlidingComplete: forcing resume after seek (iOS)'); - setPaused(false); - }, 60); - } - } - // Restart auto-hide timer after interaction finishes - if (controlsTimeout.current) { - clearTimeout(controlsTimeout.current); - } - // Ensure controls are visible, then schedule auto-hide - if (!showControls) setShowControls(true); - controlsTimeout.current = setTimeout(hideControls, 5000); - }, [duration, showControls]); - - // Ensure auto-hide resumes after drag ends - useEffect(() => { - if (!isDragging && showControls) { - if (controlsTimeout.current) { - clearTimeout(controlsTimeout.current); - } - controlsTimeout.current = setTimeout(hideControls, 5000); - } - }, [isDragging, showControls]); - - // Removed processProgressTouch - no longer needed with React Native Community Slider - - const handleProgress = (data: any) => { - // Prevent processing progress updates when component is unmounted or app is backgrounded - // This prevents Fabric from attempting to update props on detached native views - if (isDragging || isSeeking.current || !isMounted.current || isAppBackgrounded.current) return; - - const currentTimeInSeconds = data.currentTime; - - // Update time less frequently for better performance (increased threshold from 0.1s to 0.5s) - if (Math.abs(currentTimeInSeconds - currentTime) > 0.5) { - safeSetState(() => setCurrentTime(currentTimeInSeconds)); - // Removed progressAnim animation - no longer needed with React Native Community Slider - const bufferedTime = data.playableDuration || currentTimeInSeconds; - safeSetState(() => setBuffered(bufferedTime)); - } - - }; - - const onLoad = (data: any) => { - try { - if (DEBUG_MODE) { - logger.log('[AndroidVideoPlayer] Video loaded:', data); - } - if (!isMounted.current) { - logger.warn('[AndroidVideoPlayer] Component unmounted, skipping onLoad'); - return; - } - if (!data) { - logger.error('[AndroidVideoPlayer] onLoad called with null/undefined data'); - return; - } - const videoDuration = data.duration; - if (data.duration > 0) { - setDuration(videoDuration); - - // Store the actual duration for future reference and update existing progress - if (id && type) { - storageService.setContentDuration(id, type, videoDuration, episodeId); - storageService.updateProgressDuration(id, type, videoDuration, episodeId); - - // Update the saved duration for resume overlay if it was using an estimate - if (savedDuration && Math.abs(savedDuration - videoDuration) > 60) { - setSavedDuration(videoDuration); - } - } - } - - // Set aspect ratio from video dimensions - 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('[AndroidVideoPlayer] naturalSize not available, using default 16:9 aspect ratio'); - } - - // Handle audio tracks - if (data.audioTracks && data.audioTracks.length > 0) { - // Enhanced debug logging to see all available fields - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Raw audio tracks data:`, data.audioTracks); - data.audioTracks.forEach((track: any, idx: number) => { - logger.log(`[AndroidVideoPlayer] Track ${idx} raw data:`, { - index: track.index, - title: track.title, - language: track.language, - type: track.type, - channels: track.channels, - bitrate: track.bitrate, - codec: track.codec, - sampleRate: track.sampleRate, - name: track.name, - label: track.label, - allKeys: Object.keys(track), - fullTrackObject: track - }); - }); - } - - const formattedAudioTracks = data.audioTracks.map((track: any, index: number) => { - const trackIndex = track.index !== undefined ? track.index : index; - - // Build comprehensive track name from available fields - let trackName = ''; - const parts = []; - - // Add language if available (try multiple possible fields) - let language = track.language || track.lang || track.languageCode; - - // If no language field, try to extract from track name (e.g., "[Russian]", "[English]") - if ((!language || language === 'Unknown' || language === 'und' || language === '') && track.name) { - const languageMatch = track.name.match(/\[([^\]]+)\]/); - if (languageMatch && languageMatch[1]) { - language = languageMatch[1].trim(); - } - } - - if (language && language !== 'Unknown' && language !== 'und' && language !== '') { - parts.push(language.toUpperCase()); - } - - // Add codec information if available (try multiple possible fields) - const codec = track.type || track.codec || track.format; - if (codec && codec !== 'Unknown') { - parts.push(codec.toUpperCase()); - } - - // Add channel information if available - const channels = track.channels || track.channelCount; - if (channels && channels > 0) { - if (channels === 1) { - parts.push('MONO'); - } else if (channels === 2) { - parts.push('STEREO'); - } else if (channels === 6) { - parts.push('5.1CH'); - } else if (channels === 8) { - parts.push('7.1CH'); - } else { - parts.push(`${channels}CH`); - } - } - - // Add bitrate if available - const bitrate = track.bitrate || track.bitRate; - if (bitrate && bitrate > 0) { - parts.push(`${Math.round(bitrate / 1000)}kbps`); - } - - // Add sample rate if available - const sampleRate = track.sampleRate || track.sample_rate; - if (sampleRate && sampleRate > 0) { - parts.push(`${Math.round(sampleRate / 1000)}kHz`); - } - - // Add title if available and not generic - let title = track.title || track.name || track.label; - 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 || track.title || track.label; - if (simpleName && simpleName.match(/^(Track|Audio)\s*\d*$/i)) { - trackName = simpleName; - } else { - // Try to extract any meaningful info from the track object - const meaningfulFields: string[] = []; - Object.keys(track).forEach(key => { - const value = track[key]; - if (value && typeof value === 'string' && value !== 'Unknown' && value !== 'und' && value.length > 1) { - meaningfulFields.push(`${key}: ${value}`); - } - }); - - if (meaningfulFields.length > 0) { - trackName = `Audio ${index + 1} (${meaningfulFields.slice(0, 2).join(', ')})`; - } else { - trackName = `Audio ${index + 1}`; - } - } - } - - const trackLanguage = language || 'Unknown'; - - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Processed track ${index}:`, { - index: trackIndex, - name: trackName, - language: trackLanguage, - parts: parts, - meaningfulFields: Object.keys(track).filter(key => { - const value = track[key]; - return value && typeof value === 'string' && value !== 'Unknown' && value !== 'und' && value.length > 1; - }) - }); - } - - return { - id: trackIndex, // Use the actual track index from react-native-video - name: trackName, - language: trackLanguage, - }; - }); - setRnVideoAudioTracks(formattedAudioTracks); - - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Formatted audio tracks:`, formattedAudioTracks); - } - } - - // Handle text tracks - if (data.textTracks && data.textTracks.length > 0) { - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Raw text tracks data:`, data.textTracks); - data.textTracks.forEach((track: any, idx: number) => { - logger.log(`[AndroidVideoPlayer] Text Track ${idx} raw data:`, { - index: track.index, - title: track.title, - language: track.language, - type: track.type, - name: track.name, - label: track.label, - allKeys: Object.keys(track), - fullTrackObject: track - }); - }); - } - - const formattedTextTracks = data.textTracks.map((track: any, index: number) => { - const trackIndex = track.index !== undefined ? track.index : index; - - // Build comprehensive track name from available fields - let trackName = ''; - const parts = []; - - // Add language if available (try multiple possible fields) - let language = track.language || track.lang || track.languageCode; - - // If no language field, try to extract from track name (e.g., "[Russian]", "[English]") - if ((!language || language === 'Unknown' || language === 'und' || language === '') && track.title) { - const languageMatch = track.title.match(/\[([^\]]+)\]/); - if (languageMatch && languageMatch[1]) { - language = languageMatch[1].trim(); - } - } - - if (language && language !== 'Unknown' && language !== 'und' && language !== '') { - parts.push(language.toUpperCase()); - } - - // Add codec information if available (try multiple possible fields) - const codec = track.codec || track.format; - if (codec && codec !== 'Unknown' && codec !== 'und') { - parts.push(codec.toUpperCase()); - } - - // Add title if available and not generic - let title = track.title || track.name || track.label; - if (title && !title.match(/^(Subtitle|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", "Subtitle 1", etc., use them as-is - const simpleName = track.title || track.name || track.label; - if (simpleName && simpleName.match(/^(Track|Subtitle)\s*\d*$/i)) { - trackName = simpleName; - } else { - // Try to extract any meaningful info from the track object - const meaningfulFields: string[] = []; - Object.keys(track).forEach(key => { - const value = track[key]; - if (value && typeof value === 'string' && value !== 'Unknown' && value !== 'und' && value.length > 1) { - meaningfulFields.push(`${key}: ${value}`); - } - }); - - if (meaningfulFields.length > 0) { - trackName = meaningfulFields.join(' • '); - } else { - trackName = `Subtitle ${index + 1}`; - } - } - } - - return { - id: trackIndex, // Use the actual track index from react-native-video - name: trackName, - language: language, - }; - }); - setRnVideoTextTracks(formattedTextTracks); - - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Formatted text tracks:`, formattedTextTracks); - } - } - - setIsVideoLoaded(true); - setIsPlayerReady(true); - - - // Start Trakt watching session when video loads with proper duration - if (videoDuration > 0) { - traktAutosync.handlePlaybackStart(currentTime, videoDuration); - } - - // Complete opening animation immediately before seeking - completeOpeningAnimation(); - - if (initialPosition && !isInitialSeekComplete) { - logger.log(`[AndroidVideoPlayer] Seeking to initial position: ${initialPosition}s (duration: ${videoDuration}s)`); - // Reduced timeout from 1000ms to 500ms - setTimeout(() => { - if (videoRef.current && videoDuration > 0 && isMounted.current) { - seekToTime(initialPosition); - setIsInitialSeekComplete(true); - logger.log(`[AndroidVideoPlayer] Initial seek completed to: ${initialPosition}s`); - } else { - logger.error(`[AndroidVideoPlayer] Initial seek failed: videoRef=${!!videoRef.current}, 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('[AndroidVideoPlayer] Error in onLoad:', error); - // Set fallback values to prevent crashes - if (isMounted.current) { - setVideoAspectRatio(16 / 9); - setIsVideoLoaded(true); - setIsPlayerReady(true); - completeOpeningAnimation(); - } - } - }; - - const skip = useCallback((seconds: number) => { - const newTime = Math.max(0, Math.min(currentTime + seconds, duration - END_EPSILON)); - seekToTime(newTime); - }, [currentTime, duration]); - - const cycleAspectRatio = useCallback(() => { - // Prevent rapid successive resize operations - if (resizeTimeoutRef.current) { - if (DEBUG_MODE) { - logger.log('[AndroidVideoPlayer] Resize operation debounced - ignoring rapid click'); - } - return; - } - // Cycle through allowed resize modes per platform - // Android: exclude 'contain' for both VLC and RN Video (not well supported) - let resizeModes: ResizeModeType[]; - if (Platform.OS === 'ios') { - resizeModes = ['contain', 'cover']; - } else { - // On Android with VLC backend, only 'none' (original) and 'cover' (client-side crop) - resizeModes = useVLC ? ['none', 'cover'] : ['cover', 'none']; - } - - const currentIndex = resizeModes.indexOf(resizeMode); - const nextIndex = (currentIndex + 1) % resizeModes.length; - const newResizeMode = resizeModes[nextIndex]; - setResizeMode(newResizeMode); - - // Set zoom for cover mode to crop/fill screen - if (newResizeMode === 'cover') { - if (videoAspectRatio && screenDimensions.width && screenDimensions.height) { - const screenAspect = screenDimensions.width / screenDimensions.height; - const videoAspect = videoAspectRatio; - // Calculate zoom needed to fill screen (cover mode crops to fill) - const zoomFactor = Math.max(screenAspect / videoAspect, videoAspect / screenAspect); - setZoomScale(zoomFactor); - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Cover mode zoom: ${zoomFactor.toFixed(2)}x (screen: ${screenAspect.toFixed(2)}, video: ${videoAspect.toFixed(2)})`); - } - } else { - // Fallback if video aspect not available yet - will be set when video loads - setZoomScale(1.2); // Conservative zoom that works for most content - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Cover mode zoom fallback: 1.2x (video AR not available yet)`); - } - } - } else if (newResizeMode === 'none') { - // Reset zoom for none mode - setZoomScale(1); - } - - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Resize mode changed to: ${newResizeMode}`); - } - - // Debounce for 300ms to prevent rapid successive operations - resizeTimeoutRef.current = setTimeout(() => { - resizeTimeoutRef.current = null; - }, 300); - }, [resizeMode]); - - - - // Cycle playback speed - const cyclePlaybackSpeed = useCallback(() => { - const idx = speedOptions.indexOf(playbackSpeed); - const newIdx = (idx + 1) % speedOptions.length; - const newSpeed = speedOptions[newIdx]; - setPlaybackSpeed(newSpeed); - }, [playbackSpeed, speedOptions]); - - 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) { - logger.warn('[AndroidVideoPlayer] Immersive mode error:', error); - } - } - }; - - const disableImmersiveMode = () => { - StatusBar.setHidden(false); - if (Platform.OS === 'android') { - RNImmersiveMode.setBarMode('Normal'); - RNImmersiveMode.fullLayout(false); - } - }; - - const handleClose = useCallback(async () => { - // Prevent multiple close attempts - if (isSyncingBeforeClose) { - logger.log('[AndroidVideoPlayer] Close already in progress, ignoring duplicate call'); - return; - } - - logger.log('[AndroidVideoPlayer] 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(`[AndroidVideoPlayer] Current progress: ${actualCurrentTime}/${duration} (${progressPercent.toFixed(1)}%)`); - - // Restore Android system brightness state so app does not lock brightness - const restoreSystemBrightness = async () => { - if (Platform.OS !== 'android') return; - try { - // Restore mode first (if available), then brightness value - // Restore mode first (if available), then brightness value - if (typeof (Brightness as any).restoreSystemBrightnessAsync === 'function') { - await (Brightness as any).restoreSystemBrightnessAsync(); - } else { - // Fallback: verify we have permission before attempting to write to system settings - const { status } = await (Brightness as any).getPermissionsAsync(); - if (status === 'granted') { - if (originalSystemBrightnessModeRef.current !== null && typeof (Brightness as any).setSystemBrightnessModeAsync === 'function') { - await (Brightness as any).setSystemBrightnessModeAsync(originalSystemBrightnessModeRef.current); - } - if (originalSystemBrightnessRef.current !== null && typeof (Brightness as any).setSystemBrightnessAsync === 'function') { - await (Brightness as any).setSystemBrightnessAsync(originalSystemBrightnessRef.current); - } - } - } - if (DEBUG_MODE) { - logger.log('[AndroidVideoPlayer] Restored Android system brightness and mode'); - } - } catch (e) { - logger.warn('[AndroidVideoPlayer] Failed to restore system brightness state:', e); - } - }; - - // Don't await brightness restoration - do it in background - restoreSystemBrightness(); - - // Disable immersive mode immediately (synchronous) - disableImmersiveMode(); - - // Navigate IMMEDIATELY - don't wait for orientation changes - if ((navigation as any).canGoBack && (navigation as any).canGoBack()) { - (navigation as any).goBack(); - } else { - // Fallback to Streams if stack isn't present - (navigation as any).navigate('Streams', { id, type, episodeId, fromPlayer: true }); - } - - // Fire orientation changes in background - don't await - ScreenOrientation.unlockAsync() - .then(() => { - // On tablets keep rotation unlocked; on phones, return to portrait - const { width: dw, height: dh } = Dimensions.get('window'); - const isTablet = Math.min(dw, dh) >= 768 || ((Platform as any).isPad === true); - if (!isTablet) { - setTimeout(() => { - ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { }); - }, 50); - } else { - ScreenOrientation.unlockAsync().catch(() => { }); - } - }) - .catch(() => { - // Fallback: still try to restore portrait on phones - const { width: dw, height: dh } = Dimensions.get('window'); - const isTablet = Math.min(dw, dh) >= 768 || ((Platform as any).isPad === true); - if (!isTablet) { - setTimeout(() => { - ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { }); - }, 50); - } else { - ScreenOrientation.unlockAsync().catch(() => { }); - } - }); - - // Send Trakt sync in background (don't await) - const backgroundSync = async () => { - try { - logger.log('[AndroidVideoPlayer] 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('[AndroidVideoPlayer] Background Trakt sync completed successfully'); - } catch (error) { - logger.error('[AndroidVideoPlayer] Error in background Trakt sync:', error); - } - }; - - // Start background sync without blocking UI - backgroundSync(); - }, [isSyncingBeforeClose, currentTime, duration, traktAutosync, navigation, metadata, imdbId, backdrop]); - - 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 - enableImmersiveMode(); - return newShowControls; - }); - }; - - const handleError = (error: any) => { - try { - logger.error('AndroidVideoPlayer error: ', error); - - // Early return if component is unmounted to prevent iOS crashes - if (!isMounted.current) { - logger.warn('[AndroidVideoPlayer] Component unmounted, skipping error handling'); - return; - } - - // Check for codec errors that should trigger VLC fallback - const errorString = JSON.stringify(error || {}); - const isCodecError = errorString.includes('MediaCodecVideoRenderer error') || - errorString.includes('MediaCodecAudioRenderer error') || - errorString.includes('NO_EXCEEDS_CAPABILITIES') || - errorString.includes('NO_UNSUPPORTED_TYPE') || - errorString.includes('Decoder failed') || - errorString.includes('video/hevc') || - errorString.includes('audio/eac3') || - errorString.includes('ERROR_CODE_DECODING_FAILED') || - errorString.includes('ERROR_CODE_DECODER_INIT_FAILED'); - - // If it's a codec error and we're not already using VLC, silently switch to VLC - if (isCodecError && !useVLC && !vlcFallbackAttemptedRef.current) { - vlcFallbackAttemptedRef.current = true; - logger.warn('[AndroidVideoPlayer] Codec error detected, silently switching to VLC'); - // Clear any existing timeout - if (errorTimeoutRef.current) { - clearTimeout(errorTimeoutRef.current); - errorTimeoutRef.current = null; - } - safeSetState(() => setShowErrorModal(false)); - - // Switch to VLC silently - setTimeout(() => { - if (!isMounted.current) return; - // Force VLC by updating the route params - navigation.setParams({ forceVlc: true } as any); - }, 100); - return; // Do not proceed to show error UI - } - - // One-shot, silent retry without showing error UI - if (retryAttemptRef.current < 1) { - retryAttemptRef.current = 1; - // Cache-bust to force a fresh fetch and warm upstream - const addRetryParam = (url: string) => { - const sep = url.includes('?') ? '&' : '?'; - return `${url}${sep}rn_retry_ts=${Date.now()}`; - }; - const bustedUrl = addRetryParam(currentStreamUrl); - logger.warn('[AndroidVideoPlayer] Silent retry with cache-busted URL'); - // Ensure no modal is visible - if (errorTimeoutRef.current) { - clearTimeout(errorTimeoutRef.current); - errorTimeoutRef.current = null; - } - safeSetState(() => setShowErrorModal(false)); - // Brief pause to let the player reset - setPaused(true); - setTimeout(() => { - if (!isMounted.current) return; - setCurrentStreamUrl(bustedUrl); - setPaused(false); - }, 120); - return; // Do not proceed to show error UI - } - - // If format unrecognized, try different approaches for HLS streams - const isUnrecognized = !!(error?.error?.errorString && String(error.error.errorString).includes('UnrecognizedInputFormatException')); - if (isUnrecognized && retryAttemptRef.current < 1) { - retryAttemptRef.current = 1; - - // Check if this might be an HLS stream that needs different handling - const mightBeHls = currentStreamUrl.includes('.m3u8') || currentStreamUrl.includes('playlist') || - currentStreamUrl.includes('hls') || currentStreamUrl.includes('stream'); - - if (mightBeHls) { - logger.warn(`[AndroidVideoPlayer] HLS stream format not recognized. Retrying with explicit HLS type and headers`); - if (errorTimeoutRef.current) { - clearTimeout(errorTimeoutRef.current); - errorTimeoutRef.current = null; - } - safeSetState(() => setShowErrorModal(false)); - setPaused(true); - setTimeout(() => { - if (!isMounted.current) return; - // Force HLS type and add cache-busting - setCurrentVideoType('m3u8'); - const sep = currentStreamUrl.includes('?') ? '&' : '?'; - const retryUrl = `${currentStreamUrl}${sep}hls_retry=${Date.now()}`; - setCurrentStreamUrl(retryUrl); - setPaused(false); - }, 120); - return; - } else { - // For non-HLS streams, try flipping between HLS and MP4 - const nextType = currentVideoType === 'm3u8' ? 'mp4' : 'm3u8'; - logger.warn(`[AndroidVideoPlayer] Format not recognized. Retrying with type='${nextType}'`); - if (errorTimeoutRef.current) { - clearTimeout(errorTimeoutRef.current); - errorTimeoutRef.current = null; - } - safeSetState(() => setShowErrorModal(false)); - setPaused(true); - setTimeout(() => { - if (!isMounted.current) return; - setCurrentVideoType(nextType); - // Force re-mount of source by tweaking URL param - const sep = currentStreamUrl.includes('?') ? '&' : '?'; - const retryUrl = `${currentStreamUrl}${sep}rn_type_retry=${Date.now()}`; - setCurrentStreamUrl(retryUrl); - setPaused(false); - }, 120); - return; - } - } - - // Handle HLS manifest parsing errors (when content isn't actually M3U8) - const isManifestParseError = error?.error?.errorCode === '23002' || - error?.errorCode === '23002' || - (error?.error?.errorString && - error.error.errorString.includes('ERROR_CODE_PARSING_MANIFEST_MALFORMED')); - - if (isManifestParseError && retryAttemptRef.current < 2) { - retryAttemptRef.current = 2; - logger.warn('[AndroidVideoPlayer] HLS manifest parsing failed, likely not M3U8. Retrying as MP4'); - - if (errorTimeoutRef.current) { - clearTimeout(errorTimeoutRef.current); - errorTimeoutRef.current = null; - } - safeSetState(() => setShowErrorModal(false)); - setPaused(true); - setTimeout(() => { - if (!isMounted.current) return; - setCurrentVideoType('mp4'); - // Force re-mount of source by tweaking URL param - const sep = currentStreamUrl.includes('?') ? '&' : '?'; - const retryUrl = `${currentStreamUrl}${sep}manifest_fix_retry=${Date.now()}`; - setCurrentStreamUrl(retryUrl); - setPaused(false); - }, 120); - return; - } - - // Check for specific AVFoundation server configuration errors (iOS) - const isServerConfigError = error?.error?.code === -11850 || - error?.code === -11850 || - (error?.error?.localizedDescription && - error.error.localizedDescription.includes('server is not correctly configured')); - - // Format error details for user display - let errorMessage = 'An unknown error occurred'; - if (error) { - if (isServerConfigError) { - errorMessage = 'Stream server configuration issue. This may be a temporary problem with the 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.error && error.error.localizedDescription) { - errorMessage = error.error.localizedDescription; - } else if (error.code) { - errorMessage = `Error Code: ${error.code}`; - } else { - try { - errorMessage = JSON.stringify(error, null, 2); - } catch (jsonError) { - errorMessage = 'Error occurred but details could not be serialized'; - } - } - } - - // Use safeSetState to prevent crashes on iOS when component is unmounted - safeSetState(() => { - setErrorDetails(errorMessage); - setShowErrorModal(true); - }); - - // Clear any existing timeout - if (errorTimeoutRef.current) { - clearTimeout(errorTimeoutRef.current); - } - - // Auto-exit only when a modal is actually visible - if (showErrorModal) { - errorTimeoutRef.current = setTimeout(() => { - if (isMounted.current) { - handleErrorExit(); - } - }, 5000); - } - } catch (handlerError) { - // Fallback error handling to prevent crashes during error processing - logger.error('[AndroidVideoPlayer] Error in error handler:', handlerError); - if (isMounted.current) { - // Minimal safe error handling - safeSetState(() => { - setErrorDetails('A critical error occurred'); - setShowErrorModal(true); - }); - // Force exit after 3 seconds if error handler itself fails - setTimeout(() => { - if (isMounted.current) { - handleClose(); - } - }, 3000); - } - } - }; - - // Enhanced screen lock prevention - keep screen awake as soon as player mounts - const keepAwakeModuleRef = useRef(null); - const keepAwakeActiveRef = useRef(false); - - useEffect(() => { - try { - // Use require to avoid TS dynamic import constraints - // If the module is unavailable, catch and ignore - // eslint-disable-next-line @typescript-eslint/no-var-requires - const mod = require('expo-keep-awake'); - keepAwakeModuleRef.current = mod; - } catch (_e) { - keepAwakeModuleRef.current = null; - } - }, []); - - // Activate keep-awake immediately when player mounts and keep it active - useEffect(() => { - const mod = keepAwakeModuleRef.current; - if (!mod) return; - - const activate = mod.activateKeepAwakeAsync || mod.activateKeepAwake; - const deactivate = mod.deactivateKeepAwakeAsync || mod.deactivateKeepAwake; - - // Activate immediately when component mounts - try { - if (activate && !keepAwakeActiveRef.current) { - activate(); - keepAwakeActiveRef.current = true; - logger.log('[AndroidVideoPlayer] Screen lock prevention activated on mount'); - } - } catch (error) { - logger.warn('[AndroidVideoPlayer] Failed to activate keep-awake:', error); - } - - // Keep it active throughout the entire player session - const keepAliveInterval = setInterval(() => { - try { - if (activate && !keepAwakeActiveRef.current) { - activate(); - keepAwakeActiveRef.current = true; - } - } catch (error) { - logger.warn('[AndroidVideoPlayer] Failed to maintain keep-awake:', error); - } - }, 10000); // Reduced frequency from 5s to 10s to reduce overhead - - return () => { - clearInterval(keepAliveInterval); - try { - if (deactivate && keepAwakeActiveRef.current) { - deactivate(); - keepAwakeActiveRef.current = false; - logger.log('[AndroidVideoPlayer] Screen lock prevention deactivated on unmount'); - } - } catch (error) { - logger.warn('[AndroidVideoPlayer] Failed to deactivate keep-awake:', error); - } - }; - }, []); // Empty dependency array - only run on mount/unmount - - // Additional keep-awake activation on app state changes - useEffect(() => { - const mod = keepAwakeModuleRef.current; - if (!mod) return; - - const activate = mod.activateKeepAwakeAsync || mod.activateKeepAwake; - - const handleAppStateChange = (nextAppState: string) => { - if (nextAppState === 'active') { - try { - if (activate && !keepAwakeActiveRef.current) { - activate(); - keepAwakeActiveRef.current = true; - logger.log('[AndroidVideoPlayer] Screen lock prevention re-activated on app foreground'); - } - } catch (error) { - logger.warn('[AndroidVideoPlayer] Failed to re-activate keep-awake on app foreground:', error); - } - } - }; - - const subscription = AppState.addEventListener('change', handleAppStateChange); - return () => subscription?.remove(); - }, []); - - const handleErrorExit = () => { - try { - // Early return if component is unmounted - if (!isMounted.current) { - logger.warn('[AndroidVideoPlayer] Component unmounted, skipping error exit'); - return; - } - - if (errorTimeoutRef.current) { - clearTimeout(errorTimeoutRef.current); - errorTimeoutRef.current = null; - } - - // Use safeSetState to prevent crashes on iOS when component is unmounted - safeSetState(() => { - setShowErrorModal(false); - }); - - // Add small delay before closing to ensure modal state is updated - setTimeout(() => { - if (isMounted.current) { - handleClose(); - } - }, 100); - } catch (exitError) { - logger.error('[AndroidVideoPlayer] Error in handleErrorExit:', exitError); - // Force close as last resort - if (isMounted.current) { - handleClose(); - } - } - }; - - const onBuffer = (data: any) => { - setIsBuffering(data.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('[AndroidVideoPlayer] 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('[AndroidVideoPlayer] Sending final stop call after natural end'); - await traktAutosync.handlePlaybackEnd(finalTime, duration, 'ended'); - - logger.log('[AndroidVideoPlayer] Completed video end sync to Trakt'); - } catch (error) { - logger.error('[AndroidVideoPlayer] Error syncing to Trakt on video end:', error); - } - }; - - const selectAudioTrack = (trackSelection: SelectedTrack) => { - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Selecting audio track:`, trackSelection); - logger.log(`[AndroidVideoPlayer] Available tracks:`, rnVideoAudioTracks); - } - - // Validate track selection - if (trackSelection.type === SelectedTrackType.INDEX) { - const trackExists = rnVideoAudioTracks.some(track => track.id === trackSelection.value); - if (!trackExists) { - logger.error(`[AndroidVideoPlayer] Audio track ${trackSelection.value} not found in available tracks`); - return; - } - - } - - // If changing tracks, briefly pause to allow smooth transition - const wasPlaying = !paused; - if (wasPlaying) { - setPaused(true); - } - - // Set the new audio track - setSelectedAudioTrack(trackSelection); - - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Audio track changed to:`, trackSelection); - } - - // Resume playback after a brief delay if it was playing - if (wasPlaying) { - setTimeout(() => { - if (isMounted.current) { - setPaused(false); - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Resumed playback after audio track change`); - } - } - }, 300); - } - }; - - // Wrapper function to convert number to SelectedTrack for modal usage - const selectAudioTrackById = useCallback((trackId: number) => { - if (useVLC) { - // For VLC, directly set the selected track - selectVlcAudioTrack(trackId); - } else { - // For RN Video, use the existing track selection system - const trackSelection: SelectedTrack = { type: SelectedTrackType.INDEX, value: trackId }; - selectAudioTrack(trackSelection); - } - }, [useVLC, selectVlcAudioTrack, selectAudioTrack]); - - const selectTextTrack = useCallback((trackId: number) => { - if (useVLC) { - // For VLC, directly set the selected subtitle track and disable custom subtitles - if (trackId === -999) { - // Custom subtitles selected - disable embedded subtitles - setUseCustomSubtitles(true); - setSelectedTextTrack(-1); - selectVlcSubtitleTrack(null); // Disable embedded subtitles - } else { - // Embedded subtitle selected - disable custom subtitles - setUseCustomSubtitles(false); - setSelectedTextTrack(trackId); - selectVlcSubtitleTrack(trackId >= 0 ? trackId : null); - } - } else { - // For RN Video, use existing subtitle selection logic - if (trackId === -999) { - setUseCustomSubtitles(true); - setSelectedTextTrack(-1); - } else { - setUseCustomSubtitles(false); - setSelectedTextTrack(trackId); - } - } - }, [useVLC, selectVlcSubtitleTrack]); - - // Automatically disable VLC internal subtitles when external subtitles are enabled - useEffect(() => { - if (useVLC && useCustomSubtitles) { - logger.log('[AndroidVideoPlayer][VLC] External subtitles enabled, disabling internal subtitles'); - selectVlcSubtitleTrack(null); - } - }, [useVLC, useCustomSubtitles, selectVlcSubtitleTrack]); - - const disableCustomSubtitles = useCallback(() => { - setUseCustomSubtitles(false); - setCustomSubtitles([]); - // Reset to first available built-in track or disable all tracks - if (useVLC) { - selectVlcSubtitleTrack(ksTextTracks.length > 0 ? 0 : null); - } - setSelectedTextTrack(ksTextTracks.length > 0 ? 0 : -1); - }, [useVLC, selectVlcSubtitleTrack, ksTextTracks.length]); - - 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('[AndroidVideoPlayer] 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('[AndroidVideoPlayer] Error saving subtitle size:', error); - } - }; - - const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = true) => { - const targetImdbId = imdbIdParam || imdbId; - if (!targetImdbId) { - logger.error('[AndroidVideoPlayer] No IMDb ID available for subtitle search'); - return; - } - setIsLoadingSubtitleList(true); - try { - // Fetch from all installed subtitle-capable addons via Stremio - const stremioType = type === 'series' ? 'series' : 'movie'; - const stremioVideoId = stremioType === 'series' && season && episode - ? `series:${targetImdbId}:${season}:${episode}` - : undefined; - const stremioResults = await stremioService.getSubtitles(stremioType, targetImdbId, stremioVideoId); - const stremioSubs: WyzieSubtitle[] = (stremioResults || []).map(sub => ({ - id: sub.id || `${sub.lang}-${sub.url}`, - url: sub.url, - flagUrl: '', - format: 'srt', - encoding: 'utf-8', - media: sub.addonName || sub.addon || '', - display: sub.lang || 'Unknown', - language: (sub.lang || '').toLowerCase(), - isHearingImpaired: false, - source: sub.addonName || sub.addon || 'Addon', - })); - // Sort with English languages first, then alphabetical over full list - const isEnglish = (s: WyzieSubtitle) => { - const lang = (s.language || '').toLowerCase(); - const disp = (s.display || '').toLowerCase(); - return lang === 'en' || lang === 'eng' || /^en([-_]|$)/.test(lang) || disp.includes('english'); - }; - stremioSubs.sort((a, b) => { - const aIsEn = isEnglish(a); - const bIsEn = isEnglish(b); - if (aIsEn && !bIsEn) return -1; - if (!aIsEn && bIsEn) return 1; - return (a.display || '').localeCompare(b.display || ''); - }); - setAvailableSubtitles(stremioSubs); - if (autoSelectEnglish) { - const englishSubtitle = stremioSubs.find(sub => - sub.language.toLowerCase() === 'eng' || - sub.language.toLowerCase() === 'en' || - sub.display.toLowerCase().includes('english') - ); - if (englishSubtitle) { - loadWyzieSubtitle(englishSubtitle); - return; - } - } - if (!autoSelectEnglish) { - // If no English found and not auto-selecting, still open the modal - setShowSubtitleLanguageModal(true); - } - } catch (error) { - logger.error('[AndroidVideoPlayer] Error fetching subtitles from OpenSubtitles addon:', error); - } finally { - setIsLoadingSubtitleList(false); - } - }; - - const loadWyzieSubtitle = async (subtitle: WyzieSubtitle) => { - logger.log(`[AndroidVideoPlayer] Subtitle click received: id=${subtitle.id}, lang=${subtitle.language}, url=${subtitle.url}`); - setShowSubtitleLanguageModal(false); - logger.log('[AndroidVideoPlayer] setShowSubtitleLanguageModal(false)'); - setIsLoadingSubtitles(true); - logger.log('[AndroidVideoPlayer] isLoadingSubtitles -> true'); - try { - logger.log('[AndroidVideoPlayer] 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('[AndroidVideoPlayer] Axios subtitle fetch failed, falling back to fetch()', { - message: axiosErr?.message, - code: axiosErr?.code - }); - // Fallback with explicit timeout using AbortController - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - try { - const resp = await fetch(subtitle.url, { signal: controller.signal }); - srtContent = await resp.text(); - } finally { - clearTimeout(timeoutId); - } - } - logger.log(`[AndroidVideoPlayer] Fetching subtitle SRT done, size=${srtContent.length}`); - const parsedCues = parseSRT(srtContent); - logger.log(`[AndroidVideoPlayer] Parsed cues count=${parsedCues.length}`); - - // iOS AVPlayer workaround: clear subtitle state first, then apply - if (Platform.OS === 'ios') { - logger.log('[AndroidVideoPlayer] iOS detected; clearing subtitle state before apply'); - // Immediately stop spinner so UI doesn't get stuck - setIsLoadingSubtitles(false); - logger.log('[AndroidVideoPlayer] isLoadingSubtitles -> false (early stop for iOS)'); - // Step 1: Clear any existing subtitle state - setUseCustomSubtitles(false); - logger.log('[AndroidVideoPlayer] useCustomSubtitles -> false'); - setCustomSubtitles([]); - logger.log('[AndroidVideoPlayer] customSubtitles -> []'); - setSelectedTextTrack(-1); - logger.log('[AndroidVideoPlayer] selectedTextTrack -> -1'); - - // Step 2: Apply immediately (no scheduling), then do a small micro-nudge - logger.log('[AndroidVideoPlayer] Applying parsed cues immediately (iOS)'); - setCustomSubtitles(parsedCues); - logger.log('[AndroidVideoPlayer] customSubtitles <- parsedCues'); - setUseCustomSubtitles(true); - logger.log('[AndroidVideoPlayer] useCustomSubtitles -> true'); - setSelectedTextTrack(-1); - logger.log('[AndroidVideoPlayer] selectedTextTrack -> -1 (disable native while using custom)'); - setCustomSubtitleVersion(v => v + 1); - logger.log('[AndroidVideoPlayer] customSubtitleVersion incremented'); - - // Immediately set current subtitle based on currentTime to avoid waiting for next onProgress - 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('[AndroidVideoPlayer] currentSubtitle set immediately after apply (iOS)'); - } catch (e) { - logger.error('[AndroidVideoPlayer] Error setting immediate subtitle', e); - } - - // Removed micro-seek nudge on iOS - } else { - // Android works immediately - setCustomSubtitles(parsedCues); - logger.log('[AndroidVideoPlayer] (Android) customSubtitles <- parsedCues'); - setUseCustomSubtitles(true); - logger.log('[AndroidVideoPlayer] (Android) useCustomSubtitles -> true'); - setSelectedTextTrack(-1); - logger.log('[AndroidVideoPlayer] (Android) selectedTextTrack -> -1'); - setIsLoadingSubtitles(false); - logger.log('[AndroidVideoPlayer] (Android) isLoadingSubtitles -> false'); - 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('[AndroidVideoPlayer] currentSubtitle set immediately after apply (Android)'); - } catch { } - } - } catch (error) { - logger.error('[AndroidVideoPlayer] Error loading Wyzie subtitle:', error); - setIsLoadingSubtitles(false); - logger.log('[AndroidVideoPlayer] isLoadingSubtitles -> false (error path)'); - } - }; - - const togglePlayback = useCallback(() => { - const newPausedState = !paused; - setPaused(newPausedState); - - if (duration > 0) { - traktAutosync.handleProgressUpdate(currentTime, duration, true); - } - }, [paused, currentTime, duration, traktAutosync]); - - // Handle next episode button press - const handlePlayNextEpisode = useCallback(async () => { - if (!nextEpisode || !id || isLoadingNextEpisode) return; - - setIsLoadingNextEpisode(true); - - try { - logger.log('[AndroidVideoPlayer] 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('[AndroidVideoPlayer] 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('[AndroidVideoPlayer] 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 as any).replace('PlayerAndroid', { - 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, - forceVlc: false, - 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('[AndroidVideoPlayer] No streams found for next episode after checking all providers'); - setIsLoadingNextEpisode(false); - } - }); - - // Fallback timeout in case providers don't respond - setTimeout(() => { - if (!streamFound) { - logger.warn('[AndroidVideoPlayer] Timeout: No streams found for next episode'); - setIsLoadingNextEpisode(false); - } - }, 8000); - - } catch (error) { - logger.error('[AndroidVideoPlayer] Error loading next episode:', error); - setIsLoadingNextEpisode(false); - } - }, [nextEpisode, id, isLoadingNextEpisode, navigation, metadata, imdbId, backdrop]); - - // Function to hide pause overlay and show controls - const hidePauseOverlay = useCallback(() => { - if (showPauseOverlay) { - // Reset cast details state when hiding overlay - if (showCastDetails) { - Animated.parallel([ - Animated.timing(castDetailsOpacity, { - toValue: 0, - duration: 200, - useNativeDriver: true, - }), - Animated.timing(castDetailsScale, { - toValue: 0.95, - duration: 200, - useNativeDriver: true, - }) - ]).start(() => { - setShowCastDetails(false); - setSelectedCastMember(null); - // Reset metadata animations - metadataOpacity.setValue(1); - metadataScale.setValue(1); - }); - } else { - setShowCastDetails(false); - setSelectedCastMember(null); - // Reset metadata animations - metadataOpacity.setValue(1); - metadataScale.setValue(1); - } - - Animated.parallel([ - Animated.timing(pauseOverlayOpacity, { - toValue: 0, - duration: 220, - useNativeDriver: true, - }), - Animated.timing(pauseOverlayTranslateY, { - toValue: 8, - duration: 220, - useNativeDriver: true, - }) - ]).start(() => setShowPauseOverlay(false)); - - // Show controls when overlay is touched - if (!showControls) { - setShowControls(true); - Animated.timing(fadeAnim, { - toValue: 1, - duration: 300, - useNativeDriver: true, - }).start(); - - // Auto-hide controls after 5 seconds - if (controlsTimeout.current) { - clearTimeout(controlsTimeout.current); - } - controlsTimeout.current = setTimeout(hideControls, 5000); - } - } - }, [showPauseOverlay, pauseOverlayOpacity, pauseOverlayTranslateY, showControls, fadeAnim, controlsTimeout, hideControls]); - - // Handle paused overlay after 5 seconds of being paused - useEffect(() => { - if (paused) { - if (pauseOverlayTimerRef.current) { - clearTimeout(pauseOverlayTimerRef.current); - } - pauseOverlayTimerRef.current = setTimeout(() => { - setShowPauseOverlay(true); - pauseOverlayOpacity.setValue(0); - pauseOverlayTranslateY.setValue(12); - Animated.parallel([ - Animated.timing(pauseOverlayOpacity, { - toValue: 1, - duration: 550, - useNativeDriver: true, - }), - Animated.timing(pauseOverlayTranslateY, { - toValue: 0, - duration: 450, - useNativeDriver: true, - }) - ]).start(); - }, 5000); - } else { - if (pauseOverlayTimerRef.current) { - clearTimeout(pauseOverlayTimerRef.current); - pauseOverlayTimerRef.current = null; - } - hidePauseOverlay(); - } - return () => { - if (pauseOverlayTimerRef.current) { - clearTimeout(pauseOverlayTimerRef.current); - pauseOverlayTimerRef.current = null; - } - }; - }, [paused]); - - // Up Next visibility handled inside reusable component - - useEffect(() => { - isMounted.current = true; - isAppBackgrounded.current = false; - return () => { - isMounted.current = false; - isAppBackgrounded.current = false; - // Clear all timers and intervals - if (seekDebounceTimer.current) { - clearTimeout(seekDebounceTimer.current); - seekDebounceTimer.current = null; - } - if (errorTimeoutRef.current) { - clearTimeout(errorTimeoutRef.current); - errorTimeoutRef.current = null; - } - if (controlsTimeout.current) { - clearTimeout(controlsTimeout.current); - controlsTimeout.current = null; - } - if (pauseOverlayTimerRef.current) { - clearTimeout(pauseOverlayTimerRef.current); - pauseOverlayTimerRef.current = null; - } - if (progressSaveInterval) { - clearInterval(progressSaveInterval); - setProgressSaveInterval(null); - } - - // Cleanup gesture controls - gestureControls.cleanup(); - // Best-effort restore of Android system brightness state on unmount - if (Platform.OS === 'android') { - try { - // Use restoreSystemBrightnessAsync if available to reset window override - if (typeof (Brightness as any).restoreSystemBrightnessAsync === 'function') { - (Brightness as any).restoreSystemBrightnessAsync(); - } else { - // Fallback for older versions or if restore is not available - // Only attempt to write system settings if strictly necessary and likely to succeed - // We skip the permission check here for sync cleanup, but catch the error if it fails - } - } catch (e) { - logger.warn('[AndroidVideoPlayer] Failed to restore system brightness on unmount:', e); - } - } - }; - }, []); - - const safeSetState = (setter: any) => { - if (isMounted.current) { - setter(); - } - }; - - - useEffect(() => { - if (!useCustomSubtitles || customSubtitles.length === 0) { - if (currentSubtitle !== '') { - setCurrentSubtitle(''); - } - if (currentFormattedSegments.length > 0) { - setCurrentFormattedSegments([]); - } - return; - } - const adjustedTime = currentTime + (subtitleOffsetSec || 0) - 0.2; - const currentCue = customSubtitles.find(cue => - adjustedTime >= cue.start && adjustedTime <= cue.end - ); - const newSubtitle = currentCue ? currentCue.text : ''; - setCurrentSubtitle(newSubtitle); - - // Extract formatted segments from current cue - if (currentCue?.formattedSegments) { - 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]); - - useEffect(() => { - loadSubtitleSize(); - }, []); - - // Handle audio track changes with proper logging - useEffect(() => { - if (selectedAudioTrack !== null && rnVideoAudioTracks.length > 0) { - if (selectedAudioTrack.type === SelectedTrackType.INDEX && selectedAudioTrack.value !== undefined) { - const selectedTrack = rnVideoAudioTracks.find(track => track.id === selectedAudioTrack.value); - if (selectedTrack) { - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Audio track selected: ${selectedTrack.name} (${selectedTrack.language}) - ID: ${selectedAudioTrack.value}`); - } - } else { - logger.warn(`[AndroidVideoPlayer] Selected audio track ${selectedAudioTrack.value} not found in available tracks`); - } - } else if (selectedAudioTrack.type === SelectedTrackType.SYSTEM) { - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Using system audio selection`); - } - } else if (selectedAudioTrack.type === SelectedTrackType.DISABLED) { - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Audio disabled`); - } - } - } - }, [selectedAudioTrack, rnVideoAudioTracks]); - - // 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 { - 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, - ]); - - const increaseSubtitleSize = () => { - const newSize = Math.min(subtitleSize + 2, 80); - saveSubtitleSize(newSize); - }; - - const decreaseSubtitleSize = () => { - const newSize = Math.max(subtitleSize - 2, 8); - saveSubtitleSize(newSize); - }; - - const toggleSubtitleBackground = () => { - setSubtitleBackground(!subtitleBackground); - }; + const handleClose = useCallback(() => { + if (navigation.canGoBack()) navigation.goBack(); + else navigation.reset({ index: 0, routes: [{ name: 'Home' }] } as any); + }, [navigation]); const handleSelectStream = async (newStream: any) => { if (newStream.url === currentStreamUrl) { - setShowSourcesModal(false); + modals.setShowSourcesModal(false); return; } + modals.setShowSourcesModal(false); + playerState.setPaused(true); - 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 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 Stream'; + const newStreamName = newStream.name || newStream.title || 'Unknown'; - // Pause current playback - setPaused(true); - - // Navigate with replace to reload player with new source setTimeout(() => { (navigation as any).replace('PlayerAndroid', { + ...route.params, uri: newStream.url, - title: title, - episodeTitle: episodeTitle, - season: season, - episode: episode, quality: newQuality, - year: year, streamProvider: newProvider, streamName: newStreamName, - headers: newStream.headers || undefined, - forceVlc: false, - id, - type, - episodeId, - imdbId: imdbId ?? undefined, - backdrop: backdrop || undefined, - availableStreams: availableStreams, + headers: newStream.headers, + availableStreams: availableStreams }); }, 100); }; - const handleEpisodeSelect = (episode: Episode) => { - logger.log('[AndroidVideoPlayer] Episode selected:', episode.name); - setSelectedEpisodeForStreams(episode); - setShowEpisodesModal(false); - setShowEpisodeStreamsModal(true); - }; - - // Debug: Log when modal state changes - useEffect(() => { - if (showEpisodesModal) { - logger.log('[AndroidVideoPlayer] Episodes modal opened, groupedEpisodes:', groupedEpisodes); - logger.log('[AndroidVideoPlayer] type:', type, 'season:', season, 'episode:', episode); - } - }, [showEpisodesModal, groupedEpisodes, type]); - const handleEpisodeStreamSelect = async (stream: any) => { - if (!selectedEpisodeForStreams) return; - - setShowEpisodeStreamsModal(false); + if (!modals.selectedEpisodeForStreams) return; + modals.setShowEpisodeStreamsModal(false); + playerState.setPaused(true); + const ep = modals.selectedEpisodeForStreams; const newQuality = stream.quality || (stream.title?.match(/(\d+)p/)?.[0]); const newProvider = stream.addonName || stream.name || stream.addon || 'Unknown'; const newStreamName = stream.name || stream.title || 'Unknown Stream'; - setPaused(true); - setTimeout(() => { (navigation as any).replace('PlayerAndroid', { 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, @@ -3098,7 +280,7 @@ const AndroidVideoPlayer: React.FC = () => { forceVlc: false, 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: {}, @@ -3107,1005 +289,264 @@ const AndroidVideoPlayer: React.FC = () => { }, 100); }; - useEffect(() => { - if (isVideoLoaded && initialPosition && !isInitialSeekComplete && duration > 0) { - logger.log(`[AndroidVideoPlayer] 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(`[AndroidVideoPlayer] 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]); + const cycleResizeMode = useCallback(() => { + if (playerState.resizeMode === 'contain') playerState.setResizeMode('cover'); + else playerState.setResizeMode('contain'); + }, [playerState.resizeMode]); return ( - - {/* Left side gesture handler - brightness + tap + long press (Android and iOS) */} - - - - - - - - - {/* Combined gesture handler for right side - volume + tap + long press */} - - - - - - - - - {/* Center area tap handler - handles both show and hide */} - { - if (showControls) { - // If controls are visible, hide them - const timeoutId = setTimeout(() => { - hideControls(); - }, 0); - // Clear any existing timeout - if (controlsTimeout.current) { - clearTimeout(controlsTimeout.current); - } - controlsTimeout.current = timeoutId; - } else { - // If controls are hidden, show them - toggleControls(); - } + + { + playerState.isSeeking.current = false; + if (data.currentTime) traktAutosync.handleProgressUpdate(data.currentTime, playerState.duration, true); }} - shouldCancelWhenOutside={false} - simultaneousHandlers={[]} - > - - - - - - - - - {useVLC && !forceVlcRemount ? ( - { - vlcLoadedRef.current = true; - onLoad(data); - // Start playback if not paused - if (!paused && vlcPlayerRef.current) { - setTimeout(() => { - if (vlcPlayerRef.current) { - vlcPlayerRef.current.play(); - } - }, 100); - } - }} - onProgress={(data) => { - const pos = typeof data?.position === 'number' ? data.position : 0; - if (duration > 0) { - const current = pos * duration; - handleProgress({ currentTime: current, playableDuration: current }); - } - }} - onSeek={onSeek} - onEnd={onEnd} - onError={handleError} - onTracksUpdate={handleVlcTracksUpdate} - selectedAudioTrack={vlcSelectedAudioTrack} - selectedSubtitleTrack={vlcSelectedSubtitleTrack} - restoreTime={vlcRestoreTime} - forceRemount={forceVlcRemount} - key={vlcKey} - /> - ) : ( - - - - - {/* Tap-capture overlay above the Video to toggle controls (Android fix) */} - - - - - - - {/* Combined Volume & Brightness Gesture Indicator - NEW PILL STYLE (No Bar) */} - {(gestureControls.showVolumeOverlay || gestureControls.showBrightnessOverlay) && ( - - {/* Dynamic Icon */} - - - - - {/* Text Label: Shows "Muted" or percentage */} - - {/* Conditional Text Content Logic */} - {gestureControls.showVolumeOverlay && volume === 0 - ? "Muted" // Display "Muted" when volume is 0 - : `${Math.round((gestureControls.showVolumeOverlay ? volume : brightness) * 100)}%` // Display percentage otherwise - } - - - )} - - {showPauseOverlay && ( - - - {/* Strong horizontal fade from left side */} - - - - - - {showCastDetails && selectedCastMember ? ( - // Cast Detail View with fade transition - - - { - // Animate cast details out, then metadata back in - Animated.parallel([ - Animated.timing(castDetailsOpacity, { - toValue: 0, - duration: 250, - useNativeDriver: true, - }), - Animated.timing(castDetailsScale, { - toValue: 0.95, - duration: 250, - useNativeDriver: true, - }) - ]).start(() => { - setShowCastDetails(false); - setSelectedCastMember(null); - // Animate metadata back in - Animated.parallel([ - Animated.timing(metadataOpacity, { - toValue: 1, - duration: 400, - useNativeDriver: true, - }), - Animated.spring(metadataScale, { - toValue: 1, - tension: 80, - friction: 8, - useNativeDriver: true, - }) - ]).start(); - }); - }} - > - - Back to details - - - - {selectedCastMember.profile_path && ( - - - - )} - - - {selectedCastMember.name} - - {selectedCastMember.character && ( - - as {selectedCastMember.character} - - )} - - {/* Biography if available */} - {selectedCastMember.biography && ( - - {selectedCastMember.biography} - - )} - - - - - ) : ( - // Default Metadata View - - - You're watching - - {title} - - {!!year && ( - - {`${year}${type === 'series' && season && episode ? ` • S${season}E${episode}` : ''}`} - - )} - {!!episodeTitle && ( - - {episodeTitle} - - )} - {(currentEpisodeDescription || metadata?.description) && ( - - {(type as any) === 'series' ? (currentEpisodeDescription || metadata?.description || '') : (metadata?.description || '')} - - )} - {cast && cast.length > 0 && ( - - Cast - - {cast.slice(0, 6).map((castMember: any, index: number) => ( - { - setSelectedCastMember(castMember); - // Animate metadata out, then cast details in - Animated.parallel([ - Animated.timing(metadataOpacity, { - toValue: 0, - duration: 250, - useNativeDriver: true, - }), - Animated.timing(metadataScale, { - toValue: 0.95, - duration: 250, - useNativeDriver: true, - }) - ]).start(() => { - setShowCastDetails(true); - // Animate cast details in - Animated.parallel([ - Animated.timing(castDetailsOpacity, { - toValue: 1, - duration: 400, - useNativeDriver: true, - }), - Animated.spring(castDetailsScale, { - toValue: 1, - tension: 80, - friction: 8, - useNativeDriver: true, - }) - ]).start(); - }); - }} - > - - {castMember.name} - - - ))} - - - )} - - - )} - - - - )} - - {/* Next Episode Button (reusable) */} - = 768 ? 120 : 100} - /> - - = 768 ? 120 : 100} - /> - - {/* Speed Activated Overlay */} - {showSpeedActivatedOverlay && ( - - - - {holdToSpeedValue}x Speed - - - - )} - - {/* Resume overlay removed when AlwaysResume is enabled; overlay component omitted */} - - - - - <> - { + if (modals.showEpisodeStreamsModal) return; + playerState.setPaused(true); + }} + onError={(err) => { + logger.error('Video Error', err); + modals.setErrorDetails(JSON.stringify(err)); + modals.setShowErrorModal(true); + }} + onBuffer={(buf) => playerState.setIsBuffering(buf.isBuffering)} + onTracksUpdate={vlcHook.handleVlcTracksUpdate} + vlcPlayerRef={vlcHook.vlcPlayerRef} + videoRef={videoRef} + pinchRef={useRef(null)} + onPinchGestureEvent={() => { }} + onPinchHandlerStateChange={() => { }} + vlcLoadedRef={vlcHook.vlcLoadedRef} + screenDimensions={playerState.screenDimensions} + customVideoStyles={{}} + loadStartAtRef={loadStartAtRef} + firstFrameAtRef={firstFrameAtRef} /> - - { + if (e.nativeEvent.state !== 4 && e.nativeEvent.state !== 2) speedControl.deactivateSpeedBoost(); + }} + toggleControls={toggleControls} + showControls={playerState.showControls} + hideControls={hideControls} + volume={volume} + brightness={brightness} + controlsTimeout={controlsTimeout} + /> + + { + const speeds = [0.5, 1, 1.25, 1.5, 2]; + const idx = speeds.indexOf(speedControl.playbackSpeed); + const next = speeds[(idx + 1) % speeds.length]; + speedControl.setPlaybackSpeed(next); + }} + currentPlaybackSpeed={speedControl.playbackSpeed} + setShowAudioModal={modals.setShowAudioModal} + setShowSubtitleModal={modals.setShowSubtitleModal} + setShowSpeedModal={modals.setShowSpeedModal} + isSubtitleModalOpen={modals.showSubtitleModal} + setShowSourcesModal={modals.setShowSourcesModal} + setShowEpisodesModal={type === 'series' ? modals.setShowEpisodesModal : undefined} + onSliderValueChange={(val) => { playerState.isDragging.current = true; }} + onSlidingStart={() => { playerState.isDragging.current = true; }} + onSlidingComplete={(val) => { + playerState.isDragging.current = false; + controlsHook.seekToTime(val); + }} + buffered={playerState.buffered} + formatTime={formatTime} + playerBackend={useVLC ? 'VLC' : 'ExoPlayer'} + /> + + + + playerState.setShowControls(true)} + title={title} + episodeTitle={episodeTitle} + season={season} + episode={episode} + year={year} + type={type || 'movie'} + description={nextEpisodeHook.currentEpisodeDescription || ''} + cast={cast} + screenDimensions={playerState.screenDimensions} + /> + + + { + useVLC ? vlcHook.selectVlcAudioTrack(trackId) : + tracksHook.setSelectedAudioTrack(trackId === null ? null : { type: 'index', value: trackId }); + }} /> - { }} // Placeholder + isLoadingSubtitleList={false} // Placeholder + isLoadingSubtitles={false} // Placeholder + customSubtitles={[]} // Placeholder + availableSubtitles={[]} // Placeholder + ksTextTracks={tracksHook.ksTextTracks} + selectedTextTrack={tracksHook.computedSelectedTextTrack} + useCustomSubtitles={false} + isKsPlayerActive={!useVLC} + subtitleSize={30} // Placeholder + subtitleBackground={false} // Placeholder + fetchAvailableSubtitles={() => { }} // Placeholder + loadWyzieSubtitle={() => { }} // Placeholder + selectTextTrack={(trackId) => { + useVLC ? vlcHook.selectVlcSubtitleTrack(trackId) : tracksHook.setSelectedTextTrack(trackId); + modals.setShowSubtitleModal(false); + }} + disableCustomSubtitles={() => { }} // Placeholder + increaseSubtitleSize={() => { }} // Placeholder + decreaseSubtitleSize={() => { }} // Placeholder + toggleSubtitleBackground={() => { }} // Placeholder + subtitleTextColor="#FFF" // Placeholder + setSubtitleTextColor={() => { }} // Placeholder + subtitleBgOpacity={0.5} // Placeholder + setSubtitleBgOpacity={() => { }} // Placeholder + subtitleTextShadow={false} // Placeholder + setSubtitleTextShadow={() => { }} // Placeholder + subtitleOutline={false} // Placeholder + setSubtitleOutline={() => { }} // Placeholder + subtitleOutlineColor="#000" // Placeholder + setSubtitleOutlineColor={() => { }} // Placeholder + subtitleOutlineWidth={1} // Placeholder + setSubtitleOutlineWidth={() => { }} // Placeholder + subtitleAlign="center" // Placeholder + setSubtitleAlign={() => { }} // Placeholder + subtitleBottomOffset={10} // Placeholder + setSubtitleBottomOffset={() => { }} // Placeholder + subtitleLetterSpacing={0} // Placeholder + setSubtitleLetterSpacing={() => { }} // Placeholder + subtitleLineHeightMultiplier={1} // Placeholder + setSubtitleLineHeightMultiplier={() => { }} // Placeholder + subtitleOffsetSec={0} // Placeholder + setSubtitleOffsetSec={() => { }} // Placeholder /> handleSelectStream(stream)} /> - {type === 'series' && ( - <> - + - { - setShowEpisodeStreamsModal(false); - setShowEpisodesModal(true); - }} - onSelectStream={handleEpisodeStreamSelect} - metadata={metadata ? { id: metadata.id, name: metadata.name } : undefined} - /> - - )} + { + modals.setSelectedEpisodeForStreams(ep); + modals.setShowEpisodesModal(false); + modals.setShowEpisodeStreamsModal(true); + }} + /> - {/* Error Modal */} - {isMounted.current && ( - - - - - - Playback Error - - - - + modals.setShowEpisodeStreamsModal(false)} + episode={modals.selectedEpisodeForStreams} + onSelectStream={handleEpisodeStreamSelect} + /> - The video player encountered an error and cannot continue playback: - - - {errorDetails} - - - - - Exit Player - - - - This dialog will auto-close in 5 seconds - - - - )} ); }; -// New styles for the gesture indicator -const localStyles = StyleSheet.create({ - gestureIndicatorContainer: { - position: 'absolute', - top: '4%', // Adjust this for vertical position - alignSelf: 'center', // Adjust this for horizontal position - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'rgba(25, 25, 25)', // Dark pill background - borderRadius: 70, - paddingHorizontal: 15, - paddingVertical: 15, - zIndex: 2000, // Very high z-index to ensure visibility - minWidth: 120, // Adjusted min width since bar is removed - }, - iconWrapper: { - borderRadius: 50, // Makes it a perfect circle (set to a high number) - width: 40, // Define the diameter of the circle - height: 40, // Define the diameter of the circle - justifyContent: 'center', - alignItems: 'center', - marginRight: 12, // Margin to separate icon circle from percentage text - }, - gestureText: { - color: '#FFFFFF', - fontSize: 18, - fontWeight: 'normal', - minWidth: 35, - textAlign: 'right', - }, -}); - export default AndroidVideoPlayer; diff --git a/src/components/player/android/components/GestureControls.tsx b/src/components/player/android/components/GestureControls.tsx new file mode 100644 index 00000000..181bb57d --- /dev/null +++ b/src/components/player/android/components/GestureControls.tsx @@ -0,0 +1,194 @@ +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 */} + {(gestureControls.showVolumeOverlay || gestureControls.showBrightnessOverlay) && ( + + + + + + + {gestureControls.showVolumeOverlay && volume === 0 + ? "Muted" + : `${Math.round((gestureControls.showVolumeOverlay ? volume : brightness) * 100)}%` + } + + + )} + + ); +}; diff --git a/src/components/player/android/components/PauseOverlay.tsx b/src/components/player/android/components/PauseOverlay.tsx new file mode 100644 index 00000000..ad7fdbb7 --- /dev/null +++ b/src/components/player/android/components/PauseOverlay.tsx @@ -0,0 +1,228 @@ +import React, { useState, useRef } 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'; + +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/android/components/SpeedActivatedOverlay.tsx b/src/components/player/android/components/SpeedActivatedOverlay.tsx new file mode 100644 index 00000000..54ad9faa --- /dev/null +++ b/src/components/player/android/components/SpeedActivatedOverlay.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { View, Text, Animated, StyleSheet } 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/android/components/VideoSurface.tsx b/src/components/player/android/components/VideoSurface.tsx new file mode 100644 index 00000000..cf978bf4 --- /dev/null +++ b/src/components/player/android/components/VideoSurface.tsx @@ -0,0 +1,208 @@ +import React, { forwardRef } from 'react'; +import { View, TouchableOpacity, StyleSheet, Platform } from 'react-native'; +import Video, { ViewType, VideoRef, ResizeMode } from 'react-native-video'; +import VlcVideoPlayer, { VlcPlayerRef } from '../../VlcVideoPlayer'; +import { PinchGestureHandler } from 'react-native-gesture-handler'; +import { styles } from '../../utils/playerStyles'; +import { logger } from '../../../../utils/logger'; +import { ResizeModeType, SelectedTrack } from '../../utils/playerTypes'; + +const getVideoResizeMode = (resizeMode: ResizeModeType) => { + switch (resizeMode) { + case 'contain': return 'contain'; + case 'cover': return 'cover'; + case 'none': return 'contain'; + default: return 'contain'; + } +}; + +interface VideoSurfaceProps { + useVLC: boolean; + forceVlcRemount: boolean; + processedStreamUrl: string; + volume: number; + playbackSpeed: number; + zoomScale: number; + resizeMode: ResizeModeType; + paused: boolean; + currentStreamUrl: string; + headers: any; + videoType: any; + vlcSelectedAudioTrack?: number; + vlcSelectedSubtitleTrack?: number; + vlcRestoreTime?: number; + vlcKey: string; + selectedAudioTrack: any; + selectedTextTrack: any; + useCustomSubtitles: boolean; + + // Callbacks + toggleControls: () => void; + onLoad: (data: any) => void; + onProgress: (data: any) => void; + onSeek: (data: any) => void; + onEnd: () => void; + onError: (err: any) => void; + onBuffer: (buf: any) => void; + onTracksUpdate: (tracks: any) => void; + + // Refs + vlcPlayerRef: React.RefObject; + videoRef: React.RefObject; + pinchRef: any; + + // Handlers + onPinchGestureEvent: any; + onPinchHandlerStateChange: any; + vlcLoadedRef: React.MutableRefObject; + screenDimensions: { width: number, height: number }; + customVideoStyles: any; + + // Debugging + loadStartAtRef: React.MutableRefObject; + firstFrameAtRef: React.MutableRefObject; +} + +export const VideoSurface: React.FC = ({ + useVLC, + forceVlcRemount, + processedStreamUrl, + volume, + playbackSpeed, + zoomScale, + resizeMode, + paused, + currentStreamUrl, + headers, + videoType, + vlcSelectedAudioTrack, + vlcSelectedSubtitleTrack, + vlcRestoreTime, + vlcKey, + selectedAudioTrack, + selectedTextTrack, + useCustomSubtitles, + toggleControls, + onLoad, + onProgress, + onSeek, + onEnd, + onError, + onBuffer, + onTracksUpdate, + vlcPlayerRef, + videoRef, + pinchRef, + onPinchGestureEvent, + onPinchHandlerStateChange, + vlcLoadedRef, + screenDimensions, + customVideoStyles, + loadStartAtRef, + firstFrameAtRef +}) => { + + const isHlsStream = (url: string) => { + return url.includes('.m3u8') || url.includes('m3u8') || + url.includes('hls') || url.includes('playlist') || + (videoType && videoType.toLowerCase() === 'm3u8'); + }; + + return ( + + + + + {useVLC && !forceVlcRemount ? ( + { + vlcLoadedRef.current = true; + onLoad(data); + if (!paused && vlcPlayerRef.current) { + setTimeout(() => { + if (vlcPlayerRef.current) { + vlcPlayerRef.current.play(); + } + }, 100); + } + }} + onProgress={onProgress} + onSeek={onSeek} + onEnd={onEnd} + onError={onError} + onTracksUpdate={onTracksUpdate} + selectedAudioTrack={vlcSelectedAudioTrack} + selectedSubtitleTrack={vlcSelectedSubtitleTrack} + restoreTime={vlcRestoreTime} + forceRemount={forceVlcRemount} + key={vlcKey} + /> + ) : ( + + + + + ); +}; diff --git a/src/components/player/android/hooks/useNextEpisode.ts b/src/components/player/android/hooks/useNextEpisode.ts new file mode 100644 index 00000000..1b3fca8d --- /dev/null +++ b/src/components/player/android/hooks/useNextEpisode.ts @@ -0,0 +1,59 @@ +import { useMemo } from 'react'; +import { logger } from '../../../../utils/logger'; + +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 as any) !== '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 as any) !== '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/android/hooks/useOpeningAnimation.ts b/src/components/player/android/hooks/useOpeningAnimation.ts new file mode 100644 index 00000000..e2245ef2 --- /dev/null +++ b/src/components/player/android/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('[AndroidVideoPlayer] 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/android/hooks/usePlayerControls.ts b/src/components/player/android/hooks/usePlayerControls.ts new file mode 100644 index 00000000..6befc3cb --- /dev/null +++ b/src/components/player/android/hooks/usePlayerControls.ts @@ -0,0 +1,71 @@ +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 = ( + videoRef: any, + vlcPlayerRef: any, + useVLC: boolean, + 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 (useVLC) { + if (vlcPlayerRef.current && duration > 0) { + if (DEBUG_MODE) logger.log(`[usePlayerControls][VLC] Seeking to ${timeInSeconds}`); + vlcPlayerRef.current.seek(timeInSeconds); + } + } else { + if (videoRef.current && duration > 0 && !isSeeking.current) { + if (DEBUG_MODE) logger.log(`[usePlayerControls] Seeking to ${timeInSeconds}`); + + isSeeking.current = true; + + if (Platform.OS === 'ios') { + iosWasPausedDuringSeekRef.current = paused; + if (!paused) setPaused(true); + } + + // Actually perform the seek + videoRef.current.seek(timeInSeconds); + + setTimeout(() => { + if (isMounted.current && isSeeking.current) { + isSeeking.current = false; + if (Platform.OS === 'ios' && iosWasPausedDuringSeekRef.current === false) { + setPaused(false); + iosWasPausedDuringSeekRef.current = null; + } + } + }, 500); + } + } + }, [useVLC, duration, paused, setPaused, videoRef, vlcPlayerRef, isSeeking, isMounted]); + + const skip = useCallback((seconds: number) => { + seekToTime(currentTime + seconds); + }, [currentTime, seekToTime]); + + return { + togglePlayback, + seekToTime, + skip, + iosWasPausedDuringSeekRef + }; +}; diff --git a/src/components/player/android/hooks/usePlayerModals.ts b/src/components/player/android/hooks/usePlayerModals.ts new file mode 100644 index 00000000..c0520f04 --- /dev/null +++ b/src/components/player/android/hooks/usePlayerModals.ts @@ -0,0 +1,28 @@ +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); + + // Some modals have associated data + const [selectedEpisodeForStreams, setSelectedEpisodeForStreams] = useState(null); + const [errorDetails, setErrorDetails] = useState(''); + + return { + showAudioModal, setShowAudioModal, + showSubtitleModal, setShowSubtitleModal, + showSpeedModal, setShowSpeedModal, + showSourcesModal, setShowSourcesModal, + showEpisodesModal, setShowEpisodesModal, + showEpisodeStreamsModal, setShowEpisodeStreamsModal, + showErrorModal, setShowErrorModal, + selectedEpisodeForStreams, setSelectedEpisodeForStreams, + errorDetails, setErrorDetails + }; +}; diff --git a/src/components/player/android/hooks/usePlayerSetup.ts b/src/components/player/android/hooks/usePlayerSetup.ts new file mode 100644 index 00000000..6b5e00b4 --- /dev/null +++ b/src/components/player/android/hooks/usePlayerSetup.ts @@ -0,0 +1,107 @@ +import { useEffect, useRef } from 'react'; +import { StatusBar, Platform, Dimensions, AppState } from 'react-native'; +import RNImmersiveMode from 'react-native-immersive-mode'; +import * as Brightness from 'expo-brightness'; +import { logger } from '../../../../utils/logger'; +import { useFocusEffect } from '@react-navigation/native'; +import { useCallback } from 'react'; + +const DEBUG_MODE = false; + +export const usePlayerSetup = ( + setScreenDimensions: (dim: any) => void, + setVolume: (vol: number) => void, + setBrightness: (bri: number) => void, + paused: boolean +) => { + const originalSystemBrightnessRef = useRef(null); + const originalSystemBrightnessModeRef = useRef(null); + const isAppBackgrounded = useRef(false); + + const enableImmersiveMode = () => { + if (Platform.OS === 'android') { + RNImmersiveMode.setBarTranslucent(true); + RNImmersiveMode.fullLayout(true); + StatusBar.setHidden(true, 'none'); + } + }; + + const disableImmersiveMode = () => { + if (Platform.OS === 'android') { + RNImmersiveMode.setBarTranslucent(false); + RNImmersiveMode.fullLayout(false); + StatusBar.setHidden(false, 'fade'); + } + }; + + useFocusEffect( + useCallback(() => { + enableImmersiveMode(); + return () => { }; + }, []) + ); + + useEffect(() => { + // Initial Setup + const subscription = Dimensions.addEventListener('change', ({ screen }) => { + setScreenDimensions(screen); + enableImmersiveMode(); + }); + + StatusBar.setHidden(true, 'none'); + enableImmersiveMode(); + + // Initialize volume (default to 1.0) + setVolume(1.0); + + // Initialize Brightness + const initBrightness = async () => { + try { + if (Platform.OS === 'android') { + try { + const [sysBright, sysMode] = await Promise.all([ + (Brightness as any).getSystemBrightnessAsync?.(), + (Brightness as any).getSystemBrightnessModeAsync?.() + ]); + originalSystemBrightnessRef.current = typeof sysBright === 'number' ? sysBright : null; + originalSystemBrightnessModeRef.current = typeof sysMode === 'number' ? sysMode : null; + } catch (e) { + // ignore + } + } + const currentBrightness = await Brightness.getBrightnessAsync(); + setBrightness(currentBrightness); + } catch (error) { + logger.warn('[usePlayerSetup] Error setting brightness', error); + setBrightness(1.0); + } + }; + initBrightness(); + + return () => { + subscription?.remove(); + disableImmersiveMode(); + + // Restore brightness on unmount + if (Platform.OS === 'android' && originalSystemBrightnessRef.current !== null) { + // restoration logic normally happens here or in a separate effect + } + }; + }, []); + + // Handle App State + useEffect(() => { + const onAppStateChange = (state: string) => { + if (state === 'active') { + isAppBackgrounded.current = false; + enableImmersiveMode(); + } else if (state === 'background' || state === 'inactive') { + isAppBackgrounded.current = true; + } + }; + const sub = AppState.addEventListener('change', onAppStateChange); + return () => sub.remove(); + }, []); + + return { isAppBackgrounded }; +}; diff --git a/src/components/player/android/hooks/usePlayerState.ts b/src/components/player/android/hooks/usePlayerState.ts new file mode 100644 index 00000000..a4b70d62 --- /dev/null +++ b/src/components/player/android/hooks/usePlayerState.ts @@ -0,0 +1,41 @@ +import { useState, useRef } from 'react'; +import { Dimensions } from 'react-native'; +import { ResizeModeType, SelectedTrack } from '../../utils/playerTypes'; + +export const usePlayerState = () => { + const [paused, setPaused] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [buffered, setBuffered] = useState(0); + + // UI State + const [showControls, setShowControls] = useState(true); + const [resizeMode, setResizeMode] = useState('contain'); + const [isBuffering, setIsBuffering] = useState(false); + const [isVideoLoaded, setIsVideoLoaded] = useState(false); + + // Layout State + const [videoAspectRatio, setVideoAspectRatio] = useState(null); + const [screenDimensions, setScreenDimensions] = useState(Dimensions.get('screen')); + + // Logic State + const isSeeking = useRef(false); + const isDragging = useRef(false); + const isMounted = useRef(true); + + return { + paused, setPaused, + currentTime, setCurrentTime, + duration, setDuration, + buffered, setBuffered, + showControls, setShowControls, + resizeMode, setResizeMode, + isBuffering, setIsBuffering, + isVideoLoaded, setIsVideoLoaded, + videoAspectRatio, setVideoAspectRatio, + screenDimensions, setScreenDimensions, + isSeeking, + isDragging, + isMounted, + }; +}; diff --git a/src/components/player/android/hooks/usePlayerTracks.ts b/src/components/player/android/hooks/usePlayerTracks.ts new file mode 100644 index 00000000..53711b23 --- /dev/null +++ b/src/components/player/android/hooks/usePlayerTracks.ts @@ -0,0 +1,61 @@ +import { useState, useMemo } from 'react'; +import { SelectedTrack, TextTrack, AudioTrack } from '../../utils/playerTypes'; + +interface Track { + id: number; + name: string; + language?: string; +} + +export const usePlayerTracks = ( + useVLC: boolean, + vlcAudioTracks: Track[], + vlcSubtitleTracks: Track[], + vlcSelectedAudioTrack: number | undefined, + vlcSelectedSubtitleTrack: number | undefined +) => { + // React Native Video Tracks + const [rnVideoAudioTracks, setRnVideoAudioTracks] = useState([]); + const [rnVideoTextTracks, setRnVideoTextTracks] = useState([]); + + // Selected Tracks State + const [selectedAudioTrack, setSelectedAudioTrack] = useState({ type: 'system' }); + const [selectedTextTrack, setSelectedTextTrack] = useState(-1); + + // Unified Tracks + const ksAudioTracks = useMemo(() => + useVLC ? vlcAudioTracks : rnVideoAudioTracks, + [useVLC, vlcAudioTracks, rnVideoAudioTracks] + ); + + const ksTextTracks = useMemo(() => + useVLC ? vlcSubtitleTracks : rnVideoTextTracks, + [useVLC, vlcSubtitleTracks, rnVideoTextTracks] + ); + + // Unified Selection + const computedSelectedAudioTrack = useMemo(() => + useVLC + ? (vlcSelectedAudioTrack ?? null) + : (selectedAudioTrack?.type === 'index' && selectedAudioTrack?.value !== undefined + ? Number(selectedAudioTrack?.value) + : null), + [useVLC, vlcSelectedAudioTrack, selectedAudioTrack] + ); + + const computedSelectedTextTrack = useMemo(() => + useVLC ? (vlcSelectedSubtitleTrack ?? -1) : selectedTextTrack, + [useVLC, vlcSelectedSubtitleTrack, selectedTextTrack] + ); + + return { + rnVideoAudioTracks, setRnVideoAudioTracks, + rnVideoTextTracks, setRnVideoTextTracks, + selectedAudioTrack, setSelectedAudioTrack, + selectedTextTrack, setSelectedTextTrack, + ksAudioTracks, + ksTextTracks, + computedSelectedAudioTrack, + computedSelectedTextTrack + }; +}; diff --git a/src/components/player/android/hooks/useSpeedControl.ts b/src/components/player/android/hooks/useSpeedControl.ts new file mode 100644 index 00000000..d185f0e6 --- /dev/null +++ b/src/components/player/android/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/android/hooks/useVlcPlayer.ts b/src/components/player/android/hooks/useVlcPlayer.ts new file mode 100644 index 00000000..c3547443 --- /dev/null +++ b/src/components/player/android/hooks/useVlcPlayer.ts @@ -0,0 +1,148 @@ +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import { logger } from '../../../../utils/logger'; +import { VlcPlayerRef } from '../../VlcVideoPlayer'; + +interface Track { + id: number; + name: string; + language?: string; +} + +const DEBUG_MODE = false; + +export const useVlcPlayer = (useVLC: boolean, paused: boolean, currentTime: number) => { + const [vlcAudioTracks, setVlcAudioTracks] = useState([]); + const [vlcSubtitleTracks, setVlcSubtitleTracks] = useState([]); + const [vlcSelectedAudioTrack, setVlcSelectedAudioTrack] = useState(undefined); + const [vlcSelectedSubtitleTrack, setVlcSelectedSubtitleTrack] = useState(undefined); + const [vlcRestoreTime, setVlcRestoreTime] = useState(undefined); + const [forceVlcRemount, setForceVlcRemount] = useState(false); + const [vlcKey, setVlcKey] = useState('vlc-initial'); + + const vlcPlayerRef = useRef(null); + const vlcLoadedRef = useRef(false); + const trackUpdateTimeoutRef = useRef(null); + + // Handle VLC pause/play interactions + useEffect(() => { + if (useVLC && vlcLoadedRef.current && vlcPlayerRef.current) { + if (paused) { + vlcPlayerRef.current.pause(); + } else { + vlcPlayerRef.current.play(); + } + } + }, [useVLC, paused]); + + // Reset forceVlcRemount when VLC becomes inactive + useEffect(() => { + if (!useVLC && forceVlcRemount) { + setForceVlcRemount(false); + } + }, [useVLC, forceVlcRemount]); + + // Track selection + const selectVlcAudioTrack = useCallback((trackId: number | null) => { + setVlcSelectedAudioTrack(trackId ?? undefined); + logger.log('[AndroidVideoPlayer][VLC] Audio track selected:', trackId); + }, []); + + const selectVlcSubtitleTrack = useCallback((trackId: number | null) => { + setVlcSelectedSubtitleTrack(trackId ?? undefined); + logger.log('[AndroidVideoPlayer][VLC] Subtitle track selected:', trackId); + }, []); + + // Track updates handler + const handleVlcTracksUpdate = useCallback((tracks: { audio: any[], subtitle: any[] }) => { + if (!tracks) return; + + if (trackUpdateTimeoutRef.current) { + clearTimeout(trackUpdateTimeoutRef.current); + } + + trackUpdateTimeoutRef.current = setTimeout(() => { + const { audio = [], subtitle = [] } = tracks; + let hasUpdates = false; + + // Process Audio + if (Array.isArray(audio) && audio.length > 0) { + const formattedAudio = audio.map(track => ({ + id: track.id, + name: track.name || `Track ${track.id + 1}`, + language: track.language + })); + + const audioChanged = formattedAudio.length !== vlcAudioTracks.length || + formattedAudio.some((track, index) => { + const existing = vlcAudioTracks[index]; + return !existing || track.id !== existing.id || track.name !== existing.name; + }); + + if (audioChanged) { + setVlcAudioTracks(formattedAudio); + hasUpdates = true; + } + } + + // Process Subtitles + if (Array.isArray(subtitle) && subtitle.length > 0) { + const formattedSubs = subtitle.map(track => ({ + id: track.id, + name: track.name || `Track ${track.id + 1}`, + language: track.language + })); + + const subsChanged = formattedSubs.length !== vlcSubtitleTracks.length || + formattedSubs.some((track, index) => { + const existing = vlcSubtitleTracks[index]; + return !existing || track.id !== existing.id || track.name !== existing.name; + }); + + if (subsChanged) { + setVlcSubtitleTracks(formattedSubs); + hasUpdates = true; + } + } + + trackUpdateTimeoutRef.current = null; + }, 100); + }, [vlcAudioTracks, vlcSubtitleTracks]); + + // Cleanup + useEffect(() => { + return () => { + if (trackUpdateTimeoutRef.current) { + clearTimeout(trackUpdateTimeoutRef.current); + } + }; + }, []); + + const remountVlc = useCallback((reason: string) => { + if (useVLC) { + logger.log(`[VLC] Forcing complete remount: ${reason}`); + setVlcRestoreTime(currentTime); + setForceVlcRemount(true); + vlcLoadedRef.current = false; + setTimeout(() => { + setForceVlcRemount(false); + setVlcKey(`vlc-${reason}-${Date.now()}`); + }, 100); + } + }, [useVLC, currentTime]); + + return { + vlcAudioTracks, + vlcSubtitleTracks, + vlcSelectedAudioTrack, + vlcSelectedSubtitleTrack, + selectVlcAudioTrack, + selectVlcSubtitleTrack, + vlcPlayerRef, + vlcLoadedRef, + forceVlcRemount, + vlcRestoreTime, + vlcKey, + handleVlcTracksUpdate, + remountVlc, + }; +}; diff --git a/src/components/player/android/hooks/useWatchProgress.ts b/src/components/player/android/hooks/useWatchProgress.ts new file mode 100644 index 00000000..fa61406a --- /dev/null +++ b/src/components/player/android/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/utils/playerStyles.ts b/src/components/player/utils/playerStyles.ts index 76835dc0..bd429ef5 100644 --- a/src/components/player/utils/playerStyles.ts +++ b/src/components/player/utils/playerStyles.ts @@ -140,8 +140,8 @@ export const styles = StyleSheet.create({ topButton: { padding: 8, }, - - + + /* CloudStream Style - Center Controls */ controls: { position: 'absolute', @@ -156,7 +156,7 @@ export const styles = StyleSheet.create({ gap: controlsGap, zIndex: 1000, }, - + /* CloudStream Style - Seek Buttons */ seekButtonContainer: { alignItems: 'center', @@ -187,7 +187,7 @@ export const styles = StyleSheet.create({ textAlign: 'center', marginLeft: -7, }, - + /* CloudStream Style - Play Button */ playButton: { alignItems: 'center', @@ -202,7 +202,7 @@ export const styles = StyleSheet.create({ color: '#FFFFFF', opacity: 1, }, - + /* CloudStream Style - Arc Animations */ arcContainer: { position: 'absolute', @@ -233,9 +233,60 @@ export const styles = StyleSheet.create({ position: 'absolute', backgroundColor: 'rgba(255, 255, 255, 0.3)', }, - - - + speedActivatedOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + zIndex: 1500, + pointerEvents: 'none', + }, + speedActivatedContainer: { + backgroundColor: 'rgba(0, 0, 0, 0.7)', + paddingVertical: 12, + paddingHorizontal: 24, + borderRadius: 30, + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + speedActivatedText: { + color: '#FFFFFF', + fontSize: 18, + fontWeight: 'bold', + }, + 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', + alignItems: 'center', + paddingHorizontal: 16, + justifyContent: 'center', + zIndex: 2000, + }, + iconWrapper: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + marginRight: 10, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, + gestureText: { + color: '#FFFFFF', + fontSize: 24, + fontWeight: '600', + }, bottomControls: { gap: 12, @@ -1162,4 +1213,4 @@ export const styles = StyleSheet.create({ fontSize: skipTextFont, marginTop: 2, }, -}); \ No newline at end of file +}); \ No newline at end of file diff --git a/src/components/player/utils/playerTypes.ts b/src/components/player/utils/playerTypes.ts index b42e87f7..bbfb577e 100644 --- a/src/components/player/utils/playerTypes.ts +++ b/src/components/player/utils/playerTypes.ts @@ -71,8 +71,8 @@ export interface VlcMediaEvent { duration: number; bufferTime?: number; isBuffering?: boolean; - audioTracks?: Array<{id: number, name: string, language?: string}>; - textTracks?: Array<{id: number, name: string, language?: string}>; + audioTracks?: Array<{ id: number, name: string, language?: string }>; + textTracks?: Array<{ id: number, name: string, language?: string }>; selectedAudioTrack?: number; selectedTextTrack?: number; } diff --git a/src/components/player/utils/playerUtils.ts b/src/components/player/utils/playerUtils.ts index a9c630f9..8514eac5 100644 --- a/src/components/player/utils/playerUtils.ts +++ b/src/components/player/utils/playerUtils.ts @@ -200,4 +200,35 @@ export const detectRTL = (text: string): boolean => { // Consider RTL if at least 30% of non-whitespace characters are RTL // This handles mixed-language subtitles (e.g., Arabic with English numbers) return rtlCount / nonWhitespace.length >= 0.3; +}; + +// Check if a URL is an HLS stream +export const isHlsStream = (url: string | undefined): boolean => { + if (!url) return false; + return url.includes('.m3u8') || url.includes('.m3u'); +}; + +// Process URL for VLC to handle specific protocol requirements +export const processUrlForVLC = (url: string | undefined): string => { + if (!url) return ''; + // Some HLS streams need to be passed with specific protocols for VLC + if (url.startsWith('https://') && isHlsStream(url)) { + // Standard HTTPS is usually fine, but some implementations might prefer http + return url; + } + return url; +}; + +// Default headers for Android requests +export const defaultAndroidHeaders = { + 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; Mobile; rv:89.0) Gecko/89.0 Firefox/89.0', + 'Accept': '*/*' +}; + +// Get specific headers for HLS streams +export const getHlsHeaders = () => { + return { + ...defaultAndroidHeaders, + 'Accept': 'application/x-mpegURL, application/vnd.apple.mpegurl, application/json, text/plain', + }; }; \ No newline at end of file