From a50f8de913ec472b00fc2e022b632326d83dfcfa Mon Sep 17 00:00:00 2001 From: tapframe Date: Mon, 22 Dec 2025 11:35:25 +0530 Subject: [PATCH 01/18] reafactor android videoplayer --- src/components/player/AndroidVideoPlayer.tsx | 4389 ++--------------- .../android/components/GestureControls.tsx | 194 + .../android/components/PauseOverlay.tsx | 228 + .../components/SpeedActivatedOverlay.tsx | 32 + .../android/components/VideoSurface.tsx | 208 + .../player/android/hooks/useNextEpisode.ts | 59 + .../android/hooks/useOpeningAnimation.ts | 149 + .../player/android/hooks/usePlayerControls.ts | 71 + .../player/android/hooks/usePlayerModals.ts | 28 + .../player/android/hooks/usePlayerSetup.ts | 107 + .../player/android/hooks/usePlayerState.ts | 41 + .../player/android/hooks/usePlayerTracks.ts | 61 + .../player/android/hooks/useSpeedControl.ts | 93 + .../player/android/hooks/useVlcPlayer.ts | 148 + .../player/android/hooks/useWatchProgress.ts | 120 + src/components/player/utils/playerStyles.ts | 69 +- src/components/player/utils/playerTypes.ts | 4 +- src/components/player/utils/playerUtils.ts | 31 + 18 files changed, 2047 insertions(+), 3985 deletions(-) create mode 100644 src/components/player/android/components/GestureControls.tsx create mode 100644 src/components/player/android/components/PauseOverlay.tsx create mode 100644 src/components/player/android/components/SpeedActivatedOverlay.tsx create mode 100644 src/components/player/android/components/VideoSurface.tsx create mode 100644 src/components/player/android/hooks/useNextEpisode.ts create mode 100644 src/components/player/android/hooks/useOpeningAnimation.ts create mode 100644 src/components/player/android/hooks/usePlayerControls.ts create mode 100644 src/components/player/android/hooks/usePlayerModals.ts create mode 100644 src/components/player/android/hooks/usePlayerSetup.ts create mode 100644 src/components/player/android/hooks/usePlayerState.ts create mode 100644 src/components/player/android/hooks/usePlayerTracks.ts create mode 100644 src/components/player/android/hooks/useSpeedControl.ts create mode 100644 src/components/player/android/hooks/useVlcPlayer.ts create mode 100644 src/components/player/android/hooks/useWatchProgress.ts diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 9359460..627265e 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 0000000..181bb57 --- /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 0000000..ad7fdbb --- /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 0000000..54ad9fa --- /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 0000000..cf978bf --- /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 0000000..1b3fca8 --- /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 0000000..e2245ef --- /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 0000000..6befc3c --- /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 0000000..c0520f0 --- /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 0000000..6b5e00b --- /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 0000000..a4b70d6 --- /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 0000000..53711b2 --- /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 0000000..d185f0e --- /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 0000000..c354744 --- /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 0000000..fa61406 --- /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 76835dc..bd429ef 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 b42e87f..bbfb577 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 a9c630f..8514eac 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 From 0d6d69e0a8a622ec09a19f6de266000f33620172 Mon Sep 17 00:00:00 2001 From: tapframe Date: Mon, 22 Dec 2025 13:58:16 +0530 Subject: [PATCH 02/18] refactor ksplayer --- src/components/player/AndroidVideoPlayer.tsx | 28 +- src/components/player/KSPlayerCore.tsx | 3816 +++-------------- .../android/components/VideoSurface.tsx | 14 +- .../player/components/GestureControls.tsx | 199 + .../player/components/PauseOverlay.tsx | 259 ++ .../components/SpeedActivatedOverlay.tsx | 38 + src/components/player/components/index.ts | 8 + src/components/player/hooks/index.ts | 21 + .../player/hooks/useCustomSubtitles.ts | 62 + src/components/player/hooks/useNextEpisode.ts | 65 + .../player/hooks/useOpeningAnimation.ts | 152 + .../player/hooks/usePlayerControls.ts | 81 + .../player/hooks/usePlayerModals.ts | 38 + src/components/player/hooks/usePlayerSetup.ts | 117 + src/components/player/hooks/usePlayerState.ts | 88 + .../player/hooks/usePlayerTracks.ts | 47 + .../player/hooks/useSpeedControl.ts | 97 + .../player/ios/components/GestureControls.tsx | 333 ++ .../player/ios/components/KSPlayerSurface.tsx | 140 + .../player/ios/components/PauseOverlay.tsx | 228 + .../ios/components/SpeedActivatedOverlay.tsx | 32 + .../player/ios/hooks/useCustomSubtitles.ts | 58 + .../player/ios/hooks/useKSPlayer.ts | 15 + .../player/ios/hooks/useNextEpisode.ts | 58 + .../player/ios/hooks/useOpeningAnimation.ts | 149 + .../player/ios/hooks/usePlayerControls.ts | 63 + .../player/ios/hooks/usePlayerModals.ts | 34 + .../player/ios/hooks/usePlayerSetup.ts | 103 + .../player/ios/hooks/usePlayerState.ts | 83 + .../player/ios/hooks/usePlayerTracks.ts | 38 + .../player/ios/hooks/useSpeedControl.ts | 93 + .../player/ios/hooks/useWatchProgress.ts | 120 + .../player/modals/AudioTrackModal.tsx | 2 + .../player/modals/EpisodeStreamsModal.tsx | 2 + .../player/modals/EpisodesModal.tsx | 2 + src/components/player/modals/SourcesModal.tsx | 4 +- src/components/player/utils/playerStyles.ts | 35 +- src/components/player/utils/playerTypes.ts | 6 +- 38 files changed, 3396 insertions(+), 3332 deletions(-) create mode 100644 src/components/player/components/GestureControls.tsx create mode 100644 src/components/player/components/PauseOverlay.tsx create mode 100644 src/components/player/components/SpeedActivatedOverlay.tsx create mode 100644 src/components/player/components/index.ts create mode 100644 src/components/player/hooks/index.ts create mode 100644 src/components/player/hooks/useCustomSubtitles.ts create mode 100644 src/components/player/hooks/useNextEpisode.ts create mode 100644 src/components/player/hooks/useOpeningAnimation.ts create mode 100644 src/components/player/hooks/usePlayerControls.ts create mode 100644 src/components/player/hooks/usePlayerModals.ts create mode 100644 src/components/player/hooks/usePlayerSetup.ts create mode 100644 src/components/player/hooks/usePlayerState.ts create mode 100644 src/components/player/hooks/usePlayerTracks.ts create mode 100644 src/components/player/hooks/useSpeedControl.ts create mode 100644 src/components/player/ios/components/GestureControls.tsx create mode 100644 src/components/player/ios/components/KSPlayerSurface.tsx create mode 100644 src/components/player/ios/components/PauseOverlay.tsx create mode 100644 src/components/player/ios/components/SpeedActivatedOverlay.tsx create mode 100644 src/components/player/ios/hooks/useCustomSubtitles.ts create mode 100644 src/components/player/ios/hooks/useKSPlayer.ts create mode 100644 src/components/player/ios/hooks/useNextEpisode.ts create mode 100644 src/components/player/ios/hooks/useOpeningAnimation.ts create mode 100644 src/components/player/ios/hooks/usePlayerControls.ts create mode 100644 src/components/player/ios/hooks/usePlayerModals.ts create mode 100644 src/components/player/ios/hooks/usePlayerSetup.ts create mode 100644 src/components/player/ios/hooks/usePlayerState.ts create mode 100644 src/components/player/ios/hooks/usePlayerTracks.ts create mode 100644 src/components/player/ios/hooks/useSpeedControl.ts create mode 100644 src/components/player/ios/hooks/useWatchProgress.ts diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 627265e..36b6c3d 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -4,26 +4,29 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { RootStackParamList } from '../../navigation/AppNavigator'; -// Hooks -import { usePlayerState } from './android/hooks/usePlayerState'; +// Shared Hooks (cross-platform) +import { + usePlayerState, + usePlayerModals, + useSpeedControl, + useOpeningAnimation +} from './hooks'; + +// Android-specific hooks (VLC integration, dual player support) import { usePlayerSetup } from './android/hooks/usePlayerSetup'; import { useVlcPlayer } from './android/hooks/useVlcPlayer'; import { usePlayerTracks } from './android/hooks/usePlayerTracks'; import { useWatchProgress } from './android/hooks/useWatchProgress'; import { usePlayerControls } from './android/hooks/usePlayerControls'; -import { useSpeedControl } from './android/hooks/useSpeedControl'; import { useNextEpisode } from './android/hooks/useNextEpisode'; -import { useOpeningAnimation } from './android/hooks/useOpeningAnimation'; -import { usePlayerModals } from './android/hooks/usePlayerModals'; + +// App-level Hooks import { useTraktAutosync } from '../../hooks/useTraktAutosync'; import { useMetadata } from '../../hooks/useMetadata'; import { usePlayerGestureControls } from '../../hooks/usePlayerGestureControls'; -// Components -import { VideoSurface } from './android/components/VideoSurface'; -import { GestureControls } from './android/components/GestureControls'; -import { PauseOverlay } from './android/components/PauseOverlay'; -import { SpeedActivatedOverlay } from './android/components/SpeedActivatedOverlay'; +// Shared Components +import { GestureControls, PauseOverlay, SpeedActivatedOverlay } from './components'; import LoadingOverlay from './modals/LoadingOverlay'; import PlayerControls from './controls/PlayerControls'; import { AudioTrackModal } from './modals/AudioTrackModal'; @@ -33,12 +36,14 @@ import { SourcesModal } from './modals/SourcesModal'; import { EpisodesModal } from './modals/EpisodesModal'; import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal'; +// Android-specific components +import { VideoSurface } from './android/components/VideoSurface'; + // Utils import { logger } from '../../utils/logger'; import { styles } from './utils/playerStyles'; import { formatTime, isHlsStream, processUrlForVLC, getHlsHeaders, defaultAndroidHeaders } from './utils/playerUtils'; import { storageService } from '../../services/storageService'; -// SelectedTrackType removed - using string literals instead const DEBUG_MODE = false; @@ -543,6 +548,7 @@ const AndroidVideoPlayer: React.FC = () => { onClose={() => modals.setShowEpisodeStreamsModal(false)} episode={modals.selectedEpisodeForStreams} onSelectStream={handleEpisodeStreamSelect} + metadata={{ id: id, name: title }} /> diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 9bcc9f3..399589b 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -1,1825 +1,238 @@ -import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; -import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState, InteractionManager } from 'react-native'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { View, StatusBar, StyleSheet, Animated, Dimensions } from 'react-native'; +import { useNavigation, useRoute } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native'; -import FastImage from '@d11/react-native-fast-image'; -import { RootStackParamList, RootStackNavigationProp } from '../../navigation/AppNavigator'; -import { PinchGestureHandler, PanGestureHandler, TapGestureHandler, LongPressGestureHandler, State, PinchGestureHandlerGestureEvent, PanGestureHandlerGestureEvent, TapGestureHandlerGestureEvent, LongPressGestureHandlerGestureEvent } from 'react-native-gesture-handler'; -import RNImmersiveMode from 'react-native-immersive-mode'; -import * as ScreenOrientation from 'expo-screen-orientation'; -import { storageService } from '../../services/storageService'; -import { logger } from '../../utils/logger'; -import { mmkvStorage } from '../../services/mmkvStorage'; -import { MaterialIcons } from '@expo/vector-icons'; -import { LinearGradient } from 'expo-linear-gradient'; -import Slider from '@react-native-community/slider'; -import KSPlayerComponent, { KSPlayerRef, KSPlayerSource } from './KSPlayerComponent'; -import { useTraktAutosync } from '../../hooks/useTraktAutosync'; -import { useTraktAutosyncSettings } from '../../hooks/useTraktAutosyncSettings'; -import { useMetadata } from '../../hooks/useMetadata'; -import { useSettings } from '../../hooks/useSettings'; -import { usePlayerGestureControls } from '../../hooks/usePlayerGestureControls'; - -import { - DEFAULT_SUBTITLE_SIZE, - getDefaultSubtitleSize, - AudioTrack, - TextTrack, - ResizeModeType, - WyzieSubtitle, - SubtitleCue, - SubtitleSegment, - RESUME_PREF_KEY, - RESUME_PREF, - SUBTITLE_SIZE_KEY -} from './utils/playerTypes'; -import { safeDebugLog, parseSRT, DEBUG_MODE, formatTime } from './utils/playerUtils'; -import { styles } from './utils/playerStyles'; - -// Speed settings storage key -const SPEED_SETTINGS_KEY = '@nuvio_speed_settings'; -import { SubtitleModals } from './modals/SubtitleModals'; -import { AudioTrackModal } from './modals/AudioTrackModal'; -import { SpeedModal } from './modals/SpeedModal'; -// Removed ResumeOverlay usage when alwaysResume is enabled -import PlayerControls from './controls/PlayerControls'; -import CustomSubtitles from './subtitles/CustomSubtitles'; -import { SourcesModal } from './modals/SourcesModal'; -import UpNextButton from './common/UpNextButton'; -import { EpisodesModal } from './modals/EpisodesModal'; -import LoadingOverlay from './modals/LoadingOverlay'; -import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal'; -import { Episode } from '../../types/metadata'; import axios from 'axios'; -import { stremioService } from '../../services/stremioService'; -import * as Brightness from 'expo-brightness'; + +// Shared Components +import LoadingOverlay from './modals/LoadingOverlay'; +import UpNextButton from './common/UpNextButton'; +import { PlayerControls } from './controls/PlayerControls'; +import AudioTrackModal from './modals/AudioTrackModal'; +import SpeedModal from './modals/SpeedModal'; +import SubtitleModals from './modals/SubtitleModals'; +import SourcesModal from './modals/SourcesModal'; +import EpisodesModal from './modals/EpisodesModal'; +import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal'; +import CustomSubtitles from './subtitles/CustomSubtitles'; +import { SpeedActivatedOverlay, PauseOverlay, GestureControls } from './components'; + +// Platform-specific components +import { KSPlayerSurface } from './ios/components/KSPlayerSurface'; + +// Shared Hooks +import { + usePlayerState, + usePlayerModals, + useSpeedControl, + useOpeningAnimation, + usePlayerTracks, + useCustomSubtitles, + usePlayerControls, + usePlayerSetup +} from './hooks'; + +// Platform-specific hooks +import { useKSPlayer } from './ios/hooks/useKSPlayer'; + +// App-level Hooks +import { useTraktAutosync } from '../../hooks/useTraktAutosync'; +import { useMetadata } from '../../hooks/useMetadata'; +import { usePlayerGestureControls } from '../../hooks/usePlayerGestureControls'; +import stremioService from '../../services/stremioService'; +import { logger } from '../../utils/logger'; + +// Utils +import { formatTime } from './utils/playerUtils'; +import { WyzieSubtitle } from './utils/playerTypes'; +import { parseSRT } from './utils/subtitleParser'; + +// Player route params interface +interface PlayerRouteParams { + uri: string; + title: string; + episodeTitle?: string; + season?: number; + episode?: number; + quality?: string; + year?: number; + streamProvider?: string; + streamName?: string; + id: string; + type: string; + episodeId?: string; + imdbId?: string; + backdrop?: string; + availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; + headers?: Record; + initialPosition?: number; +} const KSPlayerCore: React.FC = () => { + // Navigation & Route + const navigation = useNavigation(); + const route = useRoute(); const insets = useSafeAreaInsets(); - const route = useRoute>(); - const { uri, headers, streamProvider } = route.params as any; - - - const navigation = useNavigation(); - - // KSPlayer is active only on iOS for MKV streams - const isKsPlayerActive = Platform.OS === 'ios'; + const params = route.params as PlayerRouteParams; + // Deconstruct params const { - title = 'Episode Name', + uri, title, episodeTitle, season, episode, id, type, quality, year, + episodeId, imdbId, backdrop, availableStreams, + headers, streamProvider, streamName, + initialPosition: routeInitialPosition + } = params; + + // --- Hooks --- + const playerState = usePlayerState(); + const { + paused, setPaused, + currentTime, setCurrentTime, + duration, setDuration, + buffered, setBuffered, + isBuffering, setIsBuffering, + isVideoLoaded, setIsVideoLoaded, + isPlayerReady, setIsPlayerReady, + showControls, setShowControls, + resizeMode, setResizeMode, + screenDimensions, setScreenDimensions, + zoomScale, setZoomScale, + lastZoomScale, setLastZoomScale, + isAirPlayActive, + allowsAirPlay, + isSeeking, + isMounted, + } = playerState; + + const modals = usePlayerModals(); + const speedControl = useSpeedControl(1.0); + + // Metadata Hook + const { metadata, groupedEpisodes, cast } = useMetadata({ id, type: type as 'movie' | 'series' }); + + // Trakt Autosync + const traktAutosync = useTraktAutosync({ + type: type as 'movie' | 'series', + imdbId: imdbId || (id?.startsWith('tt') ? id : ''), season, episode, - episodeTitle, - quality, - year, - streamName, + title, id, - type, - episodeId, - imdbId, - availableStreams: passedAvailableStreams, - backdrop, - groupedEpisodes - } = route.params; - - // Initialize Trakt autosync - const traktAutosync = useTraktAutosync({ - id: id || '', - type: type === 'series' ? 'series' : 'movie', - title: episodeTitle || title, - year: year || 0, - imdbId: imdbId || '', - season: season, - episode: episode, - showTitle: title, - showYear: year, - showImdbId: imdbId, - episodeId: episodeId + year: year?.toString() || metadata?.year?.toString() || '' }); - // App settings - const { settings: appSettings } = useSettings(); + const openingAnim = useOpeningAnimation(backdrop, metadata); + const tracks = usePlayerTracks(); + const { ksPlayerRef, seek } = useKSPlayer(); + const customSubs = useCustomSubtitles(); - safeDebugLog("Component mounted with props", { - uri, title, season, episode, episodeTitle, quality, year, - streamProvider, id, type, episodeId, imdbId + const controls = usePlayerControls({ + playerRef: ksPlayerRef, + paused, + setPaused, + currentTime, + duration, + isSeeking, + isMounted }); - const screenData = Dimensions.get('screen'); - const [screenDimensions, setScreenDimensions] = useState(screenData); - - // iPad/macOS-specific fullscreen handling - const isIPad = Platform.OS === 'ios' && (screenData.width > 1000 || screenData.height > 1000); - const isMacOS = Platform.OS === 'ios' && Platform.isPad === true; - const shouldUseFullscreen = isIPad || isMacOS; - - // Use window dimensions for iPad instead of screen dimensions - const windowData = Dimensions.get('window'); - const effectiveDimensions = shouldUseFullscreen ? windowData : screenData; - - // Helper to get appropriate dimensions for gesture areas and overlays - const getDimensions = () => ({ - width: shouldUseFullscreen ? windowData.width : screenDimensions.width, - height: shouldUseFullscreen ? windowData.height : screenDimensions.height, - }); - - const [paused, setPaused] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(0); - const [showControls, setShowControls] = useState(true); - const [audioTracks, setAudioTracks] = useState([]); - const [selectedAudioTrack, setSelectedAudioTrack] = useState(null); - const [textTracks, setTextTracks] = useState([]); - const [selectedTextTrack, setSelectedTextTrack] = useState(-1); - const [resizeMode, setResizeMode] = useState('contain'); - const [playerBackend, setPlayerBackend] = useState(''); - const [buffered, setBuffered] = useState(0); - const [seekPosition, setSeekPosition] = useState(null); - const ksPlayerRef = useRef(null); - const [showAudioModal, setShowAudioModal] = useState(false); - const [showSubtitleModal, setShowSubtitleModal] = useState(false); - const [showSpeedModal, setShowSpeedModal] = useState(false); - const [initialPosition, setInitialPosition] = useState(null); - const [progressSaveInterval, setProgressSaveInterval] = useState(null); - const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false); - const [showResumeOverlay, setShowResumeOverlay] = useState(false); - const [resumePosition, setResumePosition] = useState(null); - const [savedDuration, setSavedDuration] = useState(null); - const initialSeekTargetRef = useRef(null); - const initialSeekVerifiedRef = useRef(false); - const isSourceSeekableRef = useRef(null); + // Gestures const fadeAnim = useRef(new Animated.Value(1)).current; - const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false); - const [shouldHideOpeningOverlay, setShouldHideOpeningOverlay] = useState(false); - const DISABLE_OPENING_OVERLAY = false; // Enable opening overlay animation - const openingFadeAnim = useRef(new Animated.Value(0)).current; - const openingScaleAnim = useRef(new Animated.Value(0.8)).current; - const backgroundFadeAnim = useRef(new Animated.Value(1)).current; - const [isBackdropLoaded, setIsBackdropLoaded] = useState(false); - const backdropImageOpacityAnim = useRef(new Animated.Value(0)).current; - const [isBuffering, setIsBuffering] = useState(false); - const [ksAudioTracks, setKsAudioTracks] = useState>([]); - const [ksTextTracks, setKsTextTracks] = useState>([]); - const [isPlayerReady, setIsPlayerReady] = useState(false); - // Removed progressAnim and progressBarRef - no longer needed with React Native Community Slider - const [isDragging, setIsDragging] = useState(false); - const isSeeking = useRef(false); - const seekDebounceTimer = useRef(null); - const pendingSeekValue = useRef(null); - const lastSeekTime = useRef(0); - const wasPlayingBeforeDragRef = useRef(false); - const [isVideoLoaded, setIsVideoLoaded] = useState(false); - const [videoAspectRatio, setVideoAspectRatio] = useState(null); - const [is16by9Content, setIs16by9Content] = useState(false); - const [customVideoStyles, setCustomVideoStyles] = useState({}); - const [zoomScale, setZoomScale] = useState(1); - const [zoomTranslateX, setZoomTranslateX] = useState(0); - const [zoomTranslateY, setZoomTranslateY] = useState(0); - const [lastZoomScale, setLastZoomScale] = useState(1); - const [lastTranslateX, setLastTranslateX] = useState(0); - const [lastTranslateY, setLastTranslateY] = useState(0); - const pinchRef = useRef(null); - const [customSubtitles, setCustomSubtitles] = useState([]); - const [currentSubtitle, setCurrentSubtitle] = useState(''); - const [currentFormattedSegments, setCurrentFormattedSegments] = useState([]); - const [subtitleSize, setSubtitleSize] = useState(DEFAULT_SUBTITLE_SIZE); - const [subtitleBackground, setSubtitleBackground] = useState(false); - // External subtitle customization - const [subtitleTextColor, setSubtitleTextColor] = useState('#FFFFFF'); - const [subtitleBgOpacity, setSubtitleBgOpacity] = useState(0.7); - const [subtitleTextShadow, setSubtitleTextShadow] = useState(true); - const [subtitleOutline, setSubtitleOutline] = useState(true); - const [subtitleOutlineColor, setSubtitleOutlineColor] = useState('#000000'); - const [subtitleOutlineWidth, setSubtitleOutlineWidth] = useState(4); - const [subtitleAlign, setSubtitleAlign] = useState<'center' | 'left' | 'right'>('center'); - const [subtitleBottomOffset, setSubtitleBottomOffset] = useState(10); - const [subtitleLetterSpacing, setSubtitleLetterSpacing] = useState(0); - const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState(1.2); - const [subtitleOffsetSec, setSubtitleOffsetSec] = useState(0); - const [useCustomSubtitles, setUseCustomSubtitles] = useState(false); - const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false); - const [availableSubtitles, setAvailableSubtitles] = useState([]); - const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false); - const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false); - const [showSourcesModal, setShowSourcesModal] = useState(false); - const [showEpisodesModal, setShowEpisodesModal] = useState(false); - const [showEpisodeStreamsModal, setShowEpisodeStreamsModal] = useState(false); - const [selectedEpisodeForStreams, setSelectedEpisodeForStreams] = useState(null); - const [availableStreams, setAvailableStreams] = useState<{ [providerId: string]: { streams: any[]; addonName: string } }>(passedAvailableStreams || {}); - // Playback speed controls required by PlayerControls - const speedOptions = [0.5, 1.0, 1.25, 1.5, 2.0, 2.5]; - const [playbackSpeed, setPlaybackSpeed] = useState(1.0); - // Hold-to-speed-up feature state - const [holdToSpeedEnabled, setHoldToSpeedEnabled] = useState(true); - const [holdToSpeedValue, setHoldToSpeedValue] = useState(2.0); - const [isSpeedBoosted, setIsSpeedBoosted] = useState(false); - const [originalSpeed, setOriginalSpeed] = useState(1.0); - const [showSpeedActivatedOverlay, setShowSpeedActivatedOverlay] = useState(false); - const speedActivatedOverlayOpacity = useRef(new Animated.Value(0)).current; - const cyclePlaybackSpeed = useCallback(() => { - const idx = speedOptions.indexOf(playbackSpeed); - const nextIdx = (idx + 1) % speedOptions.length; - setPlaybackSpeed(speedOptions[nextIdx]); - }, [playbackSpeed, speedOptions]); - const [currentStreamUrl, setCurrentStreamUrl] = useState(uri); - const [showErrorModal, setShowErrorModal] = useState(false); - const [errorDetails, setErrorDetails] = useState(''); - const errorTimeoutRef = useRef(null); - const [currentQuality, setCurrentQuality] = useState(quality); - const [currentStreamProvider, setCurrentStreamProvider] = useState(streamProvider); - const [currentStreamName, setCurrentStreamName] = useState(streamName); - const [lastAudioTrackCheck, setLastAudioTrackCheck] = useState(0); - const [audioTrackFallbackAttempts, setAudioTrackFallbackAttempts] = useState(0); - const isMounted = useRef(true); + // Controls timeout const controlsTimeout = useRef(null); - const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false); - - // AirPlay state - const [isAirPlayActive, setIsAirPlayActive] = useState(false); - const [allowsAirPlay, setAllowsAirPlay] = useState(true); - - // Silent startup-timeout retry state - const startupRetryCountRef = useRef(0); - const startupRetryTimerRef = useRef(null); - const MAX_STARTUP_RETRIES = 3; - - // Pause overlay state - const [showPauseOverlay, setShowPauseOverlay] = useState(false); - const pauseOverlayTimerRef = useRef(null); - const pauseOverlayOpacity = useRef(new Animated.Value(0)).current; - const pauseOverlayTranslateY = useRef(new Animated.Value(12)).current; - const metadataOpacity = useRef(new Animated.Value(1)).current; - const metadataScale = useRef(new Animated.Value(1)).current; - - // Next episode loading state - const [isLoadingNextEpisode, setIsLoadingNextEpisode] = useState(false); - const [nextLoadingProvider, setNextLoadingProvider] = useState(null); - const [nextLoadingQuality, setNextLoadingQuality] = useState(null); - const [nextLoadingTitle, setNextLoadingTitle] = useState(null); - - // Cast display state - const [selectedCastMember, setSelectedCastMember] = useState(null); - const [showCastDetails, setShowCastDetails] = useState(false); - const castDetailsOpacity = useRef(new Animated.Value(0)).current; - const castDetailsScale = useRef(new Animated.Value(0.95)).current; - - // Volume and brightness controls - const [volume, setVolume] = useState(100); // KSPlayer uses 0-100 range - const [brightness, setBrightness] = useState(1.0); - const [subtitleSettingsLoaded, setSubtitleSettingsLoaded] = useState(false); - - // Use reusable gesture controls hook - const gestureControls = usePlayerGestureControls({ - volume, - setVolume, - brightness, - setBrightness, - volumeRange: { min: 0, max: 100 }, // KSPlayer uses 0-100 - volumeSensitivity: 0.006, - brightnessSensitivity: 0.004, - debugMode: DEBUG_MODE, - }); - - // Load speed settings from storage - const loadSpeedSettings = useCallback(async () => { - try { - const saved = await mmkvStorage.getItem(SPEED_SETTINGS_KEY); - if (saved) { - const settings = JSON.parse(saved); - if (typeof settings.holdToSpeedEnabled === 'boolean') { - setHoldToSpeedEnabled(settings.holdToSpeedEnabled); - } - if (typeof settings.holdToSpeedValue === 'number') { - setHoldToSpeedValue(settings.holdToSpeedValue); - } - } - } catch (error) { - logger.warn('[KSPlayerCore] Error loading speed settings:', error); - } - }, []); - - // Save speed settings to storage - const saveSpeedSettings = useCallback(async () => { - try { - const settings = { - holdToSpeedEnabled, - holdToSpeedValue, - }; - await mmkvStorage.setItem(SPEED_SETTINGS_KEY, JSON.stringify(settings)); - } catch (error) { - logger.warn('[KSPlayerCore] Error saving speed settings:', error); - } - }, [holdToSpeedEnabled, holdToSpeedValue]); - - // Load speed settings on mount - useEffect(() => { - loadSpeedSettings(); - }, [loadSpeedSettings]); - - // Save speed settings when they change - useEffect(() => { - saveSpeedSettings(); - }, [saveSpeedSettings]); - - // Get metadata to access logo (only if we have a valid id) - const shouldLoadMetadata = Boolean(id && type); - const metadataResult = useMetadata({ - id: id || 'placeholder', - type: type || 'movie' - }); - const { metadata, loading: metadataLoading, groupedEpisodes: metadataGroupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => { } }; - const { settings } = useSettings(); - - // Logo animation values - const logoScaleAnim = useRef(new Animated.Value(0.8)).current; - const logoOpacityAnim = useRef(new Animated.Value(0)).current; - const pulseAnim = useRef(new Animated.Value(1)).current; - - // Check if we have a logo to show - const hasLogo = metadata && metadata.logo && !metadataLoading; - - // Load custom backdrop on mount - // Prefetch backdrop and title logo for faster loading screen appearance - useEffect(() => { - // Defer prefetching until after navigation animation completes - const task = InteractionManager.runAfterInteractions(() => { - if (backdrop && typeof backdrop === 'string') { - // Reset loading state - setIsBackdropLoaded(false); - backdropImageOpacityAnim.setValue(0); - - // Prefetch the image - try { - FastImage.preload([{ uri: backdrop }]); - // Image prefetch initiated, fade it in smoothly - setIsBackdropLoaded(true); - Animated.timing(backdropImageOpacityAnim, { - toValue: 1, - duration: 400, - useNativeDriver: true, - }).start(); - } catch (error) { - // If prefetch fails, still show the image but without animation - if (__DEV__) logger.warn('[VideoPlayer] Backdrop prefetch failed, showing anyway:', error); - setIsBackdropLoaded(true); - backdropImageOpacityAnim.setValue(1); - } - } else { - // No backdrop provided, consider it "loaded" - setIsBackdropLoaded(true); - backdropImageOpacityAnim.setValue(0); - } - }); - return () => task.cancel(); - }, [backdrop]); - - useEffect(() => { - // Defer logo prefetch until after navigation animation - const task = InteractionManager.runAfterInteractions(() => { - const logoUrl = (metadata && (metadata as any).logo) as string | undefined; - if (logoUrl && typeof logoUrl === 'string') { - try { - FastImage.preload([{ uri: logoUrl }]); - } catch (error) { - // Silently ignore logo prefetch errors - } - } - }); - return () => task.cancel(); - }, [metadata]); - - // Log video source configuration with headers - useEffect(() => { - console.log('[KSPlayerCore] Video source configured with:', { - uri: currentStreamUrl, - hasHeaders: !!(headers && Object.keys(headers).length > 0), - headers: headers && Object.keys(headers).length > 0 ? headers : undefined - }); - }, [currentStreamUrl, headers]); - // Resolve current episode description for series - const currentEpisodeDescription = (() => { - try { - if (type !== 'series') return ''; - const allEpisodes = Object.values(groupedEpisodes || {}).flat() as any[]; - if (!allEpisodes || allEpisodes.length === 0) return ''; - let match: any | null = null; - if (episodeId) { - match = allEpisodes.find(ep => ep?.stremioId === episodeId || String(ep?.id) === String(episodeId)); - } - if (!match && season && episode) { - match = allEpisodes.find(ep => ep?.season_number === season && ep?.episode_number === episode); - } - return (match?.overview || '').trim(); - } catch { - return ''; - } - })(); - - // Find next episode for series (fallback to metadataGroupedEpisodes when needed) - const nextEpisode = useMemo(() => { - try { - if (type !== 'series' || !season || !episode) return null; - const sourceGroups = groupedEpisodes && Object.keys(groupedEpisodes || {}).length > 0 - ? groupedEpisodes - : (metadataGroupedEpisodes || {}); - const allEpisodes = Object.values(sourceGroups || {}).flat() as any[]; - if (!allEpisodes || allEpisodes.length === 0) return null; - - // First try next episode in same season - let nextEp = allEpisodes.find((ep: any) => - ep.season_number === season && ep.episode_number === episode + 1 - ); - - // If not found, try first episode of next season - if (!nextEp) { - nextEp = allEpisodes.find((ep: any) => - ep.season_number === season + 1 && ep.episode_number === 1 - ); - } - - if (DEBUG_MODE) { - logger.log('[KSPlayerCore] nextEpisode computation', { - fromRouteGroups: !!(groupedEpisodes && Object.keys(groupedEpisodes || {}).length), - fromMetadataGroups: !!(metadataGroupedEpisodes && Object.keys(metadataGroupedEpisodes || {}).length), - allEpisodesCount: allEpisodes?.length || 0, - currentSeason: season, - currentEpisode: episode, - found: !!nextEp, - foundId: nextEp?.stremioId || nextEp?.id, - foundName: nextEp?.name, - }); - } - return nextEp; - } catch { - return null; - } - }, [type, season, episode, groupedEpisodes, metadataGroupedEpisodes]); - - // Small offset (in seconds) used to avoid seeking to the *exact* end of the - // file which triggers the `onEnd` callback and causes playback to restart. - const END_EPSILON = 0.3; - - const hideControls = () => { - // Do not hide while user is interacting with the slider - if (isDragging) { - return; - } + const hideControls = useCallback(() => { + // Allow hiding controls even when paused (per user request) + setShowControls(false); Animated.timing(fadeAnim, { toValue: 0, duration: 300, useNativeDriver: true, - }).start(() => setShowControls(false)); - }; + }).start(); + }, [fadeAnim, setShowControls]); - const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => { - return { - position: 'absolute', - top: 0, - left: 0, - width: screenWidth, - height: screenHeight, - }; - }; + // Volume/Brightness State + const [volume, setVolumeState] = useState(1.0); + const [brightness, setBrightnessState] = useState(0.5); + const [isSliderDragging, setIsSliderDragging] = useState(false); - const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => { - const { scale } = event.nativeEvent; - const newScale = Math.max(1, Math.min(lastZoomScale * scale, 1.1)); - setZoomScale(newScale); - if (DEBUG_MODE) { - if (__DEV__) logger.log(`[VideoPlayer] Center Zoom: ${newScale.toFixed(2)}x`); - } - }; + // Watch Progress State + const [initialPosition, setInitialPosition] = useState(routeInitialPosition || null); - const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => { - if (event.nativeEvent.state === State.END) { - setLastZoomScale(zoomScale); - if (DEBUG_MODE) { - if (__DEV__) logger.log(`[VideoPlayer] Pinch ended - saved scale: ${zoomScale.toFixed(2)}x`); - } - } - }; + // Shared Gesture Hook + const gestureControls = usePlayerGestureControls({ + volume: volume, + setVolume: (v) => setVolumeState(v), + brightness: brightness, + setBrightness: (b) => setBrightnessState(b), + }); - const resetZoom = () => { - const targetZoom = is16by9Content ? 1.1 : 1; - setZoomScale(targetZoom); - setLastZoomScale(targetZoom); - if (DEBUG_MODE) { - if (__DEV__) logger.log(`[VideoPlayer] Zoom reset to ${targetZoom}x (16:9: ${is16by9Content})`); - } - }; + // Setup Hook (Listeners, StatusBar, etc) + usePlayerSetup({ + setScreenDimensions, + setVolume: setVolumeState, + setBrightness: setBrightnessState, + isOpeningAnimationComplete: openingAnim.isOpeningAnimationComplete + }); - // Long press gesture handlers for speed boost - const onLongPressActivated = useCallback(() => { - if (!holdToSpeedEnabled) return; + // Refs for Logic + const isSyncingBeforeClose = useRef(false); - if (!isSpeedBoosted && playbackSpeed !== holdToSpeedValue) { - setOriginalSpeed(playbackSpeed); - setPlaybackSpeed(holdToSpeedValue); - setIsSpeedBoosted(true); - - // Show "Activated" overlay - setShowSpeedActivatedOverlay(true); - Animated.spring(speedActivatedOverlayOpacity, { - toValue: 1, - tension: 100, - friction: 8, - useNativeDriver: true, - }).start(); - - // Auto-hide after 2 seconds - setTimeout(() => { - Animated.timing(speedActivatedOverlayOpacity, { - toValue: 0, - duration: 300, - useNativeDriver: true, - }).start(() => { - setShowSpeedActivatedOverlay(false); - }); - }, 2000); - - logger.log(`[KSPlayerCore] Speed boost activated: ${holdToSpeedValue}x`); - } - }, [isSpeedBoosted, playbackSpeed, holdToSpeedEnabled, holdToSpeedValue, speedActivatedOverlayOpacity]); - - const restoreSpeedSafely = useCallback(() => { - if (isSpeedBoosted) { - setPlaybackSpeed(originalSpeed); - setIsSpeedBoosted(false); - logger.log('[KSPlayerCore] Speed boost deactivated, restored to:', originalSpeed); - } - }, [isSpeedBoosted, originalSpeed]); - - const onLongPressEnd = useCallback(() => { - restoreSpeedSafely(); - }, [restoreSpeedSafely]); - - const onLongPressStateChange = useCallback((event: LongPressGestureHandlerGestureEvent) => { - // Ensure restoration on cancel/fail/end as well - // @ts-ignore - numeric State enum - const state = event?.nativeEvent?.state; - if (state === State.CANCELLED || state === State.FAILED || state === State.END) { - restoreSpeedSafely(); - } - }, [restoreSpeedSafely]); - - // Safety: restore speed on unmount if still boosted - useEffect(() => { - return () => { - if (isSpeedBoosted) { - try { setPlaybackSpeed(originalSpeed); } catch { } - } - }; - }, [isSpeedBoosted, originalSpeed]); - - useEffect(() => { - if (videoAspectRatio && effectiveDimensions.width > 0 && effectiveDimensions.height > 0) { - const styles = calculateVideoStyles( - videoAspectRatio * 1000, - 1000, - effectiveDimensions.width, - effectiveDimensions.height - ); - setCustomVideoStyles(styles); - if (DEBUG_MODE) { - if (__DEV__) logger.log(`[VideoPlayer] Screen dimensions changed, recalculated styles:`, styles); - } - } - }, [effectiveDimensions, videoAspectRatio]); - - // Force landscape orientation after opening animation completes - useEffect(() => { - // Defer orientation lock until after navigation animation to prevent sluggishness - if (isOpeningAnimationComplete) { - const task = InteractionManager.runAfterInteractions(() => { - ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE) - .then(() => { - if (__DEV__) logger.log('[VideoPlayer] Locked to landscape orientation'); - }) - .catch((error) => { - logger.warn('[VideoPlayer] Failed to lock orientation:', error); - }); - }); - return () => task.cancel(); - } - return () => { - // Do not unlock orientation here; we unlock explicitly on close to avoid mid-transition flips - }; - }, [isOpeningAnimationComplete]); - - useEffect(() => { - const subscription = Dimensions.addEventListener('change', ({ screen }) => { - setScreenDimensions(screen); - // Re-apply immersive mode on layout changes (Android) - only after opening animation - if (isOpeningAnimationComplete) { - enableImmersiveMode(); - } - }); - - // Immediate player setup - UI critical - StatusBar.setHidden(true, 'none'); - // Enable immersive mode after opening animation to prevent glitches - if (isOpeningAnimationComplete) { - enableImmersiveMode(); - } - startOpeningAnimation(); - - // Initialize volume immediately (no async) - setVolume(100); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Initial volume: 100 (KSPlayer native)`); - } - - // Defer brightness initialization until after navigation animation completes - // This prevents sluggish player entry - const brightnessTask = InteractionManager.runAfterInteractions(async () => { - try { - const currentBrightness = await Brightness.getBrightnessAsync(); - setBrightness(currentBrightness); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Initial brightness: ${currentBrightness}`); - } - } catch (error) { - logger.warn('[VideoPlayer] Error getting initial brightness:', error); - // Fallback to 1.0 if brightness API fails - setBrightness(1.0); - } - }); - - return () => { - subscription?.remove(); - brightnessTask.cancel(); - disableImmersiveMode(); - }; - }, [isOpeningAnimationComplete]); - - // Re-apply immersive mode when screen gains focus (Android) - useFocusEffect( - useCallback(() => { - if (isOpeningAnimationComplete) { - enableImmersiveMode(); - } - return () => { }; - }, [isOpeningAnimationComplete]) - ); - - // Re-apply immersive mode when app returns to foreground (Android) - useEffect(() => { - const onAppStateChange = (state: string) => { - if (state === 'active' && isOpeningAnimationComplete) { - enableImmersiveMode(); - } - }; - const sub = AppState.addEventListener('change', onAppStateChange); - return () => { - sub.remove(); - }; - }, [isOpeningAnimationComplete]); - - const startOpeningAnimation = () => { - // Logo entrance animation - optimized for faster appearance - Animated.parallel([ - Animated.timing(logoOpacityAnim, { - toValue: 1, - duration: 300, // Reduced from 600ms to 300ms - useNativeDriver: true, - }), - Animated.spring(logoScaleAnim, { - toValue: 1, - tension: 80, // Increased tension for faster spring - friction: 8, - useNativeDriver: true, - }), - ]).start(); - - // Continuous pulse animation for the logo - const createPulseAnimation = () => { - return Animated.sequence([ - Animated.timing(pulseAnim, { - toValue: 1.05, - duration: 800, // Reduced from 1000ms to 800ms - useNativeDriver: true, - }), - Animated.timing(pulseAnim, { - toValue: 1, - duration: 800, // Reduced from 1000ms to 800ms - useNativeDriver: true, - }), - ]); - }; - - const loopPulse = () => { - createPulseAnimation().start(() => { - if (!isOpeningAnimationComplete) { - loopPulse(); - } - }); - }; - - // Start pulsing immediately without delay - // Removed the 800ms delay - loopPulse(); - }; - - const completeOpeningAnimation = () => { - // Stop the pulse animation immediately - pulseAnim.stopAnimation(); - - Animated.parallel([ - Animated.timing(openingFadeAnim, { - toValue: 1, - duration: 300, // Reduced from 600ms to 300ms - useNativeDriver: true, - }), - Animated.timing(openingScaleAnim, { - toValue: 1, - duration: 350, // Reduced from 700ms to 350ms - useNativeDriver: true, - }), - Animated.timing(backgroundFadeAnim, { - toValue: 0, - duration: 400, // Reduced from 800ms to 400ms - useNativeDriver: true, - }), - ]).start(() => { - setIsOpeningAnimationComplete(true); - // Delay hiding the overlay to allow background fade animation to complete - setTimeout(() => { - setShouldHideOpeningOverlay(true); - }, 450); // Slightly longer than the background fade duration - // Enable immersive mode and lock orientation now that animation is complete - enableImmersiveMode(); - }); - }; - - useEffect(() => { - const loadWatchProgress = async () => { - if (id && type) { - try { - if (__DEV__) { - logger.log(`[VideoPlayer] Loading watch progress for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`); - } - const savedProgress = await storageService.getWatchProgress(id, type, episodeId); - if (__DEV__) { - logger.log(`[VideoPlayer] Saved progress:`, savedProgress); - } - - if (savedProgress) { - const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; - if (__DEV__) logger.log(`[VideoPlayer] Progress: ${progressPercent.toFixed(1)}% (${savedProgress.currentTime}/${savedProgress.duration})`); - - if (progressPercent < 85) { - setResumePosition(savedProgress.currentTime); - setSavedDuration(savedProgress.duration); - if (__DEV__) logger.log(`[VideoPlayer] Set resume position to: ${savedProgress.currentTime} of ${savedProgress.duration}`); - if (appSettings.alwaysResume) { - // Only prepare auto-resume state and seek when AlwaysResume is enabled - setInitialPosition(savedProgress.currentTime); - initialSeekTargetRef.current = savedProgress.currentTime; - if (__DEV__) logger.log(`[VideoPlayer] AlwaysResume enabled. Auto-seeking to ${savedProgress.currentTime}`); - // Seek immediately after load - seekToTime(savedProgress.currentTime); - } else { - // Do not set initialPosition; start from beginning with no auto-seek - setShowResumeOverlay(true); - if (__DEV__) logger.log(`[VideoPlayer] AlwaysResume disabled. Not auto-seeking; overlay shown (if enabled)`); - } - } else { - if (__DEV__) logger.log(`[VideoPlayer] Progress too high (${progressPercent.toFixed(1)}%), not showing resume overlay`); - } - } else { - logger.log(`[VideoPlayer] No saved progress found`); - } - } catch (error) { - logger.error('[VideoPlayer] Error loading watch progress:', error); - } - } else { - if (__DEV__) logger.log(`[VideoPlayer] Missing id or type: id=${id}, type=${type}`); - } - }; - loadWatchProgress(); - }, [id, type, episodeId, appSettings.alwaysResume]); - - const saveWatchProgress = async () => { - if (id && type && currentTime > 0 && duration > 0) { - const progress = { - currentTime, - duration, - lastUpdated: Date.now() - }; - try { - await storageService.setWatchProgress(id, type, progress, episodeId); - - // Sync to Trakt if authenticated - await traktAutosync.handleProgressUpdate(currentTime, duration); - } catch (error) { - logger.error('[VideoPlayer] Error saving watch progress:', error); - } - } - }; - - useEffect(() => { - if (id && type && !paused && duration > 0) { - if (progressSaveInterval) { - clearInterval(progressSaveInterval); - } - - const syncInterval = 20000; // 20s to further reduce CPU load - - const interval = setInterval(() => { - saveWatchProgress(); - }, syncInterval); - - setProgressSaveInterval(interval); - return () => { - clearInterval(interval); - setProgressSaveInterval(null); - }; - } - }, [id, type, paused, duration]); - - // Use refs to track latest values for unmount cleanup without causing effect re-runs - const currentTimeRef = useRef(currentTime); - const durationRef = useRef(duration); - - // Keep refs updated with latest values - useEffect(() => { - currentTimeRef.current = currentTime; - }, [currentTime]); - - useEffect(() => { - durationRef.current = duration; - }, [duration]); - - // Cleanup effect - only runs on actual component unmount - useEffect(() => { - return () => { - if (id && type && durationRef.current > 0) { - saveWatchProgress(); - // Final Trakt sync on component unmount - traktAutosync.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'unmount'); - } - }; - }, [id, type]); // Only id and type - NOT currentTime or duration - - const onPlaying = () => { - if (isMounted.current && !isSeeking.current) { - setPaused(false); - - // Note: handlePlaybackStart is already called in onLoad - // We don't need to call it again here to avoid duplicate calls - } - }; - - const onPaused = () => { - if (isMounted.current) { - setPaused(true); - // Reset the wasPlayingBeforeDrag ref so that seeking while paused doesn't resume playback - wasPlayingBeforeDragRef.current = false; - - // IMMEDIATE: Send immediate pause update to Trakt when user pauses - if (duration > 0) { - traktAutosync.handleProgressUpdate(currentTime, duration, true); // force=true triggers immediate sync - } - } - }; - - const seekToTime = (rawSeconds: number) => { - // For KSPlayer, we need to wait for the player to be ready - if (!ksPlayerRef.current || isSeeking.current) { - if (DEBUG_MODE) { - logger.error(`[VideoPlayer] Seek failed: ksPlayerRef=${!!ksPlayerRef.current}, seeking=${isSeeking.current}`); - } - return; - } - - // Clamp to just before the end to avoid triggering onEnd when duration is known. - const timeInSeconds = duration > 0 - ? Math.max(0, Math.min(rawSeconds, duration - END_EPSILON)) - : Math.max(0, rawSeconds); - - if (DEBUG_MODE) { - if (__DEV__) logger.log(`[VideoPlayer] Seeking to ${timeInSeconds.toFixed(2)}s out of ${duration.toFixed(2)}s`); - } - - isSeeking.current = true; - - // KSPlayer uses direct time seeking - ksPlayerRef.current.seek(timeInSeconds); - - setTimeout(() => { - if (isMounted.current) { - isSeeking.current = false; - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] KSPlayer seek completed to ${timeInSeconds.toFixed(2)}s`); - } - - // IMMEDIATE SYNC: Update Trakt progress immediately after seeking - traktAutosync.handleProgressUpdate(timeInSeconds, duration, true); // force=true for immediate sync - } - }, 500); - }; - - // Slider callback functions for React Native Community Slider - const handleSliderValueChange = (value: number) => { - if (isDragging && duration > 0) { - const seekTime = Math.min(value, duration - END_EPSILON); - setCurrentTime(seekTime); - pendingSeekValue.current = seekTime; - } - }; - - const handleSlidingStart = () => { - setIsDragging(true); - // Remember if we were playing before the user started dragging - wasPlayingBeforeDragRef.current = !paused; - // Keep controls visible while dragging and cancel any hide timeout - if (!showControls) setShowControls(true); + // Toggle controls wrapper + const toggleControls = useCallback(() => { if (controlsTimeout.current) { clearTimeout(controlsTimeout.current); controlsTimeout.current = null; } - }; - - const handleSlidingComplete = (value: number) => { - setIsDragging(false); - if (duration > 0) { - const seekTime = Math.min(value, duration - END_EPSILON); - seekToTime(seekTime); - // Only resume playback if the video was playing before the drag AND is not currently paused - // This ensures that if the user paused during or before the drag, it stays paused - if (wasPlayingBeforeDragRef.current && !paused) { - setTimeout(() => { - if (isMounted.current) { - setPaused(false); - } - }, 350); + setShowControls(prev => { + const next = !prev; + Animated.timing(fadeAnim, { + toValue: next ? 1 : 0, + duration: 300, + useNativeDriver: true, + }).start(); + // Start auto-hide timer if showing controls and not paused + if (next && !paused) { + controlsTimeout.current = setTimeout(hideControls, 5000); } - pendingSeekValue.current = null; - } - // Restart auto-hide timer after interaction finishes - if (controlsTimeout.current) { - clearTimeout(controlsTimeout.current); - } - if (!showControls) setShowControls(true); - controlsTimeout.current = setTimeout(hideControls, 5000); - }; + return next; + }); + }, [fadeAnim, hideControls, setShowControls, paused]); - // Ensure auto-hide resumes after drag ends + // Auto-hide controls when playback resumes useEffect(() => { - if (!isDragging && showControls) { + if (showControls && !paused) { + // Reset auto-hide timer when playback resumes if (controlsTimeout.current) { clearTimeout(controlsTimeout.current); } controlsTimeout.current = setTimeout(hideControls, 5000); - } - }, [isDragging, showControls]); - - // Removed processProgressTouch - no longer needed with React Native Community Slider - - const handleProgress = (event: any) => { - if (isDragging || isSeeking.current) return; - - // KSPlayer returns times in seconds directly - const currentTimeInSeconds = event.currentTime; - const durationInSeconds = event.duration; - - // Update duration if it's available and different - if (durationInSeconds > 0 && durationInSeconds !== duration) { - setDuration(durationInSeconds); - } - - // Only update if there's a significant change to avoid unnecessary updates - if (Math.abs(currentTimeInSeconds - currentTime) > 0.5) { - safeSetState(() => setCurrentTime(currentTimeInSeconds)); - // KSPlayer returns bufferTime in seconds - const bufferedTime = event.bufferTime || currentTimeInSeconds; - safeSetState(() => setBuffered(bufferedTime)); - } - - // Update AirPlay state if available - if (event.airPlayState) { - const wasAirPlayActive = isAirPlayActive; - setIsAirPlayActive(event.airPlayState.isExternalPlaybackActive); - setAllowsAirPlay(event.airPlayState.allowsExternalPlayback); - - // Log AirPlay state changes for debugging - if (wasAirPlayActive !== event.airPlayState.isExternalPlaybackActive) { - if (__DEV__) logger.log(`[VideoPlayer] AirPlay state changed: ${event.airPlayState.isExternalPlaybackActive ? 'ACTIVE' : 'INACTIVE'}`); + } else if (paused) { + // Clear timeout when paused - user controls when to hide + if (controlsTimeout.current) { + clearTimeout(controlsTimeout.current); + controlsTimeout.current = null; } } - - // Safety: if audio is advancing but onLoad didn't fire, dismiss opening overlay - if (!isOpeningAnimationComplete) { - setIsVideoLoaded(true); - setIsPlayerReady(true); - completeOpeningAnimation(); - } - - // Periodic check for disabled audio track (every 3 seconds, max 3 attempts) - const now = Date.now(); - if (now - lastAudioTrackCheck > 3000 && !paused && duration > 0 && audioTrackFallbackAttempts < 3) { - setLastAudioTrackCheck(now); - - // Check if audio track is disabled (-1) and we have available tracks - if (selectedAudioTrack === -1 && ksAudioTracks.length > 1) { - logger.warn('[VideoPlayer] Detected disabled audio track, attempting fallback'); - - // Find a fallback audio track (prefer stereo/standard formats) - const fallbackTrack = ksAudioTracks.find((track, index) => { - const trackName = (track.name || '').toLowerCase(); - const trackLang = (track.language || '').toLowerCase(); - // Prefer stereo, AAC, or standard audio formats, avoid heavy codecs - return !trackName.includes('truehd') && - !trackName.includes('dts') && - !trackName.includes('dolby') && - !trackName.includes('atmos') && - !trackName.includes('7.1') && - !trackName.includes('5.1') && - index !== selectedAudioTrack; // Don't select the same track - }); - - if (fallbackTrack) { - const fallbackIndex = ksAudioTracks.indexOf(fallbackTrack); - logger.warn(`[VideoPlayer] Switching to fallback audio track: ${fallbackTrack.name || 'Unknown'} (index: ${fallbackIndex})`); - - // Increment fallback attempts counter - setAudioTrackFallbackAttempts(prev => prev + 1); - - // Switch to fallback audio track - setSelectedAudioTrack(fallbackIndex); - - // Brief pause to allow track switching - setPaused(true); - setTimeout(() => { - if (isMounted.current) { - setPaused(false); - } - }, 500); - } else { - logger.warn('[VideoPlayer] No suitable fallback audio track found'); - // Increment attempts even if no fallback found to prevent infinite checking - setAudioTrackFallbackAttempts(prev => prev + 1); - } - } - } - }; - - const onLoad = (data: any) => { - try { - if (DEBUG_MODE) { - logger.log('[VideoPlayer] Video loaded:', data); - } - // Clear any pending startup silent retry timers and counters on success - if (startupRetryTimerRef.current) { - clearTimeout(startupRetryTimerRef.current); - startupRetryTimerRef.current = null; - } - startupRetryCountRef.current = 0; - if (!isMounted.current) { - logger.warn('[VideoPlayer] Component unmounted, skipping onLoad'); - return; - } - if (!data) { - logger.error('[VideoPlayer] onLoad called with null/undefined data'); - return; - } - // Extract player backend information - if (data.playerBackend) { - const newPlayerBackend = data.playerBackend; - setPlayerBackend(newPlayerBackend); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Player backend: ${newPlayerBackend}`); - } - - // Reset AirPlay state if switching to KSMEPlayer (which doesn't support AirPlay) - if (newPlayerBackend === 'KSMEPlayer' && (isAirPlayActive || allowsAirPlay)) { - setIsAirPlayActive(false); - setAllowsAirPlay(false); - if (DEBUG_MODE) { - logger.log('[VideoPlayer] Reset AirPlay state for KSMEPlayer'); - } - } - } - - // KSPlayer returns duration in seconds directly - const videoDuration = data.duration; - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Setting duration to: ${videoDuration}`); - } - if (videoDuration > 0) { - setDuration(videoDuration); - - // Store the actual duration for future reference and update existing progress - if (id && type) { - storageService.setContentDuration(id, type, videoDuration, episodeId); - storageService.updateProgressDuration(id, type, videoDuration, episodeId); - - // Update the saved duration for resume overlay if it was using an estimate - if (savedDuration && Math.abs(savedDuration - videoDuration) > 60) { - setSavedDuration(videoDuration); - } - } - } - - // Set aspect ratio from naturalSize (KSPlayer format) - if (data.naturalSize && data.naturalSize.width && data.naturalSize.height) { - setVideoAspectRatio(data.naturalSize.width / data.naturalSize.height); - } else { - // Fallback to 16:9 aspect ratio if naturalSize is not available - setVideoAspectRatio(16 / 9); - logger.warn('[VideoPlayer] naturalSize not available, using default 16:9 aspect ratio'); - } - - if (data.audioTracks && data.audioTracks.length > 0) { - // Enhanced debug logging to see all available fields - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Raw audio tracks data:`, data.audioTracks); - data.audioTracks.forEach((track: any, idx: number) => { - logger.log(`[VideoPlayer] Track ${idx} raw data:`, { - id: track.id, - name: track.name, - language: track.language, - languageCode: track.languageCode, - isEnabled: track.isEnabled, - bitRate: track.bitRate, - bitDepth: track.bitDepth, - allKeys: Object.keys(track), - fullTrackObject: track - }); - }); - } - - const formattedAudioTracks = data.audioTracks.map((track: any, index: number) => { - const trackIndex = track.id !== undefined ? track.id : index; - - // Build comprehensive track name from available fields - let trackName = ''; - const parts = []; - - // Add language if available - let language = track.language || track.languageCode; - - if (language && language !== 'Unknown' && language !== 'und' && language !== '') { - parts.push(language.toUpperCase()); - } - - // Add bitrate if available - const bitrate = track.bitRate; - if (bitrate && bitrate > 0) { - parts.push(`${Math.round(bitrate / 1000)}kbps`); - } - - // Add bit depth if available - const bitDepth = track.bitDepth; - if (bitDepth && bitDepth > 0) { - parts.push(`${bitDepth}bit`); - } - - // Add track name if available and not generic - let title = track.name; - if (title && !title.match(/^(Audio|Track)\s*\d*$/i) && title !== 'Unknown') { - // Clean up title by removing language brackets and trailing punctuation - title = title.replace(/\s*\[[^\]]+\]\s*[-–—]*\s*$/, '').trim(); - if (title && title !== 'Unknown') { - parts.push(title); - } - } - - // Combine parts or fallback to generic name - if (parts.length > 0) { - trackName = parts.join(' • '); - } else { - // For simple track names like "Track 1", "Audio 1", etc., use them as-is - const simpleName = track.name; - if (simpleName && simpleName.match(/^(Track|Audio)\s*\d*$/i)) { - trackName = simpleName; - } else { - trackName = `Audio ${index + 1}`; - } - } - - const trackLanguage = language || 'Unknown'; - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Processed KSPlayer track ${index}:`, { - id: trackIndex, - name: trackName, - language: trackLanguage, - parts: parts, - bitRate: bitrate, - bitDepth: bitDepth - }); - } - - return { - id: trackIndex, // Use the actual track ID from KSPlayer - name: trackName, - language: trackLanguage, - }; - }); - setKsAudioTracks(formattedAudioTracks); - - // Auto-select English audio track if available, otherwise first track - if (selectedAudioTrack === null && formattedAudioTracks.length > 0) { - // Look for English track first - const englishTrack = formattedAudioTracks.find((track: { id: number, name: string, language?: string }) => { - const lang = (track.language || '').toLowerCase(); - return lang === 'english' || lang === 'en' || lang === 'eng' || - (track.name && track.name.toLowerCase().includes('english')); - }); - - const selectedTrack = englishTrack || formattedAudioTracks[0]; - setSelectedAudioTrack(selectedTrack.id); - - if (DEBUG_MODE) { - if (englishTrack) { - logger.log(`[VideoPlayer] Auto-selected English audio track: ${selectedTrack.name} (ID: ${selectedTrack.id})`); - } else { - logger.log(`[VideoPlayer] No English track found, auto-selected first audio track: ${selectedTrack.name} (ID: ${selectedTrack.id})`); - } - } - } - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Formatted audio tracks:`, formattedAudioTracks); - } - } - if (data.textTracks && data.textTracks.length > 0) { - // Process KSPlayer text tracks - const formattedTextTracks = data.textTracks.map((track: any, index: number) => ({ - id: track.id !== undefined ? track.id : index, - name: track.name || `Subtitle ${index + 1}`, - language: track.language || track.languageCode || 'Unknown', - isEnabled: track.isEnabled || false, - isImageSubtitle: track.isImageSubtitle || false - })); - - setKsTextTracks(formattedTextTracks); - - // Auto-select English subtitle track if available - if (selectedTextTrack === -1 && !useCustomSubtitles && formattedTextTracks.length > 0) { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Available KSPlayer subtitle tracks:`, formattedTextTracks); - } - - // Look for English track first - const englishTrack = formattedTextTracks.find((track: any) => { - const lang = (track.language || '').toLowerCase(); - const name = (track.name || '').toLowerCase(); - return lang === 'english' || lang === 'en' || lang === 'eng' || - name.includes('english') || name.includes('en'); - }); - - if (englishTrack) { - setSelectedTextTrack(englishTrack.id); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Auto-selected English subtitle track: ${englishTrack.name} (ID: ${englishTrack.id})`); - } - } else if (DEBUG_MODE) { - logger.log(`[VideoPlayer] No English subtitle track found, keeping subtitles disabled`); - } - } - } - - setIsVideoLoaded(true); - setIsPlayerReady(true); - - // Reset audio track fallback attempts when new video loads - setAudioTrackFallbackAttempts(0); - setLastAudioTrackCheck(0); - - // Start Trakt watching session when video loads with proper duration - if (videoDuration > 0) { - traktAutosync.handlePlaybackStart(currentTime, videoDuration); - } - - // Complete opening animation immediately before seeking - completeOpeningAnimation(); - - if (initialPosition && !isInitialSeekComplete) { - logger.log(`[VideoPlayer] Seeking to initial position: ${initialPosition}s (duration: ${videoDuration}s)`); - // Reduced timeout from 1000ms to 500ms - setTimeout(() => { - if (videoDuration > 0 && isMounted.current) { - seekToTime(initialPosition); - setIsInitialSeekComplete(true); - logger.log(`[VideoPlayer] Initial seek completed to: ${initialPosition}s`); - } else { - logger.error(`[VideoPlayer] Initial seek failed: duration=${videoDuration}, mounted=${isMounted.current}`); - } - }, 500); - } - - controlsTimeout.current = setTimeout(hideControls, 5000); - - // Auto-fetch and load English external subtitles if available - if (imdbId) { - fetchAvailableSubtitles(undefined, true); - } - } catch (error) { - logger.error('[VideoPlayer] Error in onLoad:', error); - // Set fallback values to prevent crashes - if (isMounted.current) { - setVideoAspectRatio(16 / 9); - setIsVideoLoaded(true); - setIsPlayerReady(true); - completeOpeningAnimation(); - } - } - }; - - const skip = (seconds: number) => { - const newTime = Math.max(0, Math.min(currentTime + seconds, duration - END_EPSILON)); - seekToTime(newTime); - }; - - const onAudioTracks = (data: { audioTracks: AudioTrack[] }) => { - setAudioTracks(data.audioTracks || []); - }; - - const onTextTracks = (e: Readonly<{ textTracks: TextTrack[] }>) => { - setTextTracks(e.textTracks || []); - }; - - const cycleAspectRatio = () => { - // iOS KSPlayer: toggle native resize mode so subtitles remain independent - if (Platform.OS === 'ios') { - setResizeMode((prev) => (prev === 'cover' ? 'contain' : 'cover')); - return; - } - // Fallback (non‑iOS paths): keep legacy zoom behavior - const newZoom = zoomScale === 1.1 ? 1 : 1.1; - setZoomScale(newZoom); - setZoomTranslateX(0); - setZoomTranslateY(0); - setLastZoomScale(newZoom); - setLastTranslateX(0); - setLastTranslateY(0); - }; - - const enableImmersiveMode = () => { - StatusBar.setHidden(true, 'none'); - if (Platform.OS === 'android') { - try { - RNImmersiveMode.setBarMode('FullSticky'); - RNImmersiveMode.fullLayout(true); - if (NativeModules.StatusBarManager) { - NativeModules.StatusBarManager.setHidden(true); - } - } catch (error) { - if (__DEV__) console.log('Immersive mode error:', error); - } - } - }; - - const disableImmersiveMode = () => { - StatusBar.setHidden(false); - if (Platform.OS === 'android') { - RNImmersiveMode.setBarMode('Normal'); - RNImmersiveMode.fullLayout(false); - } - }; - - const handleClose = async () => { - // Prevent multiple close attempts - if (isSyncingBeforeClose) { - logger.log('[VideoPlayer] Close already in progress, ignoring duplicate call'); - return; - } - - logger.log('[VideoPlayer] Close button pressed - closing immediately and syncing to Trakt in background'); - setIsSyncingBeforeClose(true); - - // Make sure we have the most accurate current time - const actualCurrentTime = currentTime; - const progressPercent = duration > 0 ? (actualCurrentTime / duration) * 100 : 0; - - logger.log(`[VideoPlayer] Current progress: ${actualCurrentTime}/${duration} (${progressPercent.toFixed(1)}%)`); - - // Cleanup and navigate back immediately without delay - const cleanup = () => { - // Fire orientation changes in background - don't await them - ScreenOrientation.unlockAsync() - .then(() => { - logger.log('[VideoPlayer] Orientation unlocked'); - // On iOS tablets, keep rotation unlocked; on phones, return to portrait - if (Platform.OS === 'ios') { - const { width: dw, height: dh } = Dimensions.get('window'); - const isTablet = (Platform as any).isPad === true || Math.min(dw, dh) >= 768; - setTimeout(() => { - if (isTablet) { - ScreenOrientation.unlockAsync().catch(() => { }); - } else { - ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { }); - } - }, 50); - } - }) - .catch((orientationError: any) => { - logger.warn('[VideoPlayer] Failed to unlock orientation:', orientationError); - }); - - // Disable immersive mode (synchronous) - disableImmersiveMode(); - - // Navigate back IMMEDIATELY - don't wait for orientation - try { - if (navigation.canGoBack()) { - navigation.goBack(); - } else { - // Fallback: navigate to Streams if stack was not set as expected - (navigation as any).navigate('Streams', { id, type, episodeId, fromPlayer: true }); - } - logger.log('[VideoPlayer] Navigation completed'); - } catch (navError) { - logger.error('[VideoPlayer] Navigation error:', navError); - // Last resort: try to navigate to Streams - (navigation as any).navigate('Streams', { id, type, episodeId, fromPlayer: true }); + return () => { + if (controlsTimeout.current) { + clearTimeout(controlsTimeout.current); } }; + }, [paused, showControls, hideControls]); - // Navigate immediately - cleanup(); - - // Send Trakt sync in background (don't await) - const backgroundSync = async () => { - try { - logger.log('[VideoPlayer] Starting background Trakt sync'); - // IMMEDIATE: Force immediate progress update (uses scrobble/stop which handles pause/scrobble) - await traktAutosync.handleProgressUpdate(actualCurrentTime, duration, true); - - // IMMEDIATE: Use user_close reason to trigger immediate scrobble stop - await traktAutosync.handlePlaybackEnd(actualCurrentTime, duration, 'user_close'); - - logger.log('[VideoPlayer] Background Trakt sync completed successfully'); - } catch (error) { - logger.error('[VideoPlayer] Error in background Trakt sync:', error); - } - }; - - // Start background sync without blocking UI - backgroundSync(); - }; - - const handleResume = async () => { - if (resumePosition) { - seekToTime(resumePosition); - } - setShowResumeOverlay(false); - }; - - const handleStartFromBeginning = async () => { - seekToTime(0); - setShowResumeOverlay(false); - }; - - const toggleControls = () => { - if (controlsTimeout.current) { - clearTimeout(controlsTimeout.current); - controlsTimeout.current = null; - } - - setShowControls(prevShowControls => { - const newShowControls = !prevShowControls; - Animated.timing(fadeAnim, { - toValue: newShowControls ? 1 : 0, - duration: 300, - useNativeDriver: true, - }).start(); - if (newShowControls) { - controlsTimeout.current = setTimeout(hideControls, 5000); - } - // Reinforce immersive mode after any UI toggle (Android) - enableImmersiveMode(); - return newShowControls; - }); - }; - - const handleError = (error: any) => { - try { - logger.error('[VideoPlayer] Playback Error:', error); - - // Detect KSPlayer startup timeout and silently retry without UI - const errText = typeof error === 'string' - ? error - : (error?.message || error?.error?.message || error?.title || ''); - const isStartupTimeout = /timeout/i.test(errText) && /stream.*ready/i.test(errText); - if (isStartupTimeout && !isVideoLoaded) { - // Suppress any error modal and retry silently - if (errorTimeoutRef.current) { - clearTimeout(errorTimeoutRef.current); - errorTimeoutRef.current = null; - } - setShowErrorModal(false); - - const attempt = startupRetryCountRef.current; - if (attempt < MAX_STARTUP_RETRIES) { - const backoffMs = [4000, 8000, 12000][attempt] ?? 8000; - startupRetryCountRef.current = attempt + 1; - logger.warn(`[VideoPlayer] Startup timeout; retrying (${attempt + 1}/${MAX_STARTUP_RETRIES}) in ${backoffMs}ms`); - - if (startupRetryTimerRef.current) { - clearTimeout(startupRetryTimerRef.current); - } - startupRetryTimerRef.current = setTimeout(() => { - if (!ksPlayerRef.current) return; - try { - // Reload the same source silently using native bridge - ksPlayerRef.current.setSource({ - uri: currentStreamUrl, - headers: headers && Object.keys(headers).length > 0 ? headers : undefined - }); - // Ensure playback resumes if not paused - ksPlayerRef.current.setPaused(paused); - logger.log('[VideoPlayer] Retried source load via KSPlayer.setSource'); - } catch (e) { - logger.error('[VideoPlayer] Error during silent retry setSource:', e); - } - }, backoffMs); - return; // Exit handler; do not show UI - } - logger.error('[VideoPlayer] Max startup retries reached; proceeding to normal error handling'); - } - - // Check for audio codec errors (TrueHD, DTS, Dolby, etc.) - const isAudioCodecError = - (error?.message && /(trhd|truehd|true\s?hd|dts|dolby|atmos|e-ac3|ac3)/i.test(error.message)) || - (error?.error?.message && /(trhd|truehd|true\s?hd|dts|dolby|atmos|e-ac3|ac3)/i.test(error.error.message)) || - (error?.title && /codec not supported/i.test(error.title)); - - // Handle audio codec errors with automatic fallback - if (isAudioCodecError && ksAudioTracks.length > 1) { - logger.warn('[VideoPlayer] Audio codec error detected, attempting audio track fallback'); - - // Find a fallback audio track (prefer stereo/standard formats) - const fallbackTrack = ksAudioTracks.find((track, index) => { - const trackName = (track.name || '').toLowerCase(); - const trackLang = (track.language || '').toLowerCase(); - // Prefer stereo, AAC, or standard audio formats, avoid heavy codecs - return !trackName.includes('truehd') && - !trackName.includes('dts') && - !trackName.includes('dolby') && - !trackName.includes('atmos') && - !trackName.includes('7.1') && - !trackName.includes('5.1') && - index !== selectedAudioTrack; // Don't select the same track - }); - - if (fallbackTrack) { - const fallbackIndex = ksAudioTracks.indexOf(fallbackTrack); - logger.warn(`[VideoPlayer] Switching to fallback audio track: ${fallbackTrack.name || 'Unknown'} (index: ${fallbackIndex})`); - - // Clear any existing error state - if (errorTimeoutRef.current) { - clearTimeout(errorTimeoutRef.current); - errorTimeoutRef.current = null; - } - setShowErrorModal(false); - - // Switch to fallback audio track - setSelectedAudioTrack(fallbackIndex); - - // Brief pause to allow track switching - setPaused(true); - setTimeout(() => { - if (isMounted.current) { - setPaused(false); - } - }, 500); - - return; // Don't show error UI, attempt recovery - } - } - - // Format error details for user display - let errorMessage = 'An unknown error occurred'; - if (error) { - if (isAudioCodecError) { - errorMessage = 'Audio codec compatibility issue detected. The video contains unsupported audio codec (TrueHD/DTS/Dolby). Please try selecting a different audio track or use an alternative video source.'; - } else if (typeof error === 'string') { - errorMessage = error; - } else if (error.message) { - errorMessage = error.message; - } else if (error.error && error.error.message) { - errorMessage = error.error.message; - } else if (error.code) { - errorMessage = `Error Code: ${error.code}`; - } else { - errorMessage = JSON.stringify(error, null, 2); - } - } - - setErrorDetails(errorMessage); - setShowErrorModal(true); - - // Clear any existing timeout - if (errorTimeoutRef.current) { - clearTimeout(errorTimeoutRef.current); - } - - // Auto-exit after 5 seconds if user doesn't dismiss - errorTimeoutRef.current = setTimeout(() => { - handleErrorExit(); - }, 5000); - } catch (handlerError) { - // Fallback error handling to prevent crashes during error processing - logger.error('[VideoPlayer] Error in error handler:', handlerError); - if (isMounted.current) { - // Minimal safe error handling - setErrorDetails('A critical error occurred'); - setShowErrorModal(true); - // Force exit after 3 seconds if error handler itself fails - setTimeout(() => { - if (isMounted.current) { - handleClose(); - } - }, 3000); - } - } - }; - - const handleErrorExit = () => { - if (errorTimeoutRef.current) { - clearTimeout(errorTimeoutRef.current); - errorTimeoutRef.current = null; - } - setShowErrorModal(false); - handleClose(); - }; - - const onBuffering = (event: any) => { - setIsBuffering(event.isBuffering); - }; - - const onEnd = async () => { - // Make sure we report 100% progress to Trakt - const finalTime = duration; - setCurrentTime(finalTime); - - try { - // REGULAR: Use regular sync for natural video end (not immediate since it's not user-triggered) - logger.log('[VideoPlayer] Video ended naturally, sending final progress update with 100%'); - await traktAutosync.handleProgressUpdate(finalTime, duration, false); // force=false for regular sync - - // REGULAR: Use 'ended' reason for natural video end (uses regular queued method) - logger.log('[VideoPlayer] Sending final stop call after natural end'); - await traktAutosync.handlePlaybackEnd(finalTime, duration, 'ended'); - - logger.log('[VideoPlayer] Completed video end sync to Trakt'); - } catch (error) { - logger.error('[VideoPlayer] Error syncing to Trakt on video end:', error); - } - }; - - const selectAudioTrack = (trackId: number) => { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Selecting audio track: ${trackId}`); - logger.log(`[VideoPlayer] Available tracks:`, ksAudioTracks); - } - - // Validate that the track exists - const trackExists = ksAudioTracks.some(track => track.id === trackId); - if (!trackExists) { - logger.error(`[VideoPlayer] Audio track ${trackId} not found in available tracks`); - return; - } - - // Get the selected track info for logging - const selectedTrack = ksAudioTracks.find(track => track.id === trackId); - if (selectedTrack && DEBUG_MODE) { - logger.log(`[VideoPlayer] Switching to track: ${selectedTrack.name} (${selectedTrack.language})`); - - // Check if this is a multi-channel track that might need downmixing - const trackName = selectedTrack.name.toLowerCase(); - const isMultiChannel = trackName.includes('5.1') || trackName.includes('7.1') || - trackName.includes('truehd') || trackName.includes('dts') || - trackName.includes('dolby') || trackName.includes('atmos'); - - if (isMultiChannel) { - logger.log(`[VideoPlayer] Multi-channel audio track detected: ${selectedTrack.name}`); - logger.log(`[VideoPlayer] KSPlayer will apply downmixing to ensure dialogue is audible`); - } - } - - // If changing tracks, briefly pause to allow smooth transition - const wasPlaying = !paused; - if (wasPlaying) { - setPaused(true); - } - - // Set the new audio track - setSelectedAudioTrack(trackId); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Audio track changed to: ${trackId}`); - } - - // Resume playback after a brief delay if it was playing - if (wasPlaying) { - setTimeout(() => { - if (isMounted.current) { - setPaused(false); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Resumed playback after audio track change`); - } - } - }, 300); - } - }; - - const selectTextTrack = (trackId: number) => { - if (trackId === -999) { - setUseCustomSubtitles(true); - setSelectedTextTrack(-1); - } else { - setUseCustomSubtitles(false); - setSelectedTextTrack(trackId); - } - }; - - const disableCustomSubtitles = () => { - setUseCustomSubtitles(false); - setCustomSubtitles([]); - // Reset to first available built-in track or disable all tracks - setSelectedTextTrack(ksTextTracks.length > 0 ? 0 : -1); - }; - - // Ensure native KSPlayer text tracks are disabled when using custom (addon) subtitles - // and re-applied when switching back to built-in tracks. This prevents double-rendering. - useEffect(() => { - try { - if (useCustomSubtitles) { - // -1 disables native subtitle rendering in KSPlayer - setSelectedTextTrack(-1); - } else if (typeof selectedTextTrack === 'number' && selectedTextTrack >= 0) { - // KSPlayer picks it up via prop - } - } catch (e) { - // no-op: defensive guard in case ref methods are unavailable momentarily - } - }, [useCustomSubtitles, selectedTextTrack]); - - const loadSubtitleSize = async () => { - try { - // Prefer scoped subtitle settings - const saved = await storageService.getSubtitleSettings(); - if (saved && typeof saved.subtitleSize === 'number') { - setSubtitleSize(saved.subtitleSize); - return; - } - // One-time migrate legacy key if present - const legacy = await mmkvStorage.getItem(SUBTITLE_SIZE_KEY); - if (legacy) { - const migrated = parseInt(legacy, 10); - if (!Number.isNaN(migrated) && migrated > 0) { - setSubtitleSize(migrated); - try { - const merged = { ...(saved || {}), subtitleSize: migrated }; - await storageService.saveSubtitleSettings(merged); - } catch { } - } - try { await mmkvStorage.removeItem(SUBTITLE_SIZE_KEY); } catch { } - return; - } - // If no saved settings, use responsive default - const screenWidth = Dimensions.get('window').width; - setSubtitleSize(getDefaultSubtitleSize(screenWidth)); - } catch (error) { - logger.error('[VideoPlayer] Error loading subtitle size:', error); - // Fallback to responsive default on error - const screenWidth = Dimensions.get('window').width; - setSubtitleSize(getDefaultSubtitleSize(screenWidth)); - } - }; - - const saveSubtitleSize = async (size: number) => { - try { - setSubtitleSize(size); - // Persist via scoped subtitle settings so it survives restarts and account switches - const saved = await storageService.getSubtitleSettings(); - const next = { ...(saved || {}), subtitleSize: size }; - await storageService.saveSubtitleSettings(next); - } catch (error) { - logger.error('[VideoPlayer] Error saving subtitle size:', error); - } - }; - + // Subtitle Fetching Logic const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = true) => { const targetImdbId = imdbIdParam || imdbId; - if (!targetImdbId) { - logger.error('[VideoPlayer] No IMDb ID available for subtitle search'); - return; - } - setIsLoadingSubtitleList(true); + if (!targetImdbId) return; + + customSubs.setIsLoadingSubtitleList(true); try { - // Fetch from all installed subtitle-capable addons via Stremio const stremioType = type === 'series' ? 'series' : 'movie'; - const stremioVideoId = stremioType === 'series' && season && episode - ? `series:${targetImdbId}:${season}:${episode}` - : undefined; - const stremioResults = await stremioService.getSubtitles(stremioType, targetImdbId, stremioVideoId); - const stremioSubs: WyzieSubtitle[] = (stremioResults || []).map(sub => ({ + const stremioVideoId = stremioType === 'series' && season && episode ? `series:${targetImdbId}:${season}:${episode}` : undefined; + const results = await stremioService.getSubtitles(stremioType, targetImdbId, stremioVideoId); + + const subs: WyzieSubtitle[] = (results || []).map((sub: any) => ({ id: sub.id || `${sub.lang}-${sub.url}`, url: sub.url, flagUrl: '', @@ -1831,25 +244,12 @@ const KSPlayerCore: React.FC = () => { isHearingImpaired: false, source: sub.addonName || sub.addon || 'Addon', })); - // Sort with English languages first, then alphabetical over full list - const isEnglish = (s: WyzieSubtitle) => { - const lang = (s.language || '').toLowerCase(); - const disp = (s.display || '').toLowerCase(); - return lang === 'en' || lang === 'eng' || /^en([-_]|$)/.test(lang) || disp.includes('english'); - }; - stremioSubs.sort((a, b) => { - const aIsEn = isEnglish(a); - const bIsEn = isEnglish(b); - if (aIsEn && !bIsEn) return -1; - if (!aIsEn && bIsEn) return 1; - return (a.display || '').localeCompare(b.display || ''); - }); - setAvailableSubtitles(stremioSubs); + + customSubs.setAvailableSubtitles(subs); + if (autoSelectEnglish) { - const englishSubtitle = stremioSubs.find(sub => - sub.language.toLowerCase() === 'eng' || - sub.language.toLowerCase() === 'en' || - sub.display.toLowerCase().includes('english') + const englishSubtitle = subs.find(sub => + sub.language.includes('en') || sub.display.toLowerCase().includes('english') ); if (englishSubtitle) { loadWyzieSubtitle(englishSubtitle); @@ -1857,584 +257,139 @@ const KSPlayerCore: React.FC = () => { } } if (!autoSelectEnglish) { - // If no English found and not auto-selecting, still open the modal - setShowSubtitleLanguageModal(true); + modals.setShowSubtitleLanguageModal(true); } - } catch (error) { - logger.error('[VideoPlayer] Error fetching subtitles from OpenSubtitles addon:', error); + } catch (e) { + logger.error('[VideoPlayer] Error fetching subtitles', e); } finally { - setIsLoadingSubtitleList(false); + customSubs.setIsLoadingSubtitleList(false); } }; const loadWyzieSubtitle = async (subtitle: WyzieSubtitle) => { - logger.log(`[VideoPlayer] Subtitle click received: id=${subtitle.id}, lang=${subtitle.language}, url=${subtitle.url}`); - setShowSubtitleLanguageModal(false); - setIsLoadingSubtitles(true); + modals.setShowSubtitleLanguageModal(false); + customSubs.setIsLoadingSubtitles(true); try { - logger.log('[VideoPlayer] Fetching subtitle SRT start'); let srtContent = ''; try { - const axiosResp = await axios.get(subtitle.url, { - timeout: 10000, - headers: { - 'Accept': 'text/plain, */*', - 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Nuvio/1.0' - }, - responseType: 'text', - transitional: { clarifyTimeoutError: true } - }); - srtContent = typeof axiosResp.data === 'string' ? axiosResp.data : String(axiosResp.data || ''); - } catch (axiosErr: any) { - logger.warn('[VideoPlayer] Axios subtitle fetch failed, falling back to fetch()', { - message: axiosErr?.message, - code: axiosErr?.code - }); - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - try { - const resp = await fetch(subtitle.url, { signal: controller.signal }); - srtContent = await resp.text(); - } finally { - clearTimeout(timeoutId); - } + const resp = await axios.get(subtitle.url, { timeout: 10000 }); + srtContent = typeof resp.data === 'string' ? resp.data : String(resp.data); + } catch { + const resp = await fetch(subtitle.url); + srtContent = await resp.text(); } - logger.log(`[VideoPlayer] Fetching subtitle SRT done, size=${srtContent.length}`); const parsedCues = parseSRT(srtContent); - logger.log(`[VideoPlayer] Parsed cues count=${parsedCues.length}`); + customSubs.setCustomSubtitles(parsedCues); + customSubs.setUseCustomSubtitles(true); + tracks.selectTextTrack(-1); - // For KSPlayer on iOS: stop spinner early, then clear-apply and micro-seek nudge - setIsLoadingSubtitles(false); - logger.log('[VideoPlayer] isLoadingSubtitles -> false (early)'); + const adjustedTime = currentTime + (customSubs.subtitleOffsetSec || 0); + const cueNow = parsedCues.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end); + customSubs.setCurrentSubtitle(cueNow ? cueNow.text : ''); - // Clear existing state - setUseCustomSubtitles(false); - logger.log('[VideoPlayer] useCustomSubtitles -> false'); - setCustomSubtitles([]); - logger.log('[VideoPlayer] customSubtitles -> []'); - setSelectedTextTrack(-1); - logger.log('[VideoPlayer] selectedTextTrack -> -1'); - - // Apply immediately - setCustomSubtitles(parsedCues); - logger.log('[VideoPlayer] customSubtitles <- parsedCues'); - setUseCustomSubtitles(true); - logger.log('[VideoPlayer] useCustomSubtitles -> true'); - setSelectedTextTrack(-1); - logger.log('[VideoPlayer] selectedTextTrack -> -1 (disable native while using custom)'); - - // Immediately set current subtitle text - try { - const adjustedTime = currentTime + (subtitleOffsetSec || 0); - const cueNow = parsedCues.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end); - const textNow = cueNow ? cueNow.text : ''; - setCurrentSubtitle(textNow); - logger.log('[VideoPlayer] currentSubtitle set immediately after apply'); - } catch (e) { - logger.error('[VideoPlayer] Error setting immediate subtitle', e); - } - - // Removed micro-seek nudge - } catch (error) { - logger.error('[VideoPlayer] Error loading Wyzie subtitle:', error); - setIsLoadingSubtitles(false); + } catch (e) { + logger.error('[VideoPlayer] Error loading wyzie', e); + } finally { + customSubs.setIsLoadingSubtitles(false); } }; - const togglePlayback = () => { - setPaused(!paused); - }; + // Auto-fetch subtitles on load + useEffect(() => { + if (imdbId) { + fetchAvailableSubtitles(undefined, true); + } + }, [imdbId]); - // Handle next episode button press - const handlePlayNextEpisode = useCallback(async () => { - if (!nextEpisode || !id || isLoadingNextEpisode) return; + // Handlers + const onLoad = (data: any) => { + setDuration(data.duration); + if (data.audioTracks) tracks.setKsAudioTracks(data.audioTracks); + if (data.textTracks) tracks.setKsTextTracks(data.textTracks); - setIsLoadingNextEpisode(true); + setIsVideoLoaded(true); + setIsPlayerReady(true); + openingAnim.completeOpeningAnimation(); - try { - logger.log('[VideoPlayer] Loading next episode:', nextEpisode); - - // Create episode ID for next episode using stremioId if available, otherwise construct it - const nextEpisodeId = nextEpisode.stremioId || `${id}:${nextEpisode.season_number}:${nextEpisode.episode_number}`; - - logger.log('[VideoPlayer] Fetching streams for next episode:', nextEpisodeId); - - // Import stremio service - const stremioService = require('../../services/stremioService').default; - - let bestStream: any = null; - let streamFound = false; - let completedProviders = 0; - const expectedProviders = new Set(); - - // Get installed addons to know how many providers to expect - const installedAddons = stremioService.getInstalledAddons(); - const streamAddons = installedAddons.filter((addon: any) => - addon.resources && addon.resources.includes('stream') - ); - - streamAddons.forEach((addon: any) => expectedProviders.add(addon.id)); - - // Collect all streams from all providers for the sources modal - const allStreams: { [providerId: string]: { streams: any[]; addonName: string } } = {}; - let hasNavigated = false; - - // Fetch streams for next episode - await stremioService.getStreams('series', nextEpisodeId, (streams: any, addonId: any, addonName: any, error: any) => { - completedProviders++; - - // Always collect streams from this provider for sources modal (even after navigation) - if (streams && streams.length > 0) { - allStreams[addonId] = { - streams: streams, - addonName: addonName || addonId - }; - } - - // Navigate with first good stream found, but continue collecting streams in background - if (!hasNavigated && !streamFound && streams && streams.length > 0) { - // Sort streams by quality and cache status (prefer cached/debrid streams) - const sortedStreams = streams.sort((a: any, b: any) => { - const aQuality = parseInt(a.title?.match(/(\d+)p/)?.[1] || '0', 10); - const bQuality = parseInt(b.title?.match(/(\d+)p/)?.[1] || '0', 10); - const aCached = a.behaviorHints?.cached || false; - const bCached = b.behaviorHints?.cached || false; - - // Prioritize cached streams first - if (aCached !== bCached) { - return aCached ? -1 : 1; - } - // Then sort by quality (higher quality first) - return bQuality - aQuality; - }); - - bestStream = sortedStreams[0]; - streamFound = true; - hasNavigated = true; - - // Update loading details for the chip - const qualityText = (bestStream.title?.match(/(\d+)p/) || [])[1] || null; - setNextLoadingProvider(addonName || addonId || null); - setNextLoadingQuality(qualityText); - setNextLoadingTitle(bestStream.name || bestStream.title || null); - - logger.log('[VideoPlayer] Found stream for next episode:', bestStream); - - // Pause current playback to ensure no background player remains active - setPaused(true); - - // Start navigation immediately but let stream fetching continue in background - setTimeout(() => { - navigation.replace('PlayerIOS', { - uri: bestStream.url, - title: metadata?.name || '', - episodeTitle: nextEpisode.name, - season: nextEpisode.season_number, - episode: nextEpisode.episode_number, - quality: (bestStream.title?.match(/(\d+)p/) || [])[1] || undefined, - year: metadata?.year, - streamProvider: addonName, - streamName: bestStream.name || bestStream.title, - headers: bestStream.headers || undefined, - id, - type: 'series', - episodeId: nextEpisodeId, - imdbId: imdbId ?? undefined, - backdrop: backdrop || undefined, - availableStreams: allStreams, // Pass current available streams (more will be added) - }); - setIsLoadingNextEpisode(false); - }, 100); // Small delay to ensure smooth transition - } - - // If we've checked all providers and no stream found - if (completedProviders >= expectedProviders.size && !streamFound) { - logger.warn('[VideoPlayer] No streams found for next episode after checking all providers'); - setIsLoadingNextEpisode(false); - } - }); - - // Fallback timeout in case providers don't respond + // Initial Seek + if (initialPosition && initialPosition > 0) { setTimeout(() => { - if (!streamFound) { - logger.warn('[VideoPlayer] Timeout: No streams found for next episode'); - setIsLoadingNextEpisode(false); - } - }, 8000); - - } catch (error) { - logger.error('[VideoPlayer] Error loading next episode:', error); - setIsLoadingNextEpisode(false); + controls.seekToTime(initialPosition); + }, 500); } - }, [nextEpisode, id, isLoadingNextEpisode, navigation, metadata, imdbId, backdrop]); - // Function to hide pause overlay and show controls - const hidePauseOverlay = useCallback(() => { - if (showPauseOverlay) { - // Reset cast details state when hiding overlay - if (showCastDetails) { - Animated.parallel([ - Animated.timing(castDetailsOpacity, { - toValue: 0, - duration: 200, - useNativeDriver: true, - }), - Animated.timing(castDetailsScale, { - toValue: 0.95, - duration: 200, - useNativeDriver: true, - }) - ]).start(() => { - setShowCastDetails(false); - setSelectedCastMember(null); - // Reset metadata animations - metadataOpacity.setValue(1); - metadataScale.setValue(1); - }); - } else { - setShowCastDetails(false); - setSelectedCastMember(null); - // Reset metadata animations - metadataOpacity.setValue(1); - metadataScale.setValue(1); - } - - Animated.parallel([ - Animated.timing(pauseOverlayOpacity, { - toValue: 0, - duration: 220, - useNativeDriver: true, - }), - Animated.timing(pauseOverlayTranslateY, { - toValue: 8, - duration: 220, - useNativeDriver: true, - }) - ]).start(() => setShowPauseOverlay(false)); - - // Show controls when overlay is touched - if (!showControls) { - setShowControls(true); - Animated.timing(fadeAnim, { - toValue: 1, - duration: 300, - useNativeDriver: true, - }).start(); - - // Auto-hide controls after 5 seconds - if (controlsTimeout.current) { - clearTimeout(controlsTimeout.current); - } - controlsTimeout.current = setTimeout(hideControls, 5000); - } - } - }, [showPauseOverlay, pauseOverlayOpacity, pauseOverlayTranslateY, showControls, fadeAnim, controlsTimeout, hideControls]); - - // Handle paused overlay after 5 seconds of being paused - useEffect(() => { - if (paused) { - if (pauseOverlayTimerRef.current) { - clearTimeout(pauseOverlayTimerRef.current); - } - pauseOverlayTimerRef.current = setTimeout(() => { - setShowPauseOverlay(true); - pauseOverlayOpacity.setValue(0); - pauseOverlayTranslateY.setValue(12); - Animated.parallel([ - Animated.timing(pauseOverlayOpacity, { - toValue: 1, - duration: 550, - useNativeDriver: true, - }), - Animated.timing(pauseOverlayTranslateY, { - toValue: 0, - duration: 450, - useNativeDriver: true, - }) - ]).start(); - }, 5000); - } else { - if (pauseOverlayTimerRef.current) { - clearTimeout(pauseOverlayTimerRef.current); - pauseOverlayTimerRef.current = null; - } - hidePauseOverlay(); - } - return () => { - if (pauseOverlayTimerRef.current) { - clearTimeout(pauseOverlayTimerRef.current); - pauseOverlayTimerRef.current = null; - } - }; - }, [paused]); - - // Up Next visibility handled inside reusable component - - useEffect(() => { - isMounted.current = true; - return () => { - isMounted.current = false; - if (seekDebounceTimer.current) { - clearTimeout(seekDebounceTimer.current); - } - if (errorTimeoutRef.current) { - clearTimeout(errorTimeoutRef.current); - } - - // Cleanup gesture controls - gestureControls.cleanup(); - - if (startupRetryTimerRef.current) { - clearTimeout(startupRetryTimerRef.current); - startupRetryTimerRef.current = null; - } - }; - }, []); - - const safeSetState = (setter: any) => { - if (isMounted.current) { - setter(); + // Start trakt session + if (data.duration > 0) { + traktAutosync.handlePlaybackStart(currentTime, data.duration); } }; - useEffect(() => { - if (!useCustomSubtitles || customSubtitles.length === 0) { - if (currentSubtitle !== '') { - setCurrentSubtitle(''); - } - if (currentFormattedSegments.length > 0) { - setCurrentFormattedSegments([]); - } - return; - } - const adjustedTime = currentTime + (subtitleOffsetSec || 0) - 0.2; - const currentCue = customSubtitles.find(cue => - adjustedTime >= cue.start && adjustedTime <= cue.end - ); - const newSubtitle = currentCue ? currentCue.text : ''; - setCurrentSubtitle(newSubtitle); - - // Extract formatted segments from current cue - if (currentCue?.formattedSegments) { - const segmentsPerLine: SubtitleSegment[][] = []; - let currentLine: SubtitleSegment[] = []; - - currentCue.formattedSegments.forEach(seg => { - const parts = seg.text.split(/\r?\n/); - parts.forEach((part, index) => { - if (index > 0) { - // New line found - segmentsPerLine.push(currentLine); - currentLine = []; - } - if (part.length > 0) { - currentLine.push({ ...seg, text: part }); - } - }); - }); - - if (currentLine.length > 0) { - segmentsPerLine.push(currentLine); - } - - setCurrentFormattedSegments(segmentsPerLine); - } else { - setCurrentFormattedSegments([]); - } - }, [currentTime, customSubtitles, useCustomSubtitles, subtitleOffsetSec]); - - // Load global subtitle settings - useEffect(() => { - (async () => { - try { - const saved = await storageService.getSubtitleSettings(); - if (saved) { - if (typeof saved.subtitleSize === 'number') setSubtitleSize(saved.subtitleSize); - if (typeof saved.subtitleBackground === 'boolean') setSubtitleBackground(saved.subtitleBackground); - if (typeof saved.subtitleTextColor === 'string') setSubtitleTextColor(saved.subtitleTextColor); - if (typeof saved.subtitleBgOpacity === 'number') setSubtitleBgOpacity(saved.subtitleBgOpacity); - if (typeof saved.subtitleTextShadow === 'boolean') setSubtitleTextShadow(saved.subtitleTextShadow); - if (typeof saved.subtitleOutline === 'boolean') setSubtitleOutline(saved.subtitleOutline); - if (typeof saved.subtitleOutlineColor === 'string') setSubtitleOutlineColor(saved.subtitleOutlineColor); - if (typeof saved.subtitleOutlineWidth === 'number') setSubtitleOutlineWidth(saved.subtitleOutlineWidth); - if (typeof saved.subtitleAlign === 'string') setSubtitleAlign(saved.subtitleAlign as 'center' | 'left' | 'right'); - if (typeof saved.subtitleBottomOffset === 'number') setSubtitleBottomOffset(saved.subtitleBottomOffset); - if (typeof saved.subtitleLetterSpacing === 'number') setSubtitleLetterSpacing(saved.subtitleLetterSpacing); - if (typeof saved.subtitleLineHeightMultiplier === 'number') setSubtitleLineHeightMultiplier(saved.subtitleLineHeightMultiplier); - if (typeof saved.subtitleOffsetSec === 'number') setSubtitleOffsetSec(saved.subtitleOffsetSec); - } - } catch { } finally { - // Mark subtitle settings as loaded so we can safely persist subsequent changes - try { setSubtitleSettingsLoaded(true); } catch { } - } - })(); - }, []); - - // Persist global subtitle settings on change - useEffect(() => { - if (!subtitleSettingsLoaded) return; - storageService.saveSubtitleSettings({ - subtitleSize, - subtitleBackground, - subtitleTextColor, - subtitleBgOpacity, - subtitleTextShadow, - subtitleOutline, - subtitleOutlineColor, - subtitleOutlineWidth, - subtitleAlign, - subtitleBottomOffset, - subtitleLetterSpacing, - subtitleLineHeightMultiplier, - subtitleOffsetSec, - }); - }, [ - subtitleSize, - subtitleBackground, - subtitleTextColor, - subtitleBgOpacity, - subtitleTextShadow, - subtitleOutline, - subtitleOutlineColor, - subtitleOutlineWidth, - subtitleAlign, - subtitleBottomOffset, - subtitleLetterSpacing, - subtitleLineHeightMultiplier, - subtitleOffsetSec, - subtitleSettingsLoaded, - ]); - - useEffect(() => { - loadSubtitleSize(); - }, []); - - // Handle audio track changes with proper logging - useEffect(() => { - if (selectedAudioTrack !== null && ksAudioTracks.length > 0) { - const selectedTrack = ksAudioTracks.find(track => track.id === selectedAudioTrack); - if (selectedTrack) { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Audio track selected: ${selectedTrack.name} (${selectedTrack.language}) - ID: ${selectedAudioTrack}`); - } - } else { - logger.warn(`[VideoPlayer] Selected audio track ${selectedAudioTrack} not found in available tracks`); - } - } - }, [selectedAudioTrack, ksAudioTracks]); - - const increaseSubtitleSize = () => { - const newSize = Math.min(subtitleSize + 2, 80); - saveSubtitleSize(newSize); + const handleError = (error: any) => { + modals.setErrorDetails(typeof error === 'string' ? error : error?.message || 'Unknown Error'); + modals.setShowErrorModal(true); }; - const decreaseSubtitleSize = () => { - const newSize = Math.max(subtitleSize - 2, 8); - saveSubtitleSize(newSize); - }; - - const toggleSubtitleBackground = () => { - setSubtitleBackground(prev => !prev); - }; - - // AirPlay handler - const handleAirPlayPress = async () => { - if (!ksPlayerRef.current) return; - - try { - // First ensure AirPlay is enabled - if (!allowsAirPlay) { - ksPlayerRef.current.setAllowsExternalPlayback(true); - setAllowsAirPlay(true); - logger.log(`[VideoPlayer] AirPlay enabled before showing picker`); - } - - // Show the AirPlay picker - ksPlayerRef.current.showAirPlayPicker(); - - logger.log(`[VideoPlayer] AirPlay picker triggered - check console for native logs`); - } catch (error) { - logger.error('[VideoPlayer] Error showing AirPlay picker:', error); - } + const handleClose = async () => { + if (isSyncingBeforeClose.current) return; + isSyncingBeforeClose.current = true; + + await traktAutosync.handleProgressUpdate(currentTime, duration, true); + await traktAutosync.handlePlaybackEnd(currentTime, duration, 'user_close'); + + navigation.goBack(); }; + // Stream selection handler const handleSelectStream = async (newStream: any) => { - if (newStream.url === currentStreamUrl) { - setShowSourcesModal(false); + if (newStream.url === uri) { + modals.setShowSourcesModal(false); return; } - - setShowSourcesModal(false); - - // Extract quality and provider information - let newQuality = newStream.quality; - if (!newQuality && newStream.title) { - const qualityMatch = newStream.title.match(/(\d+)p/); - newQuality = qualityMatch ? qualityMatch[0] : undefined; - } - - const newProvider = newStream.addonName || newStream.name || newStream.addon || 'Unknown'; - const newStreamName = newStream.name || newStream.title || 'Unknown Stream'; - - // Pause current playback + modals.setShowSourcesModal(false); setPaused(true); - // Navigate with replace to reload player with new source + const newQuality = newStream.quality || newStream.title?.match(/(\d+)p/)?.[0]; + const newProvider = newStream.addonName || newStream.name || newStream.addon || 'Unknown'; + const newStreamName = newStream.name || newStream.title || 'Unknown'; + setTimeout(() => { - navigation.replace('PlayerIOS', { + (navigation as any).replace('PlayerIOS', { + ...params, uri: newStream.url, - title: title, - episodeTitle: episodeTitle, - season: season, - episode: episode, quality: newQuality, - year: year, streamProvider: newProvider, streamName: newStreamName, - headers: newStream.headers || undefined, - id, - type, - episodeId, - imdbId: imdbId ?? undefined, - backdrop: backdrop || undefined, - availableStreams: availableStreams, + headers: newStream.headers, + availableStreams: availableStreams }); }, 100); }; - const handleEpisodeSelect = (episode: Episode) => { - logger.log('[KSPlayerCore] Episode selected:', episode.name); - setSelectedEpisodeForStreams(episode); - setShowEpisodesModal(false); - setShowEpisodeStreamsModal(true); + // Episode selection handler - opens streams modal + const handleSelectEpisode = (ep: any) => { + modals.setSelectedEpisodeForStreams(ep); + modals.setShowEpisodesModal(false); + modals.setShowEpisodeStreamsModal(true); }; - // Debug: Log when modal state changes - useEffect(() => { - if (showEpisodesModal) { - logger.log('[KSPlayerCore] Episodes modal opened, groupedEpisodes:', groupedEpisodes); - logger.log('[KSPlayerCore] type:', type, 'season:', season, 'episode:', episode); - } - }, [showEpisodesModal, groupedEpisodes, type]); - + // Episode stream selection handler - navigates to new episode with selected stream const handleEpisodeStreamSelect = async (stream: any) => { - if (!selectedEpisodeForStreams) return; - - setShowEpisodeStreamsModal(false); + if (!modals.selectedEpisodeForStreams) return; + modals.setShowEpisodeStreamsModal(false); + setPaused(true); + const ep = modals.selectedEpisodeForStreams; const newQuality = stream.quality || (stream.title?.match(/(\d+)p/)?.[0]); const newProvider = stream.addonName || stream.name || stream.addon || 'Unknown'; const newStreamName = stream.name || stream.title || 'Unknown Stream'; - setPaused(true); - setTimeout(() => { - navigation.replace('PlayerIOS', { + (navigation as any).replace('PlayerIOS', { uri: stream.url, title: title, - episodeTitle: selectedEpisodeForStreams.name, - season: selectedEpisodeForStreams.season_number, - episode: selectedEpisodeForStreams.episode_number, + episodeTitle: ep.name, + season: ep.season_number, + episode: ep.episode_number, quality: newQuality, year: year, streamProvider: newProvider, @@ -2442,234 +397,122 @@ const KSPlayerCore: React.FC = () => { headers: stream.headers || undefined, id, type: 'series', - episodeId: selectedEpisodeForStreams.stremioId || `${id}:${selectedEpisodeForStreams.season_number}:${selectedEpisodeForStreams.episode_number}`, + episodeId: ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`, imdbId: imdbId ?? undefined, backdrop: backdrop || undefined, - availableStreams: {}, - groupedEpisodes: groupedEpisodes, }); }, 100); }; - useEffect(() => { - if (isVideoLoaded && initialPosition && !isInitialSeekComplete && duration > 0) { - logger.log(`[VideoPlayer] Post-load initial seek to: ${initialPosition}s`); - seekToTime(initialPosition); - setIsInitialSeekComplete(true); - // Verify whether the seek actually took effect (detect non-seekable sources) - if (!initialSeekVerifiedRef.current) { - initialSeekVerifiedRef.current = true; - const target = initialSeekTargetRef.current ?? initialPosition; - setTimeout(() => { - const delta = Math.abs(currentTime - (target || 0)); - if (target && (currentTime < target - 1.5)) { - logger.warn(`[VideoPlayer] Initial seek appears ignored (delta=${delta.toFixed(2)}). Treating source as non-seekable; starting from 0`); - isSourceSeekableRef.current = false; - // Reset resume intent and continue from 0 - setInitialPosition(null); - setResumePosition(null); - setShowResumeOverlay(false); - } else { - isSourceSeekableRef.current = true; - } - }, 1200); - } - } - }, [isVideoLoaded, initialPosition, duration]); + // Slider handlers + const onSliderValueChange = (value: number) => { + setCurrentTime(value); + }; + + const onSlidingStart = () => { + setIsSliderDragging(true); + }; + + const onSlidingComplete = (value: number) => { + setIsSliderDragging(false); + controls.seekToTime(value); + }; return ( - - {!DISABLE_OPENING_OVERLAY && ( - - )} + + ); }; diff --git a/src/components/player/android/components/VideoSurface.tsx b/src/components/player/android/components/VideoSurface.tsx index cf978bf..41273e4 100644 --- a/src/components/player/android/components/VideoSurface.tsx +++ b/src/components/player/android/components/VideoSurface.tsx @@ -11,11 +11,23 @@ const getVideoResizeMode = (resizeMode: ResizeModeType) => { switch (resizeMode) { case 'contain': return 'contain'; case 'cover': return 'cover'; + case 'stretch': return 'contain'; case 'none': return 'contain'; default: return 'contain'; } }; +// VLC only supports 'contain' | 'cover' | 'none' +const getVlcResizeMode = (resizeMode: ResizeModeType): 'contain' | 'cover' | 'none' => { + switch (resizeMode) { + case 'contain': return 'contain'; + case 'cover': return 'cover'; + case 'stretch': return 'cover'; // stretch is not supported, use cover + case 'none': return 'none'; + default: return 'contain'; + } +}; + interface VideoSurfaceProps { useVLC: boolean; forceVlcRemount: boolean; @@ -137,7 +149,7 @@ export const VideoSurface: React.FC = ({ volume={volume} playbackSpeed={playbackSpeed} zoomScale={zoomScale} - resizeMode={resizeMode} + resizeMode={getVlcResizeMode(resizeMode)} onLoad={(data) => { vlcLoadedRef.current = true; onLoad(data); diff --git a/src/components/player/components/GestureControls.tsx b/src/components/player/components/GestureControls.tsx new file mode 100644 index 0000000..a9b46ec --- /dev/null +++ b/src/components/player/components/GestureControls.tsx @@ -0,0 +1,199 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { + TapGestureHandler, + PanGestureHandler, + LongPressGestureHandler, + State +} from 'react-native-gesture-handler'; +import { MaterialIcons } from '@expo/vector-icons'; +import { styles as localStyles } from '../utils/playerStyles'; + +interface GestureControlsProps { + screenDimensions: { width: number, height: number }; + gestureControls: any; + onLongPressActivated: () => void; + onLongPressEnd: () => void; + onLongPressStateChange: (event: any) => void; + toggleControls: () => void; + showControls: boolean; + hideControls: () => void; + volume: number; + brightness: number; + controlsTimeout: React.MutableRefObject; +} + +export const GestureControls: React.FC = ({ + screenDimensions, + gestureControls, + onLongPressActivated, + onLongPressEnd, + onLongPressStateChange, + toggleControls, + showControls, + hideControls, + volume, + brightness, + controlsTimeout +}) => { + + const getVolumeIcon = (value: number) => { + if (value === 0) return 'volume-off'; + if (value < 0.3) return 'volume-mute'; + if (value < 0.6) return 'volume-down'; + return 'volume-up'; + }; + + const getBrightnessIcon = (value: number) => { + if (value < 0.3) return 'brightness-low'; + if (value < 0.7) return 'brightness-medium'; + return 'brightness-high'; + }; + + return ( + <> + {/* Left side gesture handler - brightness + tap + long press */} + + + + + + + + + {/* Right side gesture handler - volume + tap + long press */} + + + + + + + + + {/* Center area tap handler */} + { + if (showControls) { + const timeoutId = setTimeout(() => { + hideControls(); + }, 0); + if (controlsTimeout.current) { + clearTimeout(controlsTimeout.current); + } + controlsTimeout.current = timeoutId; + } else { + toggleControls(); + } + }} + shouldCancelWhenOutside={false} + simultaneousHandlers={[]} + > + + + + {/* Volume/Brightness Pill Overlay - Compact top design */} + {(gestureControls.showVolumeOverlay || gestureControls.showBrightnessOverlay) && ( + + + + + + + + {gestureControls.showVolumeOverlay && volume === 0 + ? "Muted" + : `${Math.round((gestureControls.showVolumeOverlay ? volume : brightness) * 100)}%` + } + + + + )} + + ); +}; diff --git a/src/components/player/components/PauseOverlay.tsx b/src/components/player/components/PauseOverlay.tsx new file mode 100644 index 0000000..31cd49a --- /dev/null +++ b/src/components/player/components/PauseOverlay.tsx @@ -0,0 +1,259 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { View, Text, TouchableOpacity, ScrollView, Animated, StyleSheet } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import FastImage from '@d11/react-native-fast-image'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +// Delay before showing pause overlay (in milliseconds) +const PAUSE_OVERLAY_DELAY = 5000; + +interface PauseOverlayProps { + visible: boolean; + onClose: () => void; + title: string; + episodeTitle?: string; + season?: number; + episode?: number; + year?: string | number; + type: string; + description: string; + cast: any[]; + screenDimensions: { width: number, height: number }; +} + +export const PauseOverlay: React.FC = ({ + visible, + onClose, + title, + episodeTitle, + season, + episode, + year, + type, + description, + cast, + screenDimensions +}) => { + const insets = useSafeAreaInsets(); + + // Internal state to track if overlay should actually be shown (after delay) + const [shouldShow, setShouldShow] = useState(false); + const delayTimerRef = useRef(null); + + // Handle delay logic - show overlay only after paused for 5 seconds + useEffect(() => { + if (visible) { + // Start timer to show overlay after delay + delayTimerRef.current = setTimeout(() => { + setShouldShow(true); + }, PAUSE_OVERLAY_DELAY); + } else { + // Immediately hide when not paused + if (delayTimerRef.current) { + clearTimeout(delayTimerRef.current); + delayTimerRef.current = null; + } + setShouldShow(false); + } + + return () => { + if (delayTimerRef.current) { + clearTimeout(delayTimerRef.current); + delayTimerRef.current = null; + } + }; + }, [visible]); + + // Internal Animation State + const pauseOverlayOpacity = useRef(new Animated.Value(shouldShow ? 1 : 0)).current; + const pauseOverlayTranslateY = useRef(new Animated.Value(12)).current; + const metadataOpacity = useRef(new Animated.Value(1)).current; + const metadataScale = useRef(new Animated.Value(1)).current; + + // Cast Details State + const [selectedCastMember, setSelectedCastMember] = useState(null); + const [showCastDetails, setShowCastDetails] = useState(false); + const castDetailsOpacity = useRef(new Animated.Value(0)).current; + const castDetailsScale = useRef(new Animated.Value(0.95)).current; + + useEffect(() => { + Animated.timing(pauseOverlayOpacity, { + toValue: shouldShow ? 1 : 0, + duration: 250, + useNativeDriver: true + }).start(); + }, [shouldShow]); + + if (!shouldShow && !showCastDetails) return null; + + return ( + + + {/* Horizontal Fade */} + + + + + + + {showCastDetails && selectedCastMember ? ( + + + { + Animated.parallel([ + Animated.timing(castDetailsOpacity, { toValue: 0, duration: 250, useNativeDriver: true }), + Animated.timing(castDetailsScale, { toValue: 0.95, duration: 250, useNativeDriver: true }) + ]).start(() => { + setShowCastDetails(false); + setSelectedCastMember(null); + Animated.parallel([ + Animated.timing(metadataOpacity, { toValue: 1, duration: 400, useNativeDriver: true }), + Animated.spring(metadataScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true }) + ]).start(); + }); + }} + > + + Back to details + + + + {selectedCastMember.profile_path && ( + + + + )} + + + {selectedCastMember.name} + + {selectedCastMember.character && ( + + as {selectedCastMember.character} + + )} + {selectedCastMember.biography && ( + + {selectedCastMember.biography} + + )} + + + + + ) : ( + + + You're watching + + {title} + + {!!year && ( + + {`${year}${type === 'series' && season && episode ? ` • S${season}E${episode}` : ''}`} + + )} + {!!episodeTitle && ( + + {episodeTitle} + + )} + {description && ( + + {description} + + )} + {cast && cast.length > 0 && ( + + Cast + + {cast.slice(0, 6).map((castMember: any, index: number) => ( + { + setSelectedCastMember(castMember); + Animated.parallel([ + Animated.timing(metadataOpacity, { toValue: 0, duration: 250, useNativeDriver: true }), + Animated.timing(metadataScale, { toValue: 0.95, duration: 250, useNativeDriver: true }) + ]).start(() => { + setShowCastDetails(true); + Animated.parallel([ + Animated.timing(castDetailsOpacity, { toValue: 1, duration: 400, useNativeDriver: true }), + Animated.spring(castDetailsScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true }) + ]).start(); + }); + }} + > + + {castMember.name} + + + ))} + + + )} + + + )} + + + + ); +}; diff --git a/src/components/player/components/SpeedActivatedOverlay.tsx b/src/components/player/components/SpeedActivatedOverlay.tsx new file mode 100644 index 0000000..53a5131 --- /dev/null +++ b/src/components/player/components/SpeedActivatedOverlay.tsx @@ -0,0 +1,38 @@ +/** + * Shared Speed Activated Overlay Component + * Used by both Android (VLC) and iOS (KSPlayer) players + */ +import React from 'react'; +import { View, Text, Animated } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { styles } from '../utils/playerStyles'; + +interface SpeedActivatedOverlayProps { + visible: boolean; + opacity: Animated.Value; + speed: number; +} + +export const SpeedActivatedOverlay: React.FC = ({ + visible, + opacity, + speed +}) => { + if (!visible) return null; + + return ( + + + + {speed}x Speed + + + ); +}; + +export default SpeedActivatedOverlay; diff --git a/src/components/player/components/index.ts b/src/components/player/components/index.ts new file mode 100644 index 0000000..5f1c8e9 --- /dev/null +++ b/src/components/player/components/index.ts @@ -0,0 +1,8 @@ +/** + * Shared Player Components + * Export all reusable components for both Android and iOS players + */ + +export { SpeedActivatedOverlay } from './SpeedActivatedOverlay'; +export { PauseOverlay } from './PauseOverlay'; +export { GestureControls } from './GestureControls'; diff --git a/src/components/player/hooks/index.ts b/src/components/player/hooks/index.ts new file mode 100644 index 0000000..9c91138 --- /dev/null +++ b/src/components/player/hooks/index.ts @@ -0,0 +1,21 @@ +/** + * Shared Player Hooks + * Export all reusable hooks for both Android and iOS players + */ + +// State Management +export { usePlayerState, type PlayerResizeMode } from './usePlayerState'; +export { usePlayerModals } from './usePlayerModals'; +export { usePlayerTracks } from './usePlayerTracks'; +export { useCustomSubtitles } from './useCustomSubtitles'; + +// Controls & Playback +export { usePlayerControls } from './usePlayerControls'; +export { useSpeedControl } from './useSpeedControl'; + +// Animation & UI +export { useOpeningAnimation } from './useOpeningAnimation'; +export { usePlayerSetup } from './usePlayerSetup'; + +// Content +export { useNextEpisode } from './useNextEpisode'; diff --git a/src/components/player/hooks/useCustomSubtitles.ts b/src/components/player/hooks/useCustomSubtitles.ts new file mode 100644 index 0000000..5def5c9 --- /dev/null +++ b/src/components/player/hooks/useCustomSubtitles.ts @@ -0,0 +1,62 @@ +/** + * Shared Custom Subtitles Hook + * Used by both Android (VLC) and iOS (KSPlayer) players + */ +import { useState } from 'react'; +import { + DEFAULT_SUBTITLE_SIZE, + SubtitleCue, + SubtitleSegment, + WyzieSubtitle +} from '../utils/playerTypes'; + +export const useCustomSubtitles = () => { + // Data State + const [customSubtitles, setCustomSubtitles] = useState([]); + const [currentSubtitle, setCurrentSubtitle] = useState(''); + const [currentFormattedSegments, setCurrentFormattedSegments] = useState([]); + const [availableSubtitles, setAvailableSubtitles] = useState([]); + const [useCustomSubtitles, setUseCustomSubtitles] = useState(false); + + // Loading State + const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false); + const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false); + + // Styling State + const [subtitleSize, setSubtitleSize] = useState(DEFAULT_SUBTITLE_SIZE); + const [subtitleBackground, setSubtitleBackground] = useState(false); + const [subtitleTextColor, setSubtitleTextColor] = useState('#FFFFFF'); + const [subtitleBgOpacity, setSubtitleBgOpacity] = useState(0.7); + const [subtitleTextShadow, setSubtitleTextShadow] = useState(true); + const [subtitleOutline, setSubtitleOutline] = useState(true); + const [subtitleOutlineColor, setSubtitleOutlineColor] = useState('#000000'); + const [subtitleOutlineWidth, setSubtitleOutlineWidth] = useState(4); + const [subtitleAlign, setSubtitleAlign] = useState<'center' | 'left' | 'right'>('center'); + const [subtitleBottomOffset, setSubtitleBottomOffset] = useState(10); + const [subtitleLetterSpacing, setSubtitleLetterSpacing] = useState(0); + const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState(1.2); + const [subtitleOffsetSec, setSubtitleOffsetSec] = useState(0); + + return { + customSubtitles, setCustomSubtitles, + currentSubtitle, setCurrentSubtitle, + currentFormattedSegments, setCurrentFormattedSegments, + availableSubtitles, setAvailableSubtitles, + useCustomSubtitles, setUseCustomSubtitles, + isLoadingSubtitles, setIsLoadingSubtitles, + isLoadingSubtitleList, setIsLoadingSubtitleList, + subtitleSize, setSubtitleSize, + subtitleBackground, setSubtitleBackground, + subtitleTextColor, setSubtitleTextColor, + subtitleBgOpacity, setSubtitleBgOpacity, + subtitleTextShadow, setSubtitleTextShadow, + subtitleOutline, setSubtitleOutline, + subtitleOutlineColor, setSubtitleOutlineColor, + subtitleOutlineWidth, setSubtitleOutlineWidth, + subtitleAlign, setSubtitleAlign, + subtitleBottomOffset, setSubtitleBottomOffset, + subtitleLetterSpacing, setSubtitleLetterSpacing, + subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier, + subtitleOffsetSec, setSubtitleOffsetSec + }; +}; diff --git a/src/components/player/hooks/useNextEpisode.ts b/src/components/player/hooks/useNextEpisode.ts new file mode 100644 index 0000000..d754daf --- /dev/null +++ b/src/components/player/hooks/useNextEpisode.ts @@ -0,0 +1,65 @@ +/** + * Shared Next Episode Hook + * Used by both Android (VLC) and iOS (KSPlayer) players + */ +import { useMemo } from 'react'; + +interface NextEpisodeConfig { + type: string | undefined; + season: number | undefined; + episode: number | undefined; + groupedEpisodes: Record | undefined; + episodeId?: string; +} + +export const useNextEpisode = (config: NextEpisodeConfig) => { + const { type, season, episode, groupedEpisodes, episodeId } = config; + + // Current description + const currentEpisodeDescription = useMemo(() => { + try { + if (type !== 'series') return ''; + const allEpisodes = Object.values(groupedEpisodes || {}).flat() as any[]; + if (!allEpisodes || allEpisodes.length === 0) return ''; + + let match: any | null = null; + if (episodeId) { + match = allEpisodes.find(ep => ep?.stremioId === episodeId || String(ep?.id) === String(episodeId)); + } + if (!match && season && episode) { + match = allEpisodes.find(ep => ep?.season_number === season && ep?.episode_number === episode); + } + return (match?.overview || '').trim(); + } catch { + return ''; + } + }, [type, groupedEpisodes, episodeId, season, episode]); + + // Next Episode + const nextEpisode = useMemo(() => { + try { + if (type !== 'series' || !season || !episode) return null; + const sourceGroups = groupedEpisodes || {}; + + const allEpisodes = Object.values(sourceGroups).flat() as any[]; + if (!allEpisodes || allEpisodes.length === 0) return null; + + // Try to find next episode in same season + let nextEp = allEpisodes.find((ep: any) => + ep.season_number === season && ep.episode_number === episode + 1 + ); + + // If not found, try first episode of next season + if (!nextEp) { + nextEp = allEpisodes.find((ep: any) => + ep.season_number === season + 1 && ep.episode_number === 1 + ); + } + return nextEp; + } catch { + return null; + } + }, [type, season, episode, groupedEpisodes]); + + return { currentEpisodeDescription, nextEpisode }; +}; diff --git a/src/components/player/hooks/useOpeningAnimation.ts b/src/components/player/hooks/useOpeningAnimation.ts new file mode 100644 index 0000000..a292f45 --- /dev/null +++ b/src/components/player/hooks/useOpeningAnimation.ts @@ -0,0 +1,152 @@ +/** + * Shared Opening Animation Hook + * Used by both Android (VLC) and iOS (KSPlayer) players + */ +import { useRef, useState, useEffect } from 'react'; +import { Animated, InteractionManager } from 'react-native'; +import FastImage from '@d11/react-native-fast-image'; +import { logger } from '../../../utils/logger'; + +export const useOpeningAnimation = (backdrop: string | undefined, metadata: any) => { + // Animation Values + const fadeAnim = useRef(new Animated.Value(1)).current; + const openingFadeAnim = useRef(new Animated.Value(0)).current; + const openingScaleAnim = useRef(new Animated.Value(0.8)).current; + const backgroundFadeAnim = useRef(new Animated.Value(1)).current; + const backdropImageOpacityAnim = useRef(new Animated.Value(0)).current; + const logoScaleAnim = useRef(new Animated.Value(0.8)).current; + const logoOpacityAnim = useRef(new Animated.Value(0)).current; + const pulseAnim = useRef(new Animated.Value(1)).current; + + const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false); + const [shouldHideOpeningOverlay, setShouldHideOpeningOverlay] = useState(false); + const [isBackdropLoaded, setIsBackdropLoaded] = useState(false); + + // Prefetch Background + useEffect(() => { + const task = InteractionManager.runAfterInteractions(() => { + if (backdrop && typeof backdrop === 'string') { + setIsBackdropLoaded(false); + backdropImageOpacityAnim.setValue(0); + try { + FastImage.preload([{ uri: backdrop }]); + setIsBackdropLoaded(true); + Animated.timing(backdropImageOpacityAnim, { + toValue: 1, + duration: 400, + useNativeDriver: true, + }).start(); + } catch (error) { + setIsBackdropLoaded(true); + backdropImageOpacityAnim.setValue(1); + } + } else { + setIsBackdropLoaded(true); + backdropImageOpacityAnim.setValue(0); + } + }); + return () => task.cancel(); + }, [backdrop]); + + // Prefetch Logo + useEffect(() => { + const task = InteractionManager.runAfterInteractions(() => { + const logoUrl = metadata?.logo; + if (logoUrl && typeof logoUrl === 'string') { + try { + FastImage.preload([{ uri: logoUrl }]); + } catch (error) { } + } + }); + return () => task.cancel(); + }, [metadata]); + + const startOpeningAnimation = () => { + Animated.parallel([ + Animated.timing(logoOpacityAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + Animated.spring(logoScaleAnim, { + toValue: 1, + tension: 80, + friction: 8, + useNativeDriver: true, + }), + ]).start(); + + const createPulseAnimation = () => { + return Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1.05, + duration: 800, + useNativeDriver: true, + }), + Animated.timing(pulseAnim, { + toValue: 1, + duration: 800, + useNativeDriver: true, + }), + ]); + }; + + const loopPulse = () => { + createPulseAnimation().start(() => { + if (!isOpeningAnimationComplete) { + loopPulse(); + } + }); + }; + loopPulse(); + }; + + const completeOpeningAnimation = () => { + pulseAnim.stopAnimation(); + + Animated.parallel([ + Animated.timing(openingFadeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(openingScaleAnim, { + toValue: 1, + duration: 350, + useNativeDriver: true, + }), + Animated.timing(backgroundFadeAnim, { + toValue: 0, + duration: 400, + useNativeDriver: true, + }), + ]).start(() => { + setIsOpeningAnimationComplete(true); + setTimeout(() => { + setShouldHideOpeningOverlay(true); + }, 450); + }); + + setTimeout(() => { + if (!isOpeningAnimationComplete) { + setIsOpeningAnimationComplete(true); + } + }, 1000); + }; + + return { + fadeAnim, + openingFadeAnim, + openingScaleAnim, + backgroundFadeAnim, + backdropImageOpacityAnim, + logoScaleAnim, + logoOpacityAnim, + pulseAnim, + isOpeningAnimationComplete, + shouldHideOpeningOverlay, + isBackdropLoaded, + startOpeningAnimation, + completeOpeningAnimation + }; +}; diff --git a/src/components/player/hooks/usePlayerControls.ts b/src/components/player/hooks/usePlayerControls.ts new file mode 100644 index 0000000..c94cc74 --- /dev/null +++ b/src/components/player/hooks/usePlayerControls.ts @@ -0,0 +1,81 @@ +/** + * Shared Player Controls Hook + * Used by both Android (VLC) and iOS (KSPlayer) players + */ +import { useRef, useCallback, MutableRefObject } from 'react'; +import { Platform } from 'react-native'; +import { logger } from '../../../utils/logger'; + +const DEBUG_MODE = false; +const END_EPSILON = 0.3; + +interface PlayerControlsConfig { + playerRef: MutableRefObject; + paused: boolean; + setPaused: (paused: boolean) => void; + currentTime: number; + duration: number; + isSeeking: MutableRefObject; + isMounted: MutableRefObject; +} + +export const usePlayerControls = (config: PlayerControlsConfig) => { + const { + playerRef, + paused, + setPaused, + currentTime, + duration, + isSeeking, + isMounted + } = config; + + // iOS seeking helpers + const iosWasPausedDuringSeekRef = useRef(null); + + const togglePlayback = useCallback(() => { + setPaused(!paused); + }, [paused, setPaused]); + + const seekToTime = useCallback((rawSeconds: number) => { + const timeInSeconds = Math.max(0, Math.min(rawSeconds, duration > 0 ? duration - END_EPSILON : rawSeconds)); + + if (playerRef.current && duration > 0 && !isSeeking.current) { + if (DEBUG_MODE) logger.log(`[usePlayerControls] Seeking to ${timeInSeconds}`); + + isSeeking.current = true; + + // iOS optimization: pause while seeking for smoother experience + if (Platform.OS === 'ios') { + iosWasPausedDuringSeekRef.current = paused; + if (!paused) setPaused(true); + } + + // Actually perform the seek + playerRef.current.seek(timeInSeconds); + + // Debounce the seeking state reset + setTimeout(() => { + if (isMounted.current && isSeeking.current) { + isSeeking.current = false; + // Resume if it was playing (iOS specific) + if (Platform.OS === 'ios' && iosWasPausedDuringSeekRef.current === false) { + setPaused(false); + iosWasPausedDuringSeekRef.current = null; + } + } + }, 500); + } + }, [duration, paused, setPaused, playerRef, isSeeking, isMounted]); + + const skip = useCallback((seconds: number) => { + seekToTime(currentTime + seconds); + }, [currentTime, seekToTime]); + + return { + togglePlayback, + seekToTime, + skip, + iosWasPausedDuringSeekRef + }; +}; diff --git a/src/components/player/hooks/usePlayerModals.ts b/src/components/player/hooks/usePlayerModals.ts new file mode 100644 index 0000000..1615515 --- /dev/null +++ b/src/components/player/hooks/usePlayerModals.ts @@ -0,0 +1,38 @@ +/** + * Shared Player Modals Hook + * Used by both Android (VLC) and iOS (KSPlayer) players + */ +import { useState } from 'react'; +import { Episode } from '../../../types/metadata'; + +export const usePlayerModals = () => { + const [showAudioModal, setShowAudioModal] = useState(false); + const [showSubtitleModal, setShowSubtitleModal] = useState(false); + const [showSpeedModal, setShowSpeedModal] = useState(false); + const [showSourcesModal, setShowSourcesModal] = useState(false); + const [showEpisodesModal, setShowEpisodesModal] = useState(false); + const [showEpisodeStreamsModal, setShowEpisodeStreamsModal] = useState(false); + const [showErrorModal, setShowErrorModal] = useState(false); + const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false); + const [showCastDetails, setShowCastDetails] = useState(false); + + // Some modals have associated data + const [selectedEpisodeForStreams, setSelectedEpisodeForStreams] = useState(null); + const [errorDetails, setErrorDetails] = useState(''); + const [selectedCastMember, setSelectedCastMember] = useState(null); + + return { + showAudioModal, setShowAudioModal, + showSubtitleModal, setShowSubtitleModal, + showSpeedModal, setShowSpeedModal, + showSourcesModal, setShowSourcesModal, + showEpisodesModal, setShowEpisodesModal, + showEpisodeStreamsModal, setShowEpisodeStreamsModal, + showErrorModal, setShowErrorModal, + showSubtitleLanguageModal, setShowSubtitleLanguageModal, + showCastDetails, setShowCastDetails, + selectedEpisodeForStreams, setSelectedEpisodeForStreams, + errorDetails, setErrorDetails, + selectedCastMember, setSelectedCastMember + }; +}; diff --git a/src/components/player/hooks/usePlayerSetup.ts b/src/components/player/hooks/usePlayerSetup.ts new file mode 100644 index 0000000..f293611 --- /dev/null +++ b/src/components/player/hooks/usePlayerSetup.ts @@ -0,0 +1,117 @@ +/** + * Shared Player Setup Hook + * Used by both Android (VLC) and iOS (KSPlayer) players + * Handles StatusBar, orientation, brightness, and app state + */ +import { useEffect, useRef, useCallback } from 'react'; +import { StatusBar, Dimensions, AppState, InteractionManager, Platform } from 'react-native'; +import * as Brightness from 'expo-brightness'; +import * as ScreenOrientation from 'expo-screen-orientation'; +import { logger } from '../../../utils/logger'; +import { useFocusEffect } from '@react-navigation/native'; + +interface PlayerSetupConfig { + setScreenDimensions: (dim: any) => void; + setVolume: (vol: number) => void; + setBrightness: (bri: number) => void; + isOpeningAnimationComplete: boolean; +} + +export const usePlayerSetup = (config: PlayerSetupConfig) => { + const { + setScreenDimensions, + setVolume, + setBrightness, + isOpeningAnimationComplete + } = config; + + const isAppBackgrounded = useRef(false); + + const enableImmersiveMode = () => { + StatusBar.setHidden(true, 'none'); + }; + + const disableImmersiveMode = () => { + StatusBar.setHidden(false, 'fade'); + }; + + useFocusEffect( + useCallback(() => { + if (isOpeningAnimationComplete) { + enableImmersiveMode(); + } + return () => { }; + }, [isOpeningAnimationComplete]) + ); + + useEffect(() => { + // Initial Setup + const subscription = Dimensions.addEventListener('change', ({ screen }) => { + setScreenDimensions(screen); + if (isOpeningAnimationComplete) { + enableImmersiveMode(); + } + }); + + StatusBar.setHidden(true, 'none'); + if (isOpeningAnimationComplete) { + enableImmersiveMode(); + } + + // Initialize volume (normalized 0-1 for cross-platform) + setVolume(1.0); + + // Initialize Brightness + const initBrightness = () => { + InteractionManager.runAfterInteractions(async () => { + try { + const currentBrightness = await Brightness.getBrightnessAsync(); + setBrightness(currentBrightness); + } catch (error) { + logger.warn('[usePlayerSetup] Error getting initial brightness:', error); + setBrightness(1.0); + } + }); + }; + initBrightness(); + + return () => { + subscription?.remove(); + disableImmersiveMode(); + }; + }, [isOpeningAnimationComplete]); + + // Handle Orientation (Lock to Landscape after opening) + useEffect(() => { + if (isOpeningAnimationComplete) { + const task = InteractionManager.runAfterInteractions(() => { + ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE) + .then(() => { + if (__DEV__) logger.log('[VideoPlayer] Locked to landscape orientation'); + }) + .catch((error) => { + logger.warn('[VideoPlayer] Failed to lock orientation:', error); + }); + }); + return () => task.cancel(); + } + }, [isOpeningAnimationComplete]); + + // Handle App State + useEffect(() => { + const onAppStateChange = (state: string) => { + if (state === 'active') { + isAppBackgrounded.current = false; + if (isOpeningAnimationComplete) { + enableImmersiveMode(); + } + } else if (state === 'background' || state === 'inactive') { + isAppBackgrounded.current = true; + } + }; + const sub = AppState.addEventListener('change', onAppStateChange); + return () => sub.remove(); + }, [isOpeningAnimationComplete]); + + return { isAppBackgrounded }; +}; diff --git a/src/components/player/hooks/usePlayerState.ts b/src/components/player/hooks/usePlayerState.ts new file mode 100644 index 0000000..828def5 --- /dev/null +++ b/src/components/player/hooks/usePlayerState.ts @@ -0,0 +1,88 @@ +/** + * Shared Player State Hook + * Used by both Android (VLC) and iOS (KSPlayer) players + */ +import { useState, useRef } from 'react'; +import { Dimensions, Platform } from 'react-native'; + +// Use only resize modes supported by all player backends +// (not all players support 'stretch' or 'none') +export type PlayerResizeMode = 'contain' | 'cover'; + +export const usePlayerState = () => { + // Playback State + const [paused, setPaused] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [buffered, setBuffered] = useState(0); + const [isBuffering, setIsBuffering] = useState(false); + const [isVideoLoaded, setIsVideoLoaded] = useState(false); + const [isPlayerReady, setIsPlayerReady] = useState(false); + + // UI State + const [showControls, setShowControls] = useState(true); + const [resizeMode, setResizeMode] = useState('contain'); + const [videoAspectRatio, setVideoAspectRatio] = useState(null); + const [is16by9Content, setIs16by9Content] = useState(false); + const screenData = Dimensions.get('screen'); + const [screenDimensions, setScreenDimensions] = useState(screenData); + + // Zoom State + const [zoomScale, setZoomScale] = useState(1); + const [zoomTranslateX, setZoomTranslateX] = useState(0); + const [zoomTranslateY, setZoomTranslateY] = useState(0); + const [lastZoomScale, setLastZoomScale] = useState(1); + const [lastTranslateX, setLastTranslateX] = useState(0); + const [lastTranslateY, setLastTranslateY] = useState(0); + + // AirPlay State (iOS only, but keeping it here for unified interface) + const [isAirPlayActive, setIsAirPlayActive] = useState(false); + const [allowsAirPlay, setAllowsAirPlay] = useState(true); + + // Logic State + const isSeeking = useRef(false); + const isDragging = useRef(false); + const isMounted = useRef(true); + const seekDebounceTimer = useRef(null); + const pendingSeekValue = useRef(null); + const lastSeekTime = useRef(0); + const wasPlayingBeforeDragRef = useRef(false); + + // Helper for iPad/macOS fullscreen + const isIPad = Platform.OS === 'ios' && (screenData.width > 1000 || screenData.height > 1000); + const isMacOS = Platform.OS === 'ios' && Platform.isPad === true; + const shouldUseFullscreen = isIPad || isMacOS; + const windowData = Dimensions.get('window'); + const effectiveDimensions = shouldUseFullscreen ? windowData : screenDimensions; + + return { + paused, setPaused, + currentTime, setCurrentTime, + duration, setDuration, + buffered, setBuffered, + isBuffering, setIsBuffering, + isVideoLoaded, setIsVideoLoaded, + isPlayerReady, setIsPlayerReady, + showControls, setShowControls, + resizeMode, setResizeMode, + videoAspectRatio, setVideoAspectRatio, + is16by9Content, setIs16by9Content, + screenDimensions, setScreenDimensions, + zoomScale, setZoomScale, + zoomTranslateX, setZoomTranslateX, + zoomTranslateY, setZoomTranslateY, + lastZoomScale, setLastZoomScale, + lastTranslateX, setLastTranslateX, + lastTranslateY, setLastTranslateY, + isAirPlayActive, setIsAirPlayActive, + allowsAirPlay, setAllowsAirPlay, + isSeeking, + isDragging, + isMounted, + seekDebounceTimer, + pendingSeekValue, + lastSeekTime, + wasPlayingBeforeDragRef, + effectiveDimensions + }; +}; diff --git a/src/components/player/hooks/usePlayerTracks.ts b/src/components/player/hooks/usePlayerTracks.ts new file mode 100644 index 0000000..1f19fec --- /dev/null +++ b/src/components/player/hooks/usePlayerTracks.ts @@ -0,0 +1,47 @@ +/** + * Shared Player Tracks Hook + * Used by both Android (VLC) and iOS (KSPlayer) players + */ +import { useState, useCallback } from 'react'; +import { AudioTrack, TextTrack } from '../utils/playerTypes'; + +export const usePlayerTracks = () => { + // React-native-video style tracks + const [audioTracks, setAudioTracks] = useState([]); + const [selectedAudioTrack, setSelectedAudioTrack] = useState(null); + const [textTracks, setTextTracks] = useState([]); + const [selectedTextTrack, setSelectedTextTrack] = useState(-1); + + // KS/VLC style tracks (simpler format) + const [ksAudioTracks, setKsAudioTracks] = useState>([]); + const [ksTextTracks, setKsTextTracks] = useState>([]); + + // Derived states + const hasAudioTracks = audioTracks.length > 0 || ksAudioTracks.length > 0; + const hasTextTracks = textTracks.length > 0 || ksTextTracks.length > 0; + + // Track selection functions + const selectAudioTrack = useCallback((trackId: number) => { + setSelectedAudioTrack(trackId); + }, []); + + const selectTextTrack = useCallback((trackId: number) => { + setSelectedTextTrack(trackId); + }, []); + + return { + // Standard tracks + audioTracks, setAudioTracks, + selectedAudioTrack, setSelectedAudioTrack, + textTracks, setTextTracks, + selectedTextTrack, setSelectedTextTrack, + // KS/VLC tracks + ksAudioTracks, setKsAudioTracks, + ksTextTracks, setKsTextTracks, + // Helpers + hasAudioTracks, + hasTextTracks, + selectAudioTrack, + selectTextTrack + }; +}; diff --git a/src/components/player/hooks/useSpeedControl.ts b/src/components/player/hooks/useSpeedControl.ts new file mode 100644 index 0000000..a8a0c02 --- /dev/null +++ b/src/components/player/hooks/useSpeedControl.ts @@ -0,0 +1,97 @@ +/** + * Shared Speed Control Hook + * Used by both Android (VLC) and iOS (KSPlayer) players + */ +import { useState, useRef, useCallback, useEffect } from 'react'; +import { Animated } from 'react-native'; +import { mmkvStorage } from '../../../services/mmkvStorage'; +import { logger } from '../../../utils/logger'; + +const SPEED_SETTINGS_KEY = '@nuvio_speed_settings'; + +export const useSpeedControl = (initialSpeed: number = 1.0) => { + const [playbackSpeed, setPlaybackSpeed] = useState(initialSpeed); + const [holdToSpeedEnabled, setHoldToSpeedEnabled] = useState(true); + const [holdToSpeedValue, setHoldToSpeedValue] = useState(2.0); + const [isSpeedBoosted, setIsSpeedBoosted] = useState(false); + const [originalSpeed, setOriginalSpeed] = useState(initialSpeed); + const [showSpeedActivatedOverlay, setShowSpeedActivatedOverlay] = useState(false); + + const speedActivatedOverlayOpacity = useRef(new Animated.Value(0)).current; + + // Load Settings + useEffect(() => { + const loadSettings = async () => { + try { + const saved = await mmkvStorage.getItem(SPEED_SETTINGS_KEY); + if (saved) { + const settings = JSON.parse(saved); + if (typeof settings.holdToSpeedEnabled === 'boolean') setHoldToSpeedEnabled(settings.holdToSpeedEnabled); + if (typeof settings.holdToSpeedValue === 'number') setHoldToSpeedValue(settings.holdToSpeedValue); + } + } catch (e) { + logger.warn('[useSpeedControl] Error loading settings', e); + } + }; + loadSettings(); + }, []); + + // Save Settings + useEffect(() => { + const saveSettings = async () => { + try { + await mmkvStorage.setItem(SPEED_SETTINGS_KEY, JSON.stringify({ + holdToSpeedEnabled, + holdToSpeedValue + })); + } catch (e) { } + }; + saveSettings(); + }, [holdToSpeedEnabled, holdToSpeedValue]); + + const activateSpeedBoost = useCallback(() => { + if (!holdToSpeedEnabled || isSpeedBoosted || playbackSpeed === holdToSpeedValue) return; + + setOriginalSpeed(playbackSpeed); + setPlaybackSpeed(holdToSpeedValue); + setIsSpeedBoosted(true); + setShowSpeedActivatedOverlay(true); + + Animated.timing(speedActivatedOverlayOpacity, { + toValue: 1, + duration: 200, + useNativeDriver: true + }).start(); + + setTimeout(() => { + Animated.timing(speedActivatedOverlayOpacity, { + toValue: 0, + duration: 300, + useNativeDriver: true + }).start(() => setShowSpeedActivatedOverlay(false)); + }, 2000); + + }, [holdToSpeedEnabled, isSpeedBoosted, playbackSpeed, holdToSpeedValue]); + + const deactivateSpeedBoost = useCallback(() => { + if (isSpeedBoosted) { + setPlaybackSpeed(originalSpeed); + setIsSpeedBoosted(false); + Animated.timing(speedActivatedOverlayOpacity, { toValue: 0, duration: 100, useNativeDriver: true }).start(); + } + }, [isSpeedBoosted, originalSpeed]); + + return { + playbackSpeed, + setPlaybackSpeed, + holdToSpeedEnabled, + setHoldToSpeedEnabled, + holdToSpeedValue, + setHoldToSpeedValue, + isSpeedBoosted, + activateSpeedBoost, + deactivateSpeedBoost, + showSpeedActivatedOverlay, + speedActivatedOverlayOpacity + }; +}; diff --git a/src/components/player/ios/components/GestureControls.tsx b/src/components/player/ios/components/GestureControls.tsx new file mode 100644 index 0000000..f0bb38d --- /dev/null +++ b/src/components/player/ios/components/GestureControls.tsx @@ -0,0 +1,333 @@ +import React from 'react'; +import { View, Text, Animated } from 'react-native'; +import { + TapGestureHandler, + PanGestureHandler, + LongPressGestureHandler, +} from 'react-native-gesture-handler'; +import { MaterialIcons } from '@expo/vector-icons'; + +interface GestureControlsProps { + screenDimensions: { width: number, height: number }; + gestureControls: any; + onLongPressActivated: () => void; + onLongPressEnd: () => void; + onLongPressStateChange: (event: any) => void; + toggleControls: () => void; + showControls: boolean; + hideControls: () => void; + volume: number; + brightness: number; + controlsTimeout: React.MutableRefObject; +} + +export const GestureControls: React.FC = ({ + screenDimensions, + gestureControls, + onLongPressActivated, + onLongPressEnd, + onLongPressStateChange, + toggleControls, + showControls, + hideControls, + volume, + brightness, + controlsTimeout +}) => { + // Helper to get dimensions (using passed screenDimensions) + const getDimensions = () => screenDimensions; + + return ( + <> + {/* Left side gesture handler - brightness + tap + long press */} + + + + + + + + + {/* Right side gesture handler - volume + tap + long press */} + + + + + + + + + {/* Center area tap handler */} + { + if (showControls) { + const timeoutId = setTimeout(() => { + hideControls(); + }, 0); + if (controlsTimeout.current) { + clearTimeout(controlsTimeout.current); + } + controlsTimeout.current = timeoutId; + } else { + toggleControls(); + } + }} + shouldCancelWhenOutside={false} + simultaneousHandlers={[]} + > + + + + {/* Volume Overlay */} + {gestureControls.showVolumeOverlay && ( + + + + + {/* Horizontal Dotted Progress Bar */} + + {/* Dotted background */} + + {Array.from({ length: 16 }, (_, i) => ( + + ))} + + + {/* Progress fill */} + + + + + {Math.round(volume)}% + + + + )} + + {/* Brightness Overlay */} + {gestureControls.showBrightnessOverlay && ( + + + + + {/* Horizontal Dotted Progress Bar */} + + {/* Dotted background */} + + {Array.from({ length: 16 }, (_, i) => ( + + ))} + + + {/* Progress fill */} + + + + + {Math.round(brightness * 100)}% + + + + )} + + ); +}; diff --git a/src/components/player/ios/components/KSPlayerSurface.tsx b/src/components/player/ios/components/KSPlayerSurface.tsx new file mode 100644 index 0000000..715e6b2 --- /dev/null +++ b/src/components/player/ios/components/KSPlayerSurface.tsx @@ -0,0 +1,140 @@ +import React, { useRef } from 'react'; +import { Animated } from 'react-native'; +import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler'; +import KSPlayerComponent, { KSPlayerRef, KSPlayerSource } from '../../KSPlayerComponent'; + +interface KSPlayerSurfaceProps { + ksPlayerRef: React.RefObject; + uri: string; + headers?: Record; + paused: boolean; + volume: number; + playbackSpeed: number; + resizeMode: 'contain' | 'cover' | 'stretch'; + zoomScale: number; + setZoomScale: (scale: number) => void; + lastZoomScale: number; + setLastZoomScale: (scale: number) => void; + + // Tracks - use number directly + audioTrack?: number; + textTrack?: number; + onAudioTracks: (data: any) => void; + onTextTracks: (data: any) => void; + + // Handlers + onLoad: (data: any) => void; + onProgress: (data: any) => void; + onEnd: () => void; + onError: (error: any) => void; + onBuffer: (isBuffering: boolean) => void; + onReadyForDisplay: () => void; + onPlaybackStalled: () => void; + onPlaybackResume: () => void; + + // Dimensions + screenWidth: number; + screenHeight: number; + customVideoStyles: any; +} + +export const KSPlayerSurface: React.FC = ({ + ksPlayerRef, + uri, + headers, + paused, + volume, + playbackSpeed, + resizeMode, + zoomScale, + setZoomScale, + lastZoomScale, + setLastZoomScale, + audioTrack, + textTrack, + onAudioTracks, + onTextTracks, + onLoad, + onProgress, + onEnd, + onError, + onBuffer, + onReadyForDisplay, + onPlaybackStalled, + onPlaybackResume, + screenWidth, + screenHeight, + customVideoStyles +}) => { + const pinchRef = useRef(null); + + const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => { + const { scale } = event.nativeEvent; + // Limit max zoom to 1.1x as per original logic, min 1 + const newScale = Math.max(1, Math.min(lastZoomScale * scale, 1.1)); + setZoomScale(newScale); + }; + + const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => { + if (event.nativeEvent.state === State.END) { + setLastZoomScale(zoomScale); + } + }; + + // Create source object for KSPlayerComponent + const source: KSPlayerSource = { + uri, + headers + }; + + // Handle buffering - KSPlayerComponent uses onBuffering callback + const handleBuffering = (data: any) => { + onBuffer(data?.isBuffering ?? false); + }; + + // Handle load - also extract tracks if available + const handleLoad = (data: any) => { + onLoad(data); + // Extract tracks if present in load data + if (data?.audioTracks) { + onAudioTracks({ audioTracks: data.audioTracks }); + } + if (data?.textTracks) { + onTextTracks({ textTracks: data.textTracks }); + } + // Notify ready for display + onReadyForDisplay(); + }; + + return ( + + + + + + ); +}; diff --git a/src/components/player/ios/components/PauseOverlay.tsx b/src/components/player/ios/components/PauseOverlay.tsx new file mode 100644 index 0000000..932089a --- /dev/null +++ b/src/components/player/ios/components/PauseOverlay.tsx @@ -0,0 +1,228 @@ +import React, { useState, useRef } from 'react'; +import { View, Text, TouchableOpacity, Animated, StyleSheet } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import FastImage from '@d11/react-native-fast-image'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +interface PauseOverlayProps { + visible: boolean; + onClose: () => void; + title: string; + episodeTitle?: string; + season?: number; + episode?: number; + year?: string | number; + type: string; + description: string; + cast: any[]; + screenDimensions: { width: number, height: number }; +} + +export const PauseOverlay: React.FC = ({ + visible, + onClose, + title, + episodeTitle, + season, + episode, + year, + type, + description, + cast, + screenDimensions +}) => { + const insets = useSafeAreaInsets(); + + // Internal Animation State + const pauseOverlayOpacity = useRef(new Animated.Value(visible ? 1 : 0)).current; + const pauseOverlayTranslateY = useRef(new Animated.Value(12)).current; + const metadataOpacity = useRef(new Animated.Value(1)).current; + const metadataScale = useRef(new Animated.Value(1)).current; + + // Cast Details State + const [selectedCastMember, setSelectedCastMember] = useState(null); + const [showCastDetails, setShowCastDetails] = useState(false); + const castDetailsOpacity = useRef(new Animated.Value(0)).current; + const castDetailsScale = useRef(new Animated.Value(0.95)).current; + + React.useEffect(() => { + Animated.timing(pauseOverlayOpacity, { + toValue: visible ? 1 : 0, + duration: 250, + useNativeDriver: true + }).start(); + }, [visible]); + + if (!visible && !showCastDetails) return null; + + return ( + + + {/* Horizontal Fade */} + + + + + + + {showCastDetails && selectedCastMember ? ( + + + { + Animated.parallel([ + Animated.timing(castDetailsOpacity, { toValue: 0, duration: 250, useNativeDriver: true }), + Animated.timing(castDetailsScale, { toValue: 0.95, duration: 250, useNativeDriver: true }) + ]).start(() => { + setShowCastDetails(false); + setSelectedCastMember(null); + Animated.parallel([ + Animated.timing(metadataOpacity, { toValue: 1, duration: 400, useNativeDriver: true }), + Animated.spring(metadataScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true }) + ]).start(); + }); + }} + > + + Back to details + + + + {selectedCastMember.profile_path && ( + + + + )} + + + {selectedCastMember.name} + + {selectedCastMember.character && ( + + as {selectedCastMember.character} + + )} + {selectedCastMember.biography && ( + + {selectedCastMember.biography} + + )} + + + + + ) : ( + + + You're watching + + {title} + + {!!year && ( + + {`${year}${type === 'series' && season && episode ? ` • S${season}E${episode}` : ''}`} + + )} + {!!episodeTitle && ( + + {episodeTitle} + + )} + {description && ( + + {description} + + )} + {cast && cast.length > 0 && ( + + Cast + + {cast.slice(0, 6).map((castMember: any, index: number) => ( + { + setSelectedCastMember(castMember); + Animated.parallel([ + Animated.timing(metadataOpacity, { toValue: 0, duration: 250, useNativeDriver: true }), + Animated.timing(metadataScale, { toValue: 0.95, duration: 250, useNativeDriver: true }) + ]).start(() => { + setShowCastDetails(true); + Animated.parallel([ + Animated.timing(castDetailsOpacity, { toValue: 1, duration: 400, useNativeDriver: true }), + Animated.spring(castDetailsScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true }) + ]).start(); + }); + }} + > + + {castMember.name} + + + ))} + + + )} + + + )} + + + + ); +}; diff --git a/src/components/player/ios/components/SpeedActivatedOverlay.tsx b/src/components/player/ios/components/SpeedActivatedOverlay.tsx new file mode 100644 index 0000000..b835364 --- /dev/null +++ b/src/components/player/ios/components/SpeedActivatedOverlay.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { View, Text, Animated } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { styles } from '../../utils/playerStyles'; + +interface SpeedActivatedOverlayProps { + visible: boolean; + opacity: Animated.Value; + speed: number; +} + +export const SpeedActivatedOverlay: React.FC = ({ + visible, + opacity, + speed +}) => { + if (!visible) return null; + + return ( + + + + {speed}x Speed + + + ); +}; diff --git a/src/components/player/ios/hooks/useCustomSubtitles.ts b/src/components/player/ios/hooks/useCustomSubtitles.ts new file mode 100644 index 0000000..a7ff132 --- /dev/null +++ b/src/components/player/ios/hooks/useCustomSubtitles.ts @@ -0,0 +1,58 @@ +import { useState } from 'react'; +import { + DEFAULT_SUBTITLE_SIZE, + SubtitleCue, + SubtitleSegment, + WyzieSubtitle +} from '../../utils/playerTypes'; + +export const useCustomSubtitles = () => { + // Data State + const [customSubtitles, setCustomSubtitles] = useState([]); + const [currentSubtitle, setCurrentSubtitle] = useState(''); + const [currentFormattedSegments, setCurrentFormattedSegments] = useState([]); + const [availableSubtitles, setAvailableSubtitles] = useState([]); + const [useCustomSubtitles, setUseCustomSubtitles] = useState(false); + + // Loading State + const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false); + const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false); + + // Styling State + const [subtitleSize, setSubtitleSize] = useState(DEFAULT_SUBTITLE_SIZE); + const [subtitleBackground, setSubtitleBackground] = useState(false); + const [subtitleTextColor, setSubtitleTextColor] = useState('#FFFFFF'); + const [subtitleBgOpacity, setSubtitleBgOpacity] = useState(0.7); + const [subtitleTextShadow, setSubtitleTextShadow] = useState(true); + const [subtitleOutline, setSubtitleOutline] = useState(true); + const [subtitleOutlineColor, setSubtitleOutlineColor] = useState('#000000'); + const [subtitleOutlineWidth, setSubtitleOutlineWidth] = useState(4); + const [subtitleAlign, setSubtitleAlign] = useState<'center' | 'left' | 'right'>('center'); + const [subtitleBottomOffset, setSubtitleBottomOffset] = useState(10); + const [subtitleLetterSpacing, setSubtitleLetterSpacing] = useState(0); + const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState(1.2); + const [subtitleOffsetSec, setSubtitleOffsetSec] = useState(0); + + return { + customSubtitles, setCustomSubtitles, + currentSubtitle, setCurrentSubtitle, + currentFormattedSegments, setCurrentFormattedSegments, + availableSubtitles, setAvailableSubtitles, + useCustomSubtitles, setUseCustomSubtitles, + isLoadingSubtitles, setIsLoadingSubtitles, + isLoadingSubtitleList, setIsLoadingSubtitleList, + subtitleSize, setSubtitleSize, + subtitleBackground, setSubtitleBackground, + subtitleTextColor, setSubtitleTextColor, + subtitleBgOpacity, setSubtitleBgOpacity, + subtitleTextShadow, setSubtitleTextShadow, + subtitleOutline, setSubtitleOutline, + subtitleOutlineColor, setSubtitleOutlineColor, + subtitleOutlineWidth, setSubtitleOutlineWidth, + subtitleAlign, setSubtitleAlign, + subtitleBottomOffset, setSubtitleBottomOffset, + subtitleLetterSpacing, setSubtitleLetterSpacing, + subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier, + subtitleOffsetSec, setSubtitleOffsetSec + }; +}; diff --git a/src/components/player/ios/hooks/useKSPlayer.ts b/src/components/player/ios/hooks/useKSPlayer.ts new file mode 100644 index 0000000..9432c06 --- /dev/null +++ b/src/components/player/ios/hooks/useKSPlayer.ts @@ -0,0 +1,15 @@ +import { useRef } from 'react'; +import { KSPlayerRef } from '../../KSPlayerComponent'; + +export const useKSPlayer = () => { + const ksPlayerRef = useRef(null); + + const seek = (time: number) => { + ksPlayerRef.current?.seek(time); + }; + + return { + ksPlayerRef, + seek + }; +}; diff --git a/src/components/player/ios/hooks/useNextEpisode.ts b/src/components/player/ios/hooks/useNextEpisode.ts new file mode 100644 index 0000000..63e2846 --- /dev/null +++ b/src/components/player/ios/hooks/useNextEpisode.ts @@ -0,0 +1,58 @@ +import { useMemo } from 'react'; + +export const useNextEpisode = ( + type: string | undefined, + season: number | undefined, + episode: number | undefined, + groupedEpisodes: any, + metadataGroupedEpisodes: any, + episodeId: string | undefined +) => { + // Current description + const currentEpisodeDescription = useMemo(() => { + try { + if (type !== 'series') return ''; + const allEpisodes = Object.values(groupedEpisodes || {}).flat() as any[]; + if (!allEpisodes || allEpisodes.length === 0) return ''; + + let match: any | null = null; + if (episodeId) { + match = allEpisodes.find(ep => ep?.stremioId === episodeId || String(ep?.id) === String(episodeId)); + } + if (!match && season && episode) { + match = allEpisodes.find(ep => ep?.season_number === season && ep?.episode_number === episode); + } + return (match?.overview || '').trim(); + } catch { + return ''; + } + }, [type, groupedEpisodes, episodeId, season, episode]); + + // Next Episode + const nextEpisode = useMemo(() => { + try { + if (type !== 'series' || !season || !episode) return null; + const sourceGroups = groupedEpisodes && Object.keys(groupedEpisodes || {}).length > 0 + ? groupedEpisodes + : (metadataGroupedEpisodes || {}); + + const allEpisodes = Object.values(sourceGroups || {}).flat() as any[]; + if (!allEpisodes || allEpisodes.length === 0) return null; + + let nextEp = allEpisodes.find((ep: any) => + ep.season_number === season && ep.episode_number === episode + 1 + ); + + if (!nextEp) { + nextEp = allEpisodes.find((ep: any) => + ep.season_number === season + 1 && ep.episode_number === 1 + ); + } + return nextEp; + } catch { + return null; + } + }, [type, season, episode, groupedEpisodes, metadataGroupedEpisodes]); + + return { currentEpisodeDescription, nextEpisode }; +}; diff --git a/src/components/player/ios/hooks/useOpeningAnimation.ts b/src/components/player/ios/hooks/useOpeningAnimation.ts new file mode 100644 index 0000000..8dd087e --- /dev/null +++ b/src/components/player/ios/hooks/useOpeningAnimation.ts @@ -0,0 +1,149 @@ +import { useRef, useState, useEffect } from 'react'; +import { Animated, InteractionManager } from 'react-native'; +import FastImage from '@d11/react-native-fast-image'; +import { logger } from '../../../../utils/logger'; + +export const useOpeningAnimation = (backdrop: string | undefined, metadata: any) => { + // Animation Values + const fadeAnim = useRef(new Animated.Value(1)).current; + const openingFadeAnim = useRef(new Animated.Value(0)).current; + const openingScaleAnim = useRef(new Animated.Value(0.8)).current; + const backgroundFadeAnim = useRef(new Animated.Value(1)).current; + const backdropImageOpacityAnim = useRef(new Animated.Value(0)).current; + const logoScaleAnim = useRef(new Animated.Value(0.8)).current; + const logoOpacityAnim = useRef(new Animated.Value(0)).current; + const pulseAnim = useRef(new Animated.Value(1)).current; + + const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false); + const [shouldHideOpeningOverlay, setShouldHideOpeningOverlay] = useState(false); + const [isBackdropLoaded, setIsBackdropLoaded] = useState(false); + + // Prefetch Background + useEffect(() => { + const task = InteractionManager.runAfterInteractions(() => { + if (backdrop && typeof backdrop === 'string') { + setIsBackdropLoaded(false); + backdropImageOpacityAnim.setValue(0); + try { + FastImage.preload([{ uri: backdrop }]); + setIsBackdropLoaded(true); + Animated.timing(backdropImageOpacityAnim, { + toValue: 1, + duration: 400, + useNativeDriver: true, + }).start(); + } catch (error) { + setIsBackdropLoaded(true); + backdropImageOpacityAnim.setValue(1); + } + } else { + setIsBackdropLoaded(true); + backdropImageOpacityAnim.setValue(0); + } + }); + return () => task.cancel(); + }, [backdrop]); + + // Prefetch Logo + useEffect(() => { + const task = InteractionManager.runAfterInteractions(() => { + const logoUrl = metadata?.logo; + if (logoUrl && typeof logoUrl === 'string') { + try { + FastImage.preload([{ uri: logoUrl }]); + } catch (error) { } + } + }); + return () => task.cancel(); + }, [metadata]); + + const startOpeningAnimation = () => { + Animated.parallel([ + Animated.timing(logoOpacityAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + Animated.spring(logoScaleAnim, { + toValue: 1, + tension: 80, + friction: 8, + useNativeDriver: true, + }), + ]).start(); + + const createPulseAnimation = () => { + return Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1.05, + duration: 800, + useNativeDriver: true, + }), + Animated.timing(pulseAnim, { + toValue: 1, + duration: 800, + useNativeDriver: true, + }), + ]); + }; + + const loopPulse = () => { + createPulseAnimation().start(() => { + if (!isOpeningAnimationComplete) { + loopPulse(); + } + }); + }; + loopPulse(); + }; + + const completeOpeningAnimation = () => { + pulseAnim.stopAnimation(); + + Animated.parallel([ + Animated.timing(openingFadeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(openingScaleAnim, { + toValue: 1, + duration: 350, + useNativeDriver: true, + }), + Animated.timing(backgroundFadeAnim, { + toValue: 0, + duration: 400, + useNativeDriver: true, + }), + ]).start(() => { + setIsOpeningAnimationComplete(true); + setTimeout(() => { + setShouldHideOpeningOverlay(true); + }, 450); + }); + + setTimeout(() => { + if (!isOpeningAnimationComplete) { + // logger.warn('[VideoPlayer] Opening animation fallback triggered'); + setIsOpeningAnimationComplete(true); + } + }, 1000); + }; + + return { + fadeAnim, + openingFadeAnim, + openingScaleAnim, + backgroundFadeAnim, + backdropImageOpacityAnim, + logoScaleAnim, + logoOpacityAnim, + pulseAnim, + isOpeningAnimationComplete, + shouldHideOpeningOverlay, + isBackdropLoaded, + startOpeningAnimation, + completeOpeningAnimation + }; +}; diff --git a/src/components/player/ios/hooks/usePlayerControls.ts b/src/components/player/ios/hooks/usePlayerControls.ts new file mode 100644 index 0000000..1b92ddb --- /dev/null +++ b/src/components/player/ios/hooks/usePlayerControls.ts @@ -0,0 +1,63 @@ +import { useRef, useCallback } from 'react'; +import { Platform } from 'react-native'; +import { logger } from '../../../../utils/logger'; + +const DEBUG_MODE = false; +const END_EPSILON = 0.3; + +export const usePlayerControls = ( + ksPlayerRef: any, + paused: boolean, + setPaused: (paused: boolean) => void, + currentTime: number, + duration: number, + isSeeking: React.MutableRefObject, + isMounted: React.MutableRefObject +) => { + // iOS seeking helpers + const iosWasPausedDuringSeekRef = useRef(null); + + const togglePlayback = useCallback(() => { + setPaused(!paused); + }, [paused, setPaused]); + + const seekToTime = useCallback((rawSeconds: number) => { + const timeInSeconds = Math.max(0, Math.min(rawSeconds, duration > 0 ? duration - END_EPSILON : rawSeconds)); + + if (ksPlayerRef.current && duration > 0 && !isSeeking.current) { + if (DEBUG_MODE) logger.log(`[usePlayerControls] Seeking to ${timeInSeconds}`); + + isSeeking.current = true; + + // iOS optimization: pause while seeking for smoother experience + iosWasPausedDuringSeekRef.current = paused; + if (!paused) setPaused(true); + + // Actually perform the seek + ksPlayerRef.current.seek(timeInSeconds); + + // Debounce the seeking state reset + setTimeout(() => { + if (isMounted.current && isSeeking.current) { + isSeeking.current = false; + // Resume if it was playing + if (iosWasPausedDuringSeekRef.current === false) { + setPaused(false); + iosWasPausedDuringSeekRef.current = null; + } + } + }, 500); + } + }, [duration, paused, setPaused, ksPlayerRef, isSeeking, isMounted]); + + const skip = useCallback((seconds: number) => { + seekToTime(currentTime + seconds); + }, [currentTime, seekToTime]); + + return { + togglePlayback, + seekToTime, + skip, + iosWasPausedDuringSeekRef + }; +}; diff --git a/src/components/player/ios/hooks/usePlayerModals.ts b/src/components/player/ios/hooks/usePlayerModals.ts new file mode 100644 index 0000000..2f98c01 --- /dev/null +++ b/src/components/player/ios/hooks/usePlayerModals.ts @@ -0,0 +1,34 @@ +import { useState } from 'react'; +import { Episode } from '../../../../types/metadata'; + +export const usePlayerModals = () => { + const [showAudioModal, setShowAudioModal] = useState(false); + const [showSubtitleModal, setShowSubtitleModal] = useState(false); + const [showSpeedModal, setShowSpeedModal] = useState(false); + const [showSourcesModal, setShowSourcesModal] = useState(false); + const [showEpisodesModal, setShowEpisodesModal] = useState(false); + const [showEpisodeStreamsModal, setShowEpisodeStreamsModal] = useState(false); + const [showErrorModal, setShowErrorModal] = useState(false); + const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false); + const [showCastDetails, setShowCastDetails] = useState(false); + + // Some modals have associated data + const [selectedEpisodeForStreams, setSelectedEpisodeForStreams] = useState(null); + const [errorDetails, setErrorDetails] = useState(''); + const [selectedCastMember, setSelectedCastMember] = useState(null); + + return { + showAudioModal, setShowAudioModal, + showSubtitleModal, setShowSubtitleModal, + showSpeedModal, setShowSpeedModal, + showSourcesModal, setShowSourcesModal, + showEpisodesModal, setShowEpisodesModal, + showEpisodeStreamsModal, setShowEpisodeStreamsModal, + showErrorModal, setShowErrorModal, + showSubtitleLanguageModal, setShowSubtitleLanguageModal, + showCastDetails, setShowCastDetails, + selectedEpisodeForStreams, setSelectedEpisodeForStreams, + errorDetails, setErrorDetails, + selectedCastMember, setSelectedCastMember + }; +}; diff --git a/src/components/player/ios/hooks/usePlayerSetup.ts b/src/components/player/ios/hooks/usePlayerSetup.ts new file mode 100644 index 0000000..b09a2aa --- /dev/null +++ b/src/components/player/ios/hooks/usePlayerSetup.ts @@ -0,0 +1,103 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { StatusBar, Dimensions, AppState, InteractionManager } from 'react-native'; +import * as Brightness from 'expo-brightness'; +import * as ScreenOrientation from 'expo-screen-orientation'; +import { logger } from '../../../../utils/logger'; +import { useFocusEffect } from '@react-navigation/native'; + +export const usePlayerSetup = ( + setScreenDimensions: (dim: any) => void, + setVolume: (vol: number) => void, + setBrightness: (bri: number) => void, + isOpeningAnimationComplete: boolean +) => { + const isAppBackgrounded = useRef(false); + + const enableImmersiveMode = () => { + StatusBar.setHidden(true, 'none'); + }; + + const disableImmersiveMode = () => { + StatusBar.setHidden(false, 'fade'); + }; + + useFocusEffect( + useCallback(() => { + if (isOpeningAnimationComplete) { + enableImmersiveMode(); + } + return () => { }; + }, [isOpeningAnimationComplete]) + ); + + useEffect(() => { + // Initial Setup + const subscription = Dimensions.addEventListener('change', ({ screen }) => { + setScreenDimensions(screen); + if (isOpeningAnimationComplete) { + enableImmersiveMode(); + } + }); + + StatusBar.setHidden(true, 'none'); + if (isOpeningAnimationComplete) { + enableImmersiveMode(); + } + + // Initialize volume (KSPlayer uses 0-100) + setVolume(100); + + // Initialize Brightness + const initBrightness = () => { + InteractionManager.runAfterInteractions(async () => { + try { + const currentBrightness = await Brightness.getBrightnessAsync(); + setBrightness(currentBrightness); + } catch (error) { + logger.warn('[usePlayerSetup] Error getting initial brightness:', error); + setBrightness(1.0); + } + }); + }; + initBrightness(); + + return () => { + subscription?.remove(); + disableImmersiveMode(); + }; + }, [isOpeningAnimationComplete]); + + // Handle Orientation (Lock to Landscape after opening) + useEffect(() => { + if (isOpeningAnimationComplete) { + const task = InteractionManager.runAfterInteractions(() => { + ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE) + .then(() => { + if (__DEV__) logger.log('[VideoPlayer] Locked to landscape orientation'); + }) + .catch((error) => { + logger.warn('[VideoPlayer] Failed to lock orientation:', error); + }); + }); + return () => task.cancel(); + } + }, [isOpeningAnimationComplete]); + + // Handle App State + useEffect(() => { + const onAppStateChange = (state: string) => { + if (state === 'active') { + isAppBackgrounded.current = false; + if (isOpeningAnimationComplete) { + enableImmersiveMode(); + } + } else if (state === 'background' || state === 'inactive') { + isAppBackgrounded.current = true; + } + }; + const sub = AppState.addEventListener('change', onAppStateChange); + return () => sub.remove(); + }, [isOpeningAnimationComplete]); + + return { isAppBackgrounded }; +}; diff --git a/src/components/player/ios/hooks/usePlayerState.ts b/src/components/player/ios/hooks/usePlayerState.ts new file mode 100644 index 0000000..2cd2ab8 --- /dev/null +++ b/src/components/player/ios/hooks/usePlayerState.ts @@ -0,0 +1,83 @@ +import { useState, useRef } from 'react'; +import { Dimensions, Platform } from 'react-native'; + +// Use a specific type for resizeMode that matches what KSPlayerComponent supports +type PlayerResizeMode = 'contain' | 'cover' | 'stretch'; + +export const usePlayerState = () => { + // Playback State + const [paused, setPaused] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [buffered, setBuffered] = useState(0); + const [isBuffering, setIsBuffering] = useState(false); + const [isVideoLoaded, setIsVideoLoaded] = useState(false); + const [isPlayerReady, setIsPlayerReady] = useState(false); + + // UI State + const [showControls, setShowControls] = useState(true); + const [resizeMode, setResizeMode] = useState('contain'); + const [videoAspectRatio, setVideoAspectRatio] = useState(null); + const [is16by9Content, setIs16by9Content] = useState(false); + const screenData = Dimensions.get('screen'); + const [screenDimensions, setScreenDimensions] = useState(screenData); + + // Zoom State + const [zoomScale, setZoomScale] = useState(1); + const [zoomTranslateX, setZoomTranslateX] = useState(0); + const [zoomTranslateY, setZoomTranslateY] = useState(0); + const [lastZoomScale, setLastZoomScale] = useState(1); + const [lastTranslateX, setLastTranslateX] = useState(0); + const [lastTranslateY, setLastTranslateY] = useState(0); + + // AirPlay State + const [isAirPlayActive, setIsAirPlayActive] = useState(false); + const [allowsAirPlay, setAllowsAirPlay] = useState(true); + + // Logic State + const isSeeking = useRef(false); + const isDragging = useRef(false); + const isMounted = useRef(true); + const seekDebounceTimer = useRef(null); + const pendingSeekValue = useRef(null); + const lastSeekTime = useRef(0); + const wasPlayingBeforeDragRef = useRef(false); + + // Helper for iPad/macOS fullscreen + const isIPad = Platform.OS === 'ios' && (screenData.width > 1000 || screenData.height > 1000); + const isMacOS = Platform.OS === 'ios' && Platform.isPad === true; + const shouldUseFullscreen = isIPad || isMacOS; + const windowData = Dimensions.get('window'); + const effectiveDimensions = shouldUseFullscreen ? windowData : screenDimensions; + + return { + paused, setPaused, + currentTime, setCurrentTime, + duration, setDuration, + buffered, setBuffered, + isBuffering, setIsBuffering, + isVideoLoaded, setIsVideoLoaded, + isPlayerReady, setIsPlayerReady, + showControls, setShowControls, + resizeMode, setResizeMode, + videoAspectRatio, setVideoAspectRatio, + is16by9Content, setIs16by9Content, + screenDimensions, setScreenDimensions, + zoomScale, setZoomScale, + zoomTranslateX, setZoomTranslateX, + zoomTranslateY, setZoomTranslateY, + lastZoomScale, setLastZoomScale, + lastTranslateX, setLastTranslateX, + lastTranslateY, setLastTranslateY, + isAirPlayActive, setIsAirPlayActive, + allowsAirPlay, setAllowsAirPlay, + isSeeking, + isDragging, + isMounted, + seekDebounceTimer, + pendingSeekValue, + lastSeekTime, + wasPlayingBeforeDragRef, + effectiveDimensions + }; +}; diff --git a/src/components/player/ios/hooks/usePlayerTracks.ts b/src/components/player/ios/hooks/usePlayerTracks.ts new file mode 100644 index 0000000..76f0d21 --- /dev/null +++ b/src/components/player/ios/hooks/usePlayerTracks.ts @@ -0,0 +1,38 @@ +import { useState, useMemo, useCallback } from 'react'; +import { AudioTrack, TextTrack } from '../../utils/playerTypes'; + +export const usePlayerTracks = () => { + const [audioTracks, setAudioTracks] = useState([]); + const [selectedAudioTrack, setSelectedAudioTrack] = useState(null); + const [textTracks, setTextTracks] = useState([]); + const [selectedTextTrack, setSelectedTextTrack] = useState(-1); + + const [ksAudioTracks, setKsAudioTracks] = useState>([]); + const [ksTextTracks, setKsTextTracks] = useState>([]); + + // Derived states or logic + const hasAudioTracks = audioTracks.length > 0; + const hasTextTracks = textTracks.length > 0; + + // Track selection functions + const selectAudioTrack = useCallback((trackId: number) => { + setSelectedAudioTrack(trackId); + }, []); + + const selectTextTrack = useCallback((trackId: number) => { + setSelectedTextTrack(trackId); + }, []); + + return { + audioTracks, setAudioTracks, + selectedAudioTrack, setSelectedAudioTrack, + textTracks, setTextTracks, + selectedTextTrack, setSelectedTextTrack, + ksAudioTracks, setKsAudioTracks, + ksTextTracks, setKsTextTracks, + hasAudioTracks, + hasTextTracks, + selectAudioTrack, + selectTextTrack + }; +}; diff --git a/src/components/player/ios/hooks/useSpeedControl.ts b/src/components/player/ios/hooks/useSpeedControl.ts new file mode 100644 index 0000000..d185f0e --- /dev/null +++ b/src/components/player/ios/hooks/useSpeedControl.ts @@ -0,0 +1,93 @@ +import { useState, useRef, useCallback, useEffect } from 'react'; +import { Animated } from 'react-native'; +import { mmkvStorage } from '../../../../services/mmkvStorage'; +import { logger } from '../../../../utils/logger'; + +const SPEED_SETTINGS_KEY = '@nuvio_speed_settings'; + +export const useSpeedControl = (initialSpeed: number = 1.0) => { + const [playbackSpeed, setPlaybackSpeed] = useState(initialSpeed); + const [holdToSpeedEnabled, setHoldToSpeedEnabled] = useState(true); + const [holdToSpeedValue, setHoldToSpeedValue] = useState(2.0); + const [isSpeedBoosted, setIsSpeedBoosted] = useState(false); + const [originalSpeed, setOriginalSpeed] = useState(initialSpeed); + const [showSpeedActivatedOverlay, setShowSpeedActivatedOverlay] = useState(false); + + const speedActivatedOverlayOpacity = useRef(new Animated.Value(0)).current; + + // Load Settings + useEffect(() => { + const loadSettings = async () => { + try { + const saved = await mmkvStorage.getItem(SPEED_SETTINGS_KEY); + if (saved) { + const settings = JSON.parse(saved); + if (typeof settings.holdToSpeedEnabled === 'boolean') setHoldToSpeedEnabled(settings.holdToSpeedEnabled); + if (typeof settings.holdToSpeedValue === 'number') setHoldToSpeedValue(settings.holdToSpeedValue); + } + } catch (e) { + logger.warn('[useSpeedControl] Error loading settings', e); + } + }; + loadSettings(); + }, []); + + // Save Settings + useEffect(() => { + const saveSettings = async () => { + try { + await mmkvStorage.setItem(SPEED_SETTINGS_KEY, JSON.stringify({ + holdToSpeedEnabled, + holdToSpeedValue + })); + } catch (e) { } + }; + saveSettings(); + }, [holdToSpeedEnabled, holdToSpeedValue]); + + const activateSpeedBoost = useCallback(() => { + if (!holdToSpeedEnabled || isSpeedBoosted || playbackSpeed === holdToSpeedValue) return; + + setOriginalSpeed(playbackSpeed); + setPlaybackSpeed(holdToSpeedValue); + setIsSpeedBoosted(true); + setShowSpeedActivatedOverlay(true); + + Animated.timing(speedActivatedOverlayOpacity, { + toValue: 1, + duration: 200, + useNativeDriver: true + }).start(); + + setTimeout(() => { + Animated.timing(speedActivatedOverlayOpacity, { + toValue: 0, + duration: 300, + useNativeDriver: true + }).start(() => setShowSpeedActivatedOverlay(false)); + }, 2000); + + }, [holdToSpeedEnabled, isSpeedBoosted, playbackSpeed, holdToSpeedValue]); + + const deactivateSpeedBoost = useCallback(() => { + if (isSpeedBoosted) { + setPlaybackSpeed(originalSpeed); + setIsSpeedBoosted(false); + Animated.timing(speedActivatedOverlayOpacity, { toValue: 0, duration: 100, useNativeDriver: true }).start(); + } + }, [isSpeedBoosted, originalSpeed]); + + return { + playbackSpeed, + setPlaybackSpeed, + holdToSpeedEnabled, + setHoldToSpeedEnabled, + holdToSpeedValue, + setHoldToSpeedValue, + isSpeedBoosted, + activateSpeedBoost, + deactivateSpeedBoost, + showSpeedActivatedOverlay, + speedActivatedOverlayOpacity + }; +}; diff --git a/src/components/player/ios/hooks/useWatchProgress.ts b/src/components/player/ios/hooks/useWatchProgress.ts new file mode 100644 index 0000000..fa61406 --- /dev/null +++ b/src/components/player/ios/hooks/useWatchProgress.ts @@ -0,0 +1,120 @@ +import { useState, useEffect, useRef } from 'react'; +import { storageService } from '../../../../services/storageService'; +import { logger } from '../../../../utils/logger'; +import { useSettings } from '../../../../hooks/useSettings'; + +export const useWatchProgress = ( + id: string | undefined, + type: string | undefined, + episodeId: string | undefined, + currentTime: number, + duration: number, + paused: boolean, + traktAutosync: any, + seekToTime: (time: number) => void +) => { + const [resumePosition, setResumePosition] = useState(null); + const [savedDuration, setSavedDuration] = useState(null); + const [initialPosition, setInitialPosition] = useState(null); + const [showResumeOverlay, setShowResumeOverlay] = useState(false); + const [progressSaveInterval, setProgressSaveInterval] = useState(null); + + const { settings: appSettings } = useSettings(); + const initialSeekTargetRef = useRef(null); + + // Values refs for unmount cleanup + const currentTimeRef = useRef(currentTime); + const durationRef = useRef(duration); + + useEffect(() => { + currentTimeRef.current = currentTime; + }, [currentTime]); + + useEffect(() => { + durationRef.current = duration; + }, [duration]); + + // Load Watch Progress + useEffect(() => { + const loadWatchProgress = async () => { + if (id && type) { + try { + const savedProgress = await storageService.getWatchProgress(id, type, episodeId); + if (savedProgress) { + const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; + + if (progressPercent < 85) { + setResumePosition(savedProgress.currentTime); + setSavedDuration(savedProgress.duration); + + if (appSettings.alwaysResume) { + setInitialPosition(savedProgress.currentTime); + initialSeekTargetRef.current = savedProgress.currentTime; + seekToTime(savedProgress.currentTime); + } else { + setShowResumeOverlay(true); + } + } + } + } catch (error) { + logger.error('[useWatchProgress] Error loading watch progress:', error); + } + } + }; + loadWatchProgress(); + }, [id, type, episodeId, appSettings.alwaysResume]); + + const saveWatchProgress = async () => { + if (id && type && currentTimeRef.current > 0 && durationRef.current > 0) { + const progress = { + currentTime: currentTimeRef.current, + duration: durationRef.current, + lastUpdated: Date.now() + }; + try { + await storageService.setWatchProgress(id, type, progress, episodeId); + await traktAutosync.handleProgressUpdate(currentTimeRef.current, durationRef.current); + } catch (error) { + logger.error('[useWatchProgress] Error saving watch progress:', error); + } + } + }; + + // Save Interval + useEffect(() => { + if (id && type && !paused && duration > 0) { + if (progressSaveInterval) clearInterval(progressSaveInterval); + + const interval = setInterval(() => { + saveWatchProgress(); + }, 10000); + + setProgressSaveInterval(interval); + return () => { + clearInterval(interval); + setProgressSaveInterval(null); + }; + } + }, [id, type, paused, currentTime, duration]); + + // Unmount Save + useEffect(() => { + return () => { + if (id && type && durationRef.current > 0) { + saveWatchProgress(); + traktAutosync.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'unmount'); + } + }; + }, [id, type]); + + return { + resumePosition, + savedDuration, + initialPosition, + setInitialPosition, + showResumeOverlay, + setShowResumeOverlay, + saveWatchProgress, + initialSeekTargetRef + }; +}; diff --git a/src/components/player/modals/AudioTrackModal.tsx b/src/components/player/modals/AudioTrackModal.tsx index cd37b69..eb30433 100644 --- a/src/components/player/modals/AudioTrackModal.tsx +++ b/src/components/player/modals/AudioTrackModal.tsx @@ -110,3 +110,5 @@ export const AudioTrackModal: React.FC = ({ ); }; + +export default AudioTrackModal; diff --git a/src/components/player/modals/EpisodeStreamsModal.tsx b/src/components/player/modals/EpisodeStreamsModal.tsx index e41722b..747f611 100644 --- a/src/components/player/modals/EpisodeStreamsModal.tsx +++ b/src/components/player/modals/EpisodeStreamsModal.tsx @@ -374,3 +374,5 @@ export const EpisodeStreamsModal: React.FC = ({ ); }; + +export default EpisodeStreamsModal; diff --git a/src/components/player/modals/EpisodesModal.tsx b/src/components/player/modals/EpisodesModal.tsx index 1f6a5f3..2fd8b58 100644 --- a/src/components/player/modals/EpisodesModal.tsx +++ b/src/components/player/modals/EpisodesModal.tsx @@ -170,3 +170,5 @@ export const EpisodesModal: React.FC = ({ ); }; + +export default EpisodesModal; diff --git a/src/components/player/modals/SourcesModal.tsx b/src/components/player/modals/SourcesModal.tsx index 6bfac5d..d40896d 100644 --- a/src/components/player/modals/SourcesModal.tsx +++ b/src/components/player/modals/SourcesModal.tsx @@ -276,4 +276,6 @@ export const SourcesModal: React.FC = ({ ); -}; \ No newline at end of file +}; + +export default SourcesModal; \ No newline at end of file diff --git a/src/components/player/utils/playerStyles.ts b/src/components/player/utils/playerStyles.ts index bd429ef..a470e67 100644 --- a/src/components/player/utils/playerStyles.ts +++ b/src/components/player/utils/playerStyles.ts @@ -260,32 +260,37 @@ export const styles = StyleSheet.create({ }, gestureIndicatorContainer: { position: 'absolute', - top: '40%', - left: '50%', - transform: [{ translateX: -75 }, { translateY: -40 }], - width: 150, - height: 80, - backgroundColor: 'rgba(0, 0, 0, 0.8)', - borderRadius: 16, - flexDirection: 'row', + top: 40, + alignSelf: 'center', + left: 0, + right: 0, alignItems: 'center', - paddingHorizontal: 16, justifyContent: 'center', zIndex: 2000, }, + gestureIndicatorPill: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.75)', + borderRadius: 24, + paddingVertical: 8, + paddingHorizontal: 14, + gap: 8, + }, iconWrapper: { - width: 40, - height: 40, - borderRadius: 20, + width: 28, + height: 28, + borderRadius: 14, alignItems: 'center', justifyContent: 'center', - marginRight: 10, - backgroundColor: 'rgba(255, 255, 255, 0.1)', + backgroundColor: 'rgba(255, 255, 255, 0.15)', }, gestureText: { color: '#FFFFFF', - fontSize: 24, + fontSize: 16, fontWeight: '600', + minWidth: 48, + textAlign: 'center', }, bottomControls: { diff --git a/src/components/player/utils/playerTypes.ts b/src/components/player/utils/playerTypes.ts index bbfb577..9413c59 100644 --- a/src/components/player/utils/playerTypes.ts +++ b/src/components/player/utils/playerTypes.ts @@ -61,9 +61,9 @@ export interface TextTrack { type?: string | null; // Adjusting type based on linter error } -// Define the possible resize modes - force to stretch for absolute full screen -export type ResizeModeType = 'contain' | 'cover' | 'none'; -export const resizeModes: ResizeModeType[] = ['cover']; // Force cover mode for absolute full screen +// Define the possible resize modes +export type ResizeModeType = 'contain' | 'cover' | 'stretch' | 'none'; +export const resizeModes: ResizeModeType[] = ['cover', 'contain', 'stretch']; // Add VLC specific interface for their event structure export interface VlcMediaEvent { From 967b90b98e866b2571cc3ddd7bab5888a2e73167 Mon Sep 17 00:00:00 2001 From: tapframe Date: Mon, 22 Dec 2025 16:06:52 +0530 Subject: [PATCH 03/18] Error modal simplified for videoplayers --- src/components/player/AndroidVideoPlayer.tsx | 10 ++ src/components/player/KSPlayerCore.tsx | 28 ++++- src/components/player/modals/ErrorModal.tsx | 112 +++++++++++++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/components/player/modals/ErrorModal.tsx diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 36b6c3d..c2142be 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -35,6 +35,7 @@ import SpeedModal from './modals/SpeedModal'; import { SourcesModal } from './modals/SourcesModal'; import { EpisodesModal } from './modals/EpisodesModal'; import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal'; +import { ErrorModal } from './modals/ErrorModal'; // Android-specific components import { VideoSurface } from './android/components/VideoSurface'; @@ -543,6 +544,15 @@ const AndroidVideoPlayer: React.FC = () => { }} /> + + + + modals.setShowEpisodeStreamsModal(false)} diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 399589b..0b21996 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -14,6 +14,7 @@ import SubtitleModals from './modals/SubtitleModals'; import SourcesModal from './modals/SourcesModal'; import EpisodesModal from './modals/EpisodesModal'; import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal'; +import { ErrorModal } from './modals/ErrorModal'; import CustomSubtitles from './subtitles/CustomSubtitles'; import { SpeedActivatedOverlay, PauseOverlay, GestureControls } from './components'; @@ -325,7 +326,25 @@ const KSPlayerCore: React.FC = () => { }; const handleError = (error: any) => { - modals.setErrorDetails(typeof error === 'string' ? error : error?.message || 'Unknown Error'); + let msg = 'Unknown Error'; + try { + if (typeof error === 'string') { + msg = error; + } else if (error?.error?.localizedDescription) { + msg = error.error.localizedDescription; + } else if (error?.error?.message) { + msg = error.error.message; + } else if (error?.message) { + msg = error.message; + } else if (error?.error) { + msg = typeof error.error === 'string' ? error.error : JSON.stringify(error.error); + } else { + msg = JSON.stringify(error); + } + } catch (e) { + msg = 'Error parsing error details'; + } + modals.setErrorDetails(msg); modals.setShowErrorModal(true); }; @@ -605,6 +624,13 @@ const KSPlayerCore: React.FC = () => { selectAudioTrack={tracks.selectAudioTrack} /> + + void; + errorDetails: string; + onDismiss?: () => void; +} + +export const ErrorModal: React.FC = ({ + showErrorModal, + setShowErrorModal, + errorDetails, + onDismiss, +}) => { + const { width } = useWindowDimensions(); + const MODAL_WIDTH = Math.min(width * 0.8, 400); + + const handleClose = () => { + setShowErrorModal(false); + if (onDismiss) { + onDismiss(); + } + }; + + if (!showErrorModal) return null; + + return ( + + + + + + + + + + + + Playback Error + + + + {errorDetails || 'An unknown error occurred during playback.'} + + + + + Dismiss + + + + + ); +}; + +export default ErrorModal; From 19438ff1d5e2eecc3d7002c2ad784be55596f5aa Mon Sep 17 00:00:00 2001 From: tapframe Date: Mon, 22 Dec 2025 21:26:22 +0530 Subject: [PATCH 04/18] mpv init --- android/app/build.gradle | 3 + android/app/src/main/AndroidManifest.xml | 4 +- .../java/com/nuvio/app/MainApplication.kt | 2 +- .../main/java/com/nuvio/app/mpv/MPVView.kt | 249 +++++++++++++++++ .../main/java/com/nuvio/app/mpv/MpvPackage.kt | 16 ++ .../com/nuvio/app/mpv/MpvPlayerViewManager.kt | 128 +++++++++ package-lock.json | 29 ++ package.json | 2 + src/components/player/AndroidVideoPlayer.tsx | 30 +- src/components/player/android/MpvPlayer.tsx | 117 ++++++++ .../android/components/VideoSurface.tsx | 262 ++++++++---------- .../player/android/hooks/usePlayerSetup.ts | 20 +- src/components/player/modals/ErrorModal.tsx | 48 +++- 13 files changed, 746 insertions(+), 164 deletions(-) create mode 100644 android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt create mode 100644 android/app/src/main/java/com/nuvio/app/mpv/MpvPackage.kt create mode 100644 android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt create mode 100644 src/components/player/android/MpvPlayer.tsx diff --git a/android/app/build.gradle b/android/app/build.gradle index d8870d1..f852b41 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -246,6 +246,9 @@ dependencies { // Include only FFmpeg decoder AAR to avoid duplicates with Maven Media3 implementation files("libs/lib-decoder-ffmpeg-release.aar") + + // MPV Player library + implementation files("libs/libmpv-release.aar") // Google Cast Framework implementation "com.google.android.gms:play-services-cast-framework:${safeExtGet('castFrameworkVersion', '+')}" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 63ac1b6..e241f30 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ - + + diff --git a/android/app/src/main/java/com/nuvio/app/MainApplication.kt b/android/app/src/main/java/com/nuvio/app/MainApplication.kt index 2a6f8de..9e0bf7b 100644 --- a/android/app/src/main/java/com/nuvio/app/MainApplication.kt +++ b/android/app/src/main/java/com/nuvio/app/MainApplication.kt @@ -24,7 +24,7 @@ class MainApplication : Application(), ReactApplication { override fun getPackages(): List = PackageList(this).packages.apply { // Packages that cannot be autolinked yet can be added manually here, for example: - // add(MyReactNativePackage()) + add(com.nuvio.app.mpv.MpvPackage()) } override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt new file mode 100644 index 0000000..e97fb31 --- /dev/null +++ b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt @@ -0,0 +1,249 @@ +package com.nuvio.app.mpv + +import android.content.Context +import android.graphics.SurfaceTexture +import android.util.AttributeSet +import android.util.Log +import android.view.Surface +import android.view.TextureView +import dev.jdtech.mpv.MPVLib + +class MPVView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : TextureView(context, attrs, defStyleAttr), TextureView.SurfaceTextureListener, MPVLib.EventObserver { + + companion object { + private const val TAG = "MPVView" + } + + private var isMpvInitialized = false + private var pendingDataSource: String? = null + private var isPaused: Boolean = true + private var surface: Surface? = null + + // Event listener for React Native + var onLoadCallback: ((duration: Double, width: Int, height: Int) -> Unit)? = null + var onProgressCallback: ((position: Double, duration: Double) -> Unit)? = null + var onEndCallback: (() -> Unit)? = null + var onErrorCallback: ((message: String) -> Unit)? = null + + init { + surfaceTextureListener = this + isOpaque = false + } + + override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) { + Log.d(TAG, "Surface texture available: ${width}x${height}") + try { + surface = Surface(surfaceTexture) + + MPVLib.create(context.applicationContext) + initOptions() + MPVLib.init() + MPVLib.attachSurface(surface!!) + MPVLib.addObserver(this) + MPVLib.setPropertyString("android-surface-size", "${width}x${height}") + observeProperties() + isMpvInitialized = true + + // If a data source was set before surface was ready, load it now + pendingDataSource?.let { url -> + loadFile(url) + pendingDataSource = null + } + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize MPV", e) + onErrorCallback?.invoke("MPV initialization failed: ${e.message}") + } + } + + override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) { + Log.d(TAG, "Surface texture size changed: ${width}x${height}") + if (isMpvInitialized) { + MPVLib.setPropertyString("android-surface-size", "${width}x${height}") + } + } + + override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { + Log.d(TAG, "Surface texture destroyed") + if (isMpvInitialized) { + MPVLib.removeObserver(this) + MPVLib.detachSurface() + MPVLib.destroy() + isMpvInitialized = false + } + surface?.release() + surface = null + return true + } + + override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) { + // Called when the SurfaceTexture is updated via updateTexImage() + } + + private fun initOptions() { + // Mobile-optimized profile + MPVLib.setOptionString("profile", "fast") + MPVLib.setOptionString("vo", "gpu") + MPVLib.setOptionString("gpu-context", "android") + MPVLib.setOptionString("opengl-es", "yes") + MPVLib.setOptionString("hwdec", "mediacodec,mediacodec-copy") + MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1") + MPVLib.setOptionString("ao", "audiotrack,opensles") + + // Network caching for streaming + MPVLib.setOptionString("demuxer-max-bytes", "67108864") // 64MB + MPVLib.setOptionString("demuxer-max-back-bytes", "33554432") // 32MB + MPVLib.setOptionString("cache", "yes") + MPVLib.setOptionString("cache-secs", "30") + + // Disable terminal/input + MPVLib.setOptionString("terminal", "no") + MPVLib.setOptionString("input-default-bindings", "no") + } + + private fun observeProperties() { + // MPV format constants (from MPVLib source) + val MPV_FORMAT_NONE = 0 + val MPV_FORMAT_FLAG = 3 + val MPV_FORMAT_INT64 = 4 + val MPV_FORMAT_DOUBLE = 5 + + MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE) + MPVLib.observeProperty("duration", MPV_FORMAT_DOUBLE) + MPVLib.observeProperty("pause", MPV_FORMAT_FLAG) + MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG) + MPVLib.observeProperty("eof-reached", MPV_FORMAT_FLAG) + MPVLib.observeProperty("video-params/aspect", MPV_FORMAT_DOUBLE) + MPVLib.observeProperty("width", MPV_FORMAT_INT64) + MPVLib.observeProperty("height", MPV_FORMAT_INT64) + MPVLib.observeProperty("track-list", MPV_FORMAT_NONE) + } + + private fun loadFile(url: String) { + Log.d(TAG, "Loading file: $url") + MPVLib.command(arrayOf("loadfile", url)) + } + + // Public API + + fun setDataSource(url: String) { + if (isMpvInitialized) { + loadFile(url) + } else { + pendingDataSource = url + } + } + + fun setPaused(paused: Boolean) { + isPaused = paused + if (isMpvInitialized) { + MPVLib.setPropertyBoolean("pause", paused) + } + } + + fun seekTo(positionSeconds: Double) { + if (isMpvInitialized) { + MPVLib.command(arrayOf("seek", positionSeconds.toString(), "absolute")) + } + } + + fun setSpeed(speed: Double) { + if (isMpvInitialized) { + MPVLib.setPropertyDouble("speed", speed) + } + } + + fun setVolume(volume: Double) { + if (isMpvInitialized) { + // MPV volume is 0-100 + MPVLib.setPropertyDouble("volume", volume * 100.0) + } + } + + fun setAudioTrack(trackId: Int) { + if (isMpvInitialized) { + if (trackId == -1) { + MPVLib.setPropertyString("aid", "no") + } else { + MPVLib.setPropertyInt("aid", trackId) + } + } + } + + fun setSubtitleTrack(trackId: Int) { + if (isMpvInitialized) { + if (trackId == -1) { + MPVLib.setPropertyString("sid", "no") + } else { + MPVLib.setPropertyInt("sid", trackId) + } + } + } + + // MPVLib.EventObserver implementation + + override fun eventProperty(property: String) { + Log.d(TAG, "Property changed: $property") + when (property) { + "track-list" -> { + // Track list updated, could notify JS about available tracks + } + } + } + + override fun eventProperty(property: String, value: Long) { + Log.d(TAG, "Property $property = $value (Long)") + } + + override fun eventProperty(property: String, value: Double) { + Log.d(TAG, "Property $property = $value (Double)") + when (property) { + "time-pos" -> { + val duration = MPVLib.getPropertyDouble("duration") ?: 0.0 + onProgressCallback?.invoke(value, duration) + } + "duration" -> { + val width = MPVLib.getPropertyInt("width") ?: 0 + val height = MPVLib.getPropertyInt("height") ?: 0 + onLoadCallback?.invoke(value, width, height) + } + } + } + + override fun eventProperty(property: String, value: Boolean) { + Log.d(TAG, "Property $property = $value (Boolean)") + when (property) { + "eof-reached" -> { + if (value) { + onEndCallback?.invoke() + } + } + } + } + + override fun eventProperty(property: String, value: String) { + Log.d(TAG, "Property $property = $value (String)") + } + + override fun event(eventId: Int) { + Log.d(TAG, "Event: $eventId") + // MPV event constants (from MPVLib source) + val MPV_EVENT_FILE_LOADED = 8 + val MPV_EVENT_END_FILE = 7 + + when (eventId) { + MPV_EVENT_FILE_LOADED -> { + // File is loaded, start playback if not paused + if (!isPaused) { + MPVLib.setPropertyBoolean("pause", false) + } + } + MPV_EVENT_END_FILE -> { + onEndCallback?.invoke() + } + } + } +} diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MpvPackage.kt b/android/app/src/main/java/com/nuvio/app/mpv/MpvPackage.kt new file mode 100644 index 0000000..49c3dd2 --- /dev/null +++ b/android/app/src/main/java/com/nuvio/app/mpv/MpvPackage.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.mpv + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class MpvPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return emptyList() + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return listOf(MpvPlayerViewManager(reactContext)) + } +} diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt b/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt new file mode 100644 index 0000000..822e529 --- /dev/null +++ b/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt @@ -0,0 +1,128 @@ +package com.nuvio.app.mpv + +import android.graphics.Color +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.common.MapBuilder +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.uimanager.events.RCTEventEmitter + +class MpvPlayerViewManager( + private val reactContext: ReactApplicationContext +) : SimpleViewManager() { + + companion object { + const val REACT_CLASS = "MpvPlayer" + + // Commands + const val COMMAND_SEEK = 1 + const val COMMAND_SET_AUDIO_TRACK = 2 + const val COMMAND_SET_SUBTITLE_TRACK = 3 + } + + override fun getName(): String = REACT_CLASS + + override fun createViewInstance(context: ThemedReactContext): MPVView { + val view = MPVView(context) + // Note: Do NOT set background color - it will block the SurfaceView content + + // Set up event callbacks + view.onLoadCallback = { duration, width, height -> + val event = Arguments.createMap().apply { + putDouble("duration", duration) + putInt("width", width) + putInt("height", height) + } + sendEvent(context, view.id, "onLoad", event) + } + + view.onProgressCallback = { position, duration -> + val event = Arguments.createMap().apply { + putDouble("currentTime", position) + putDouble("duration", duration) + } + sendEvent(context, view.id, "onProgress", event) + } + + view.onEndCallback = { + sendEvent(context, view.id, "onEnd", Arguments.createMap()) + } + + view.onErrorCallback = { message -> + val event = Arguments.createMap().apply { + putString("error", message) + } + sendEvent(context, view.id, "onError", event) + } + + return view + } + + private fun sendEvent(context: ThemedReactContext, viewId: Int, eventName: String, params: com.facebook.react.bridge.WritableMap) { + context.getJSModule(RCTEventEmitter::class.java) + .receiveEvent(viewId, eventName, params) + } + + override fun getExportedCustomBubblingEventTypeConstants(): Map { + return MapBuilder.builder() + .put("onLoad", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onLoad"))) + .put("onProgress", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onProgress"))) + .put("onEnd", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onEnd"))) + .put("onError", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onError"))) + .build() + } + + override fun getCommandsMap(): Map { + return MapBuilder.of( + "seek", COMMAND_SEEK, + "setAudioTrack", COMMAND_SET_AUDIO_TRACK, + "setSubtitleTrack", COMMAND_SET_SUBTITLE_TRACK + ) + } + + override fun receiveCommand(view: MPVView, commandId: String?, args: ReadableArray?) { + when (commandId) { + "seek" -> { + args?.getDouble(0)?.let { view.seekTo(it) } + } + "setAudioTrack" -> { + args?.getInt(0)?.let { view.setAudioTrack(it) } + } + "setSubtitleTrack" -> { + args?.getInt(0)?.let { view.setSubtitleTrack(it) } + } + } + } + + // React Props + + @ReactProp(name = "source") + fun setSource(view: MPVView, source: String?) { + source?.let { view.setDataSource(it) } + } + + @ReactProp(name = "paused") + fun setPaused(view: MPVView, paused: Boolean) { + view.setPaused(paused) + } + + @ReactProp(name = "volume", defaultFloat = 1.0f) + fun setVolume(view: MPVView, volume: Float) { + view.setVolume(volume.toDouble()) + } + + @ReactProp(name = "rate", defaultFloat = 1.0f) + fun setRate(view: MPVView, rate: Float) { + view.setSpeed(rate.toDouble()) + } + + // Handle backgroundColor prop to prevent crash from React Native style system + @ReactProp(name = "backgroundColor", customType = "Color") + fun setBackgroundColor(view: MPVView, color: Int?) { + // Intentionally ignoring - background color would block the TextureView content + // Leave the view transparent + } +} diff --git a/package-lock.json b/package-lock.json index 9f09ce7..b4c0c06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "expo-auth-session": "~7.0.8", "expo-blur": "~15.0.7", "expo-brightness": "~14.0.7", + "expo-clipboard": "~8.0.8", "expo-crypto": "~15.0.7", "expo-dev-client": "~6.0.15", "expo-device": "~8.0.9", @@ -53,6 +54,7 @@ "expo-libvlc-player": "^2.2.3", "expo-linear-gradient": "~15.0.7", "expo-localization": "~17.0.7", + "expo-navigation-bar": "~5.0.10", "expo-notifications": "~0.32.12", "expo-random": "^14.0.1", "expo-screen-orientation": "~9.0.7", @@ -6326,6 +6328,17 @@ "react-native": "*" } }, + "node_modules/expo-clipboard": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz", + "integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "18.0.12", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.12.tgz", @@ -6590,6 +6603,22 @@ "react-native": "*" } }, + "node_modules/expo-navigation-bar": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/expo-navigation-bar/-/expo-navigation-bar-5.0.10.tgz", + "integrity": "sha512-r9rdLw8mY6GPMQmVVOY/r1NBBw74DZefXHF60HxhRsdNI2kjc1wLdfWfR2rk4JVdOvdMDujnGrc9HQmqM3n8Jg==", + "license": "MIT", + "dependencies": { + "@react-native/normalize-colors": "0.81.5", + "debug": "^4.3.2", + "react-native-is-edge-to-edge": "^1.2.1" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-notifications": { "version": "0.32.15", "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.15.tgz", diff --git a/package.json b/package.json index e74ba9b..e4f9750 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "expo-auth-session": "~7.0.8", "expo-blur": "~15.0.7", "expo-brightness": "~14.0.7", + "expo-clipboard": "~8.0.8", "expo-crypto": "~15.0.7", "expo-dev-client": "~6.0.15", "expo-device": "~8.0.9", @@ -53,6 +54,7 @@ "expo-libvlc-player": "^2.2.3", "expo-linear-gradient": "~15.0.7", "expo-localization": "~17.0.7", + "expo-navigation-bar": "~5.0.10", "expo-notifications": "~0.32.12", "expo-random": "^14.0.1", "expo-screen-orientation": "~9.0.7", diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index c2142be..72a19b2 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -1,5 +1,6 @@ import React, { useRef, useEffect, useMemo, useCallback, useState } from 'react'; import { View, StyleSheet, Platform, Animated } from 'react-native'; +import { toast } from '@backpackapp-io/react-native-toast'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { RootStackParamList } from '../../navigation/AppNavigator'; @@ -349,8 +350,35 @@ const AndroidVideoPlayer: React.FC = () => { if (modals.showEpisodeStreamsModal) return; playerState.setPaused(true); }} - onError={(err) => { + onError={(err: any) => { logger.error('Video Error', err); + + // Check for decoding errors to switch to VLC + const errorString = err?.errorString || err?.error?.errorString; + const errorCode = err?.errorCode || err?.error?.errorCode; + const causeMessage = err?.error?.cause?.message; + + const isDecodingError = + (errorString && errorString.includes('ERROR_CODE_DECODING_FAILED')) || + errorCode === '24003' || + (causeMessage && causeMessage.includes('MediaCodecVideoRenderer error')); + + if (!useVLC && isDecodingError) { + const toastId = toast.loading('Decoding error. Switching to VLC Player...'); + setTimeout(() => toast.dismiss(toastId), 3000); + + // We can just show a normal toast or use the existing modal system if we want, + // but checking the file imports, I don't see Toast imported. + // Let's implement the navigation replace. + + // Using a simple navigation replace to force VLC + (navigation as any).replace('PlayerAndroid', { + ...route.params, + forceVlc: true + }); + return; + } + modals.setErrorDetails(JSON.stringify(err)); modals.setShowErrorModal(true); }} diff --git a/src/components/player/android/MpvPlayer.tsx b/src/components/player/android/MpvPlayer.tsx new file mode 100644 index 0000000..df1868f --- /dev/null +++ b/src/components/player/android/MpvPlayer.tsx @@ -0,0 +1,117 @@ +import React, { useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react'; +import { View, StyleSheet, requireNativeComponent, Platform, UIManager, findNodeHandle } from 'react-native'; + +// Only available on Android +const MpvPlayerNative = Platform.OS === 'android' + ? requireNativeComponent('MpvPlayer') + : null; + +export interface MpvPlayerRef { + seek: (positionSeconds: number) => void; + setAudioTrack: (trackId: number) => void; + setSubtitleTrack: (trackId: number) => void; +} + +export interface MpvPlayerProps { + source: string; + paused?: boolean; + volume?: number; + rate?: number; + style?: any; + onLoad?: (data: { duration: number; width: number; height: number }) => void; + onProgress?: (data: { currentTime: number; duration: number }) => void; + onEnd?: () => void; + onError?: (error: { error: string }) => void; +} + +const MpvPlayer = forwardRef((props, ref) => { + const nativeRef = useRef(null); + + const dispatchCommand = useCallback((commandName: string, args: any[] = []) => { + if (nativeRef.current && Platform.OS === 'android') { + const handle = findNodeHandle(nativeRef.current); + if (handle) { + UIManager.dispatchViewManagerCommand( + handle, + commandName, + args + ); + } + } + }, []); + + useImperativeHandle(ref, () => ({ + seek: (positionSeconds: number) => { + dispatchCommand('seek', [positionSeconds]); + }, + setAudioTrack: (trackId: number) => { + dispatchCommand('setAudioTrack', [trackId]); + }, + setSubtitleTrack: (trackId: number) => { + dispatchCommand('setSubtitleTrack', [trackId]); + }, + }), [dispatchCommand]); + + if (Platform.OS !== 'android' || !MpvPlayerNative) { + // Fallback for iOS or if native component is not available + return ( + + ); + } + + console.log('[MpvPlayer] Rendering native component with:', { + source: props.source?.substring(0, 50) + '...', + paused: props.paused ?? true, + volume: props.volume ?? 1.0, + rate: props.rate ?? 1.0, + }); + + const handleLoad = (event: any) => { + console.log('[MpvPlayer] Native onLoad event:', event?.nativeEvent); + props.onLoad?.(event?.nativeEvent); + }; + + const handleProgress = (event: any) => { + const data = event?.nativeEvent; + if (data && Math.floor(data.currentTime) % 5 === 0) { + console.log('[MpvPlayer] Native onProgress event:', data); + } + props.onProgress?.(data); + }; + + const handleEnd = (event: any) => { + console.log('[MpvPlayer] Native onEnd event'); + props.onEnd?.(); + }; + + const handleError = (event: any) => { + console.log('[MpvPlayer] Native onError event:', event?.nativeEvent); + props.onError?.(event?.nativeEvent); + }; + + return ( + + ); +}); + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: 'black', + }, +}); + +MpvPlayer.displayName = 'MpvPlayer'; + +export default MpvPlayer; diff --git a/src/components/player/android/components/VideoSurface.tsx b/src/components/player/android/components/VideoSurface.tsx index 41273e4..8b98dcb 100644 --- a/src/components/player/android/components/VideoSurface.tsx +++ b/src/components/player/android/components/VideoSurface.tsx @@ -1,52 +1,18 @@ -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 React from 'react'; +import { View, TouchableWithoutFeedback, StyleSheet } from 'react-native'; import { PinchGestureHandler } from 'react-native-gesture-handler'; +import MpvPlayer, { MpvPlayerRef } from '../MpvPlayer'; import { styles } from '../../utils/playerStyles'; +import { ResizeModeType } from '../../utils/playerTypes'; 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 'stretch': return 'contain'; - case 'none': return 'contain'; - default: return 'contain'; - } -}; - -// VLC only supports 'contain' | 'cover' | 'none' -const getVlcResizeMode = (resizeMode: ResizeModeType): 'contain' | 'cover' | 'none' => { - switch (resizeMode) { - case 'contain': return 'contain'; - case 'cover': return 'cover'; - case 'stretch': return 'cover'; // stretch is not supported, use cover - case 'none': return 'none'; - default: return 'contain'; - } -}; interface VideoSurfaceProps { - useVLC: boolean; - forceVlcRemount: boolean; 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; @@ -56,44 +22,45 @@ interface VideoSurfaceProps { onEnd: () => void; onError: (err: any) => void; onBuffer: (buf: any) => void; - onTracksUpdate: (tracks: any) => void; // Refs - vlcPlayerRef: React.RefObject; - videoRef: React.RefObject; + mpvPlayerRef?: 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; + // Legacy props (kept for compatibility but unused with MPV) + useVLC?: boolean; + forceVlcRemount?: boolean; + headers?: any; + videoType?: any; + vlcSelectedAudioTrack?: number; + vlcSelectedSubtitleTrack?: number; + vlcRestoreTime?: number; + vlcKey?: string; + selectedAudioTrack?: any; + selectedTextTrack?: any; + useCustomSubtitles?: boolean; + onTracksUpdate?: (tracks: any) => void; + vlcPlayerRef?: any; + videoRef?: any; + vlcLoadedRef?: any; + customVideoStyles?: any; + loadStartAtRef?: any; + firstFrameAtRef?: any; + zoomScale?: number; } 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, @@ -101,23 +68,57 @@ export const VideoSurface: React.FC = ({ onEnd, onError, onBuffer, - onTracksUpdate, - vlcPlayerRef, - videoRef, + mpvPlayerRef, pinchRef, onPinchGestureEvent, onPinchHandlerStateChange, - vlcLoadedRef, screenDimensions, - customVideoStyles, - loadStartAtRef, - firstFrameAtRef }) => { + // Use the actual stream URL + const streamUrl = currentStreamUrl || processedStreamUrl; - const isHlsStream = (url: string) => { - return url.includes('.m3u8') || url.includes('m3u8') || - url.includes('hls') || url.includes('playlist') || - (videoType && videoType.toLowerCase() === 'm3u8'); + console.log('[VideoSurface] Rendering with:', { + streamUrl: streamUrl?.substring(0, 50) + '...', + paused, + volume, + playbackSpeed, + screenDimensions, + }); + + const handleLoad = (data: { duration: number; width: number; height: number }) => { + console.log('[VideoSurface] onLoad received:', data); + onLoad({ + duration: data.duration, + naturalSize: { + width: data.width, + height: data.height, + }, + }); + }; + + const handleProgress = (data: { currentTime: number; duration: number }) => { + // Log every 5 seconds to avoid spam + if (Math.floor(data.currentTime) % 5 === 0) { + console.log('[VideoSurface] onProgress:', data); + } + onProgress({ + currentTime: data.currentTime, + playableDuration: data.currentTime, + }); + }; + + const handleError = (error: { error: string }) => { + console.log('[VideoSurface] onError received:', error); + onError({ + error: { + errorString: error.error, + }, + }); + }; + + const handleEnd = () => { + console.log('[VideoSurface] onEnd received'); + onEnd(); }; return ( @@ -125,96 +126,53 @@ export const VideoSurface: React.FC = ({ width: screenDimensions.width, height: screenDimensions.height, }]}> + {/* MPV Player - rendered at the bottom of the z-order */} + + + {/* Gesture overlay - transparent, on top of the player */} - - - {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} - /> - ) : ( - + + + + ); }; + +const localStyles = StyleSheet.create({ + player: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + gestureOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + touchArea: { + flex: 1, + backgroundColor: 'transparent', + }, +}); diff --git a/src/components/player/android/hooks/usePlayerSetup.ts b/src/components/player/android/hooks/usePlayerSetup.ts index 6b5e00b..4dde019 100644 --- a/src/components/player/android/hooks/usePlayerSetup.ts +++ b/src/components/player/android/hooks/usePlayerSetup.ts @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; import { StatusBar, Platform, Dimensions, AppState } from 'react-native'; import RNImmersiveMode from 'react-native-immersive-mode'; +import * as NavigationBar from 'expo-navigation-bar'; import * as Brightness from 'expo-brightness'; import { logger } from '../../../../utils/logger'; import { useFocusEffect } from '@react-navigation/native'; @@ -18,19 +19,34 @@ export const usePlayerSetup = ( const originalSystemBrightnessModeRef = useRef(null); const isAppBackgrounded = useRef(false); - const enableImmersiveMode = () => { + const enableImmersiveMode = async () => { if (Platform.OS === 'android') { + // Standard immersive mode RNImmersiveMode.setBarTranslucent(true); RNImmersiveMode.fullLayout(true); StatusBar.setHidden(true, 'none'); + + // Explicitly hide bottom navigation bar using Expo + try { + await NavigationBar.setVisibilityAsync("hidden"); + await NavigationBar.setBehaviorAsync("overlay-swipe"); + } catch (e) { + // Ignore errors on non-supported devices + } } }; - const disableImmersiveMode = () => { + const disableImmersiveMode = async () => { if (Platform.OS === 'android') { RNImmersiveMode.setBarTranslucent(false); RNImmersiveMode.fullLayout(false); StatusBar.setHidden(false, 'fade'); + + try { + await NavigationBar.setVisibilityAsync("visible"); + } catch (e) { + // Ignore + } } }; diff --git a/src/components/player/modals/ErrorModal.tsx b/src/components/player/modals/ErrorModal.tsx index 174f934..a1eda32 100644 --- a/src/components/player/modals/ErrorModal.tsx +++ b/src/components/player/modals/ErrorModal.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import * as ExpoClipboard from 'expo-clipboard'; import { View, Text, TouchableOpacity, StyleSheet, useWindowDimensions } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import Animated, { @@ -21,6 +22,7 @@ export const ErrorModal: React.FC = ({ errorDetails, onDismiss, }) => { + const [copied, setCopied] = React.useState(false); const { width } = useWindowDimensions(); const MODAL_WIDTH = Math.min(width * 0.8, 400); @@ -31,6 +33,12 @@ export const ErrorModal: React.FC = ({ } }; + const handleCopy = async () => { + await ExpoClipboard.setStringAsync(errorDetails); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + if (!showErrorModal) return null; return ( @@ -74,16 +82,42 @@ export const ErrorModal: React.FC = ({ Playback Error - + {errorDetails || 'An unknown error occurred during playback.'} + + + + {copied ? 'Copied to clipboard' : 'Copy error details'} + + + Date: Mon, 22 Dec 2025 21:40:11 +0530 Subject: [PATCH 05/18] seek fix --- .../main/java/com/nuvio/app/mpv/MPVView.kt | 2 + .../com/nuvio/app/mpv/MpvPlayerViewManager.kt | 5 ++- src/components/player/AndroidVideoPlayer.tsx | 26 +++++++++-- src/components/player/android/MpvPlayer.tsx | 13 +----- .../android/components/VideoSurface.tsx | 15 +------ .../player/android/hooks/usePlayerControls.ts | 45 +++++++++++-------- .../player/android/hooks/useWatchProgress.ts | 7 ++- 7 files changed, 64 insertions(+), 49 deletions(-) diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt index e97fb31..88b8a96 100644 --- a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt +++ b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt @@ -145,7 +145,9 @@ class MPVView @JvmOverloads constructor( } fun seekTo(positionSeconds: Double) { + Log.d(TAG, "seekTo called: positionSeconds=$positionSeconds, isMpvInitialized=$isMpvInitialized") if (isMpvInitialized) { + Log.d(TAG, "Executing MPV seek command: seek $positionSeconds absolute") MPVLib.command(arrayOf("seek", positionSeconds.toString(), "absolute")) } } diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt b/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt index 822e529..45b055e 100644 --- a/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt +++ b/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt @@ -84,9 +84,12 @@ class MpvPlayerViewManager( } override fun receiveCommand(view: MPVView, commandId: String?, args: ReadableArray?) { + android.util.Log.d("MpvPlayerViewManager", "receiveCommand: $commandId, args: $args") when (commandId) { "seek" -> { - args?.getDouble(0)?.let { view.seekTo(it) } + val position = args?.getDouble(0) + android.util.Log.d("MpvPlayerViewManager", "Seek command received: position=$position") + position?.let { view.seekTo(it) } } "setAudioTrack" -> { args?.getInt(0)?.let { view.setAudioTrack(it) } diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 72a19b2..55c93b1 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -40,6 +40,7 @@ import { ErrorModal } from './modals/ErrorModal'; // Android-specific components import { VideoSurface } from './android/components/VideoSurface'; +import { MpvPlayerRef } from './android/MpvPlayer'; // Utils import { logger } from '../../utils/logger'; @@ -75,6 +76,7 @@ const AndroidVideoPlayer: React.FC = () => { const useVLC = (Platform.OS === 'android' && forceVlc); const videoRef = useRef(null); + const mpvPlayerRef = useRef(null); const vlcHook = useVlcPlayer(useVLC, playerState.paused, playerState.currentTime); const tracksHook = usePlayerTracks( useVLC, @@ -103,7 +105,7 @@ const AndroidVideoPlayer: React.FC = () => { const setupHook = usePlayerSetup(playerState.setScreenDimensions, setVolume, setBrightness, playerState.paused); const controlsHook = usePlayerControls( - videoRef, + mpvPlayerRef, // Use mpvPlayerRef for MPV player vlcHook.vlcPlayerRef, useVLC, playerState.paused, @@ -168,6 +170,13 @@ const AndroidVideoPlayer: React.FC = () => { if (!playerState.isMounted.current) return; const videoDuration = data.duration; + console.log('[AndroidVideoPlayer] handleLoad called:', { + duration: videoDuration, + initialPosition: watchProgress.initialPosition, + showResumeOverlay: watchProgress.showResumeOverlay, + initialSeekTarget: watchProgress.initialSeekTargetRef?.current + }); + if (videoDuration > 0) { playerState.setDuration(videoDuration); if (id && type) { @@ -204,9 +213,17 @@ const AndroidVideoPlayer: React.FC = () => { playerState.setIsVideoLoaded(true); openingAnimation.completeOpeningAnimation(); - // Handle Resume - if (watchProgress.initialPosition && !watchProgress.showResumeOverlay) { - controlsHook.seekToTime(watchProgress.initialPosition); + // Handle Resume - check both initialPosition and initialSeekTargetRef + const resumeTarget = watchProgress.initialPosition || watchProgress.initialSeekTargetRef?.current; + if (resumeTarget && resumeTarget > 0 && !watchProgress.showResumeOverlay && videoDuration > 0) { + console.log('[AndroidVideoPlayer] Seeking to resume position:', resumeTarget, 'duration:', videoDuration); + // Use a small delay to ensure the player is ready, then seek directly + setTimeout(() => { + if (mpvPlayerRef.current) { + console.log('[AndroidVideoPlayer] Calling mpvPlayerRef.current.seek directly'); + mpvPlayerRef.current.seek(Math.min(resumeTarget, videoDuration - 0.5)); + } + }, 200); } }, [id, type, episodeId, useVLC, playerState.isMounted, watchProgress.initialPosition]); @@ -385,6 +402,7 @@ const AndroidVideoPlayer: React.FC = () => { onBuffer={(buf) => playerState.setIsBuffering(buf.isBuffering)} onTracksUpdate={vlcHook.handleVlcTracksUpdate} vlcPlayerRef={vlcHook.vlcPlayerRef} + mpvPlayerRef={mpvPlayerRef} videoRef={videoRef} pinchRef={useRef(null)} onPinchGestureEvent={() => { }} diff --git a/src/components/player/android/MpvPlayer.tsx b/src/components/player/android/MpvPlayer.tsx index df1868f..5b9dd1e 100644 --- a/src/components/player/android/MpvPlayer.tsx +++ b/src/components/player/android/MpvPlayer.tsx @@ -59,12 +59,7 @@ const MpvPlayer = forwardRef((props, ref) => { ); } - console.log('[MpvPlayer] Rendering native component with:', { - source: props.source?.substring(0, 50) + '...', - paused: props.paused ?? true, - volume: props.volume ?? 1.0, - rate: props.rate ?? 1.0, - }); + // Debug logging removed to prevent console spam const handleLoad = (event: any) => { console.log('[MpvPlayer] Native onLoad event:', event?.nativeEvent); @@ -72,11 +67,7 @@ const MpvPlayer = forwardRef((props, ref) => { }; const handleProgress = (event: any) => { - const data = event?.nativeEvent; - if (data && Math.floor(data.currentTime) % 5 === 0) { - console.log('[MpvPlayer] Native onProgress event:', data); - } - props.onProgress?.(data); + props.onProgress?.(event?.nativeEvent); }; const handleEnd = (event: any) => { diff --git a/src/components/player/android/components/VideoSurface.tsx b/src/components/player/android/components/VideoSurface.tsx index 8b98dcb..9a02f73 100644 --- a/src/components/player/android/components/VideoSurface.tsx +++ b/src/components/player/android/components/VideoSurface.tsx @@ -1,10 +1,9 @@ -import React from 'react'; +import React, { useCallback, memo } from 'react'; import { View, TouchableWithoutFeedback, StyleSheet } from 'react-native'; import { PinchGestureHandler } from 'react-native-gesture-handler'; import MpvPlayer, { MpvPlayerRef } from '../MpvPlayer'; import { styles } from '../../utils/playerStyles'; import { ResizeModeType } from '../../utils/playerTypes'; -import { logger } from '../../../../utils/logger'; interface VideoSurfaceProps { processedStreamUrl: string; @@ -77,13 +76,7 @@ export const VideoSurface: React.FC = ({ // Use the actual stream URL const streamUrl = currentStreamUrl || processedStreamUrl; - console.log('[VideoSurface] Rendering with:', { - streamUrl: streamUrl?.substring(0, 50) + '...', - paused, - volume, - playbackSpeed, - screenDimensions, - }); + // Debug logging removed to prevent console spam const handleLoad = (data: { duration: number; width: number; height: number }) => { console.log('[VideoSurface] onLoad received:', data); @@ -97,10 +90,6 @@ export const VideoSurface: React.FC = ({ }; const handleProgress = (data: { currentTime: number; duration: number }) => { - // Log every 5 seconds to avoid spam - if (Math.floor(data.currentTime) % 5 === 0) { - console.log('[VideoSurface] onProgress:', data); - } onProgress({ currentTime: data.currentTime, playableDuration: data.currentTime, diff --git a/src/components/player/android/hooks/usePlayerControls.ts b/src/components/player/android/hooks/usePlayerControls.ts index 6befc3c..83bc421 100644 --- a/src/components/player/android/hooks/usePlayerControls.ts +++ b/src/components/player/android/hooks/usePlayerControls.ts @@ -2,11 +2,11 @@ import { useRef, useCallback } from 'react'; import { Platform } from 'react-native'; import { logger } from '../../../../utils/logger'; -const DEBUG_MODE = false; +const DEBUG_MODE = true; // Temporarily enable for debugging seek const END_EPSILON = 0.3; export const usePlayerControls = ( - videoRef: any, + mpvPlayerRef: any, vlcPlayerRef: any, useVLC: boolean, paused: boolean, @@ -26,39 +26,46 @@ export const usePlayerControls = ( const seekToTime = useCallback((rawSeconds: number) => { const timeInSeconds = Math.max(0, Math.min(rawSeconds, duration > 0 ? duration - END_EPSILON : rawSeconds)); + console.log('[usePlayerControls] seekToTime called:', { + rawSeconds, + timeInSeconds, + useVLC, + hasMpvRef: !!mpvPlayerRef?.current, + hasVlcRef: !!vlcPlayerRef?.current, + duration, + isSeeking: isSeeking.current + }); + if (useVLC) { if (vlcPlayerRef.current && duration > 0) { - if (DEBUG_MODE) logger.log(`[usePlayerControls][VLC] Seeking to ${timeInSeconds}`); + 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}`); + // MPV Player + if (mpvPlayerRef.current && duration > 0) { + console.log(`[usePlayerControls][MPV] Seeking to ${timeInSeconds}`); isSeeking.current = true; + mpvPlayerRef.current.seek(timeInSeconds); - if (Platform.OS === 'ios') { - iosWasPausedDuringSeekRef.current = paused; - if (!paused) setPaused(true); - } - - // Actually perform the seek - videoRef.current.seek(timeInSeconds); - + // Reset seeking flag after a delay setTimeout(() => { - if (isMounted.current && isSeeking.current) { + if (isMounted.current) { isSeeking.current = false; - if (Platform.OS === 'ios' && iosWasPausedDuringSeekRef.current === false) { - setPaused(false); - iosWasPausedDuringSeekRef.current = null; - } } }, 500); + } else { + console.log('[usePlayerControls][MPV] Cannot seek - ref or duration invalid:', { + hasRef: !!mpvPlayerRef?.current, + duration + }); } } - }, [useVLC, duration, paused, setPaused, videoRef, vlcPlayerRef, isSeeking, isMounted]); + }, [useVLC, duration, paused, setPaused, mpvPlayerRef, vlcPlayerRef, isSeeking, isMounted]); const skip = useCallback((seconds: number) => { + console.log('[usePlayerControls] skip called:', { seconds, currentTime, newTime: currentTime + seconds }); seekToTime(currentTime + seconds); }, [currentTime, seekToTime]); diff --git a/src/components/player/android/hooks/useWatchProgress.ts b/src/components/player/android/hooks/useWatchProgress.ts index fa61406..8f38d98 100644 --- a/src/components/player/android/hooks/useWatchProgress.ts +++ b/src/components/player/android/hooks/useWatchProgress.ts @@ -40,17 +40,22 @@ export const useWatchProgress = ( if (id && type) { try { const savedProgress = await storageService.getWatchProgress(id, type, episodeId); + console.log('[useWatchProgress] Loaded saved progress:', savedProgress); + if (savedProgress) { const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; + console.log('[useWatchProgress] Progress percent:', progressPercent); if (progressPercent < 85) { setResumePosition(savedProgress.currentTime); setSavedDuration(savedProgress.duration); if (appSettings.alwaysResume) { + console.log('[useWatchProgress] Always resume enabled, setting initial position:', savedProgress.currentTime); setInitialPosition(savedProgress.currentTime); initialSeekTargetRef.current = savedProgress.currentTime; - seekToTime(savedProgress.currentTime); + // Don't call seekToTime here - duration is 0 + // The seek will be handled in handleLoad callback } else { setShowResumeOverlay(true); } From 3cea29190171b61186ee394d54810021461afc31 Mon Sep 17 00:00:00 2001 From: tapframe Date: Tue, 23 Dec 2025 14:25:52 +0530 Subject: [PATCH 06/18] fixed subtitle rendering, added aspect ratio support --- .../main/java/com/nuvio/app/mpv/MPVView.kt | 160 +++++++++++++++++- .../com/nuvio/app/mpv/MpvPlayerViewManager.kt | 35 ++++ src/components/player/AndroidVideoPlayer.tsx | 55 +++++- src/components/player/android/MpvPlayer.tsx | 9 + .../android/components/VideoSurface.tsx | 4 + 5 files changed, 256 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt index 88b8a96..c983ccc 100644 --- a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt +++ b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt @@ -28,6 +28,7 @@ class MPVView @JvmOverloads constructor( var onProgressCallback: ((position: Double, duration: Double) -> Unit)? = null var onEndCallback: (() -> Unit)? = null var onErrorCallback: ((message: String) -> Unit)? = null + var onTracksChangedCallback: ((audioTracks: List>, subtitleTracks: List>) -> Unit)? = null init { surfaceTextureListener = this @@ -89,8 +90,13 @@ class MPVView @JvmOverloads constructor( MPVLib.setOptionString("vo", "gpu") MPVLib.setOptionString("gpu-context", "android") MPVLib.setOptionString("opengl-es", "yes") - MPVLib.setOptionString("hwdec", "mediacodec,mediacodec-copy") - MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1") + + // Hardware decoding - use mediacodec-copy to allow subtitle overlay + // 'mediacodec-copy' copies frames to CPU memory which enables subtitle blending + MPVLib.setOptionString("hwdec", "mediacodec-copy") + MPVLib.setOptionString("hwdec-codecs", "all") + + // Audio output MPVLib.setOptionString("ao", "audiotrack,opensles") // Network caching for streaming @@ -99,6 +105,43 @@ class MPVView @JvmOverloads constructor( MPVLib.setOptionString("cache", "yes") MPVLib.setOptionString("cache-secs", "30") + // Subtitle configuration - CRITICAL for Android + MPVLib.setOptionString("sub-auto", "fuzzy") // Auto-load subtitles + MPVLib.setOptionString("sub-visibility", "yes") // Make subtitles visible by default + MPVLib.setOptionString("sub-font-size", "48") // Larger font size for mobile readability + MPVLib.setOptionString("sub-pos", "95") // Position at bottom (0-100, 100 = very bottom) + MPVLib.setOptionString("sub-color", "#FFFFFFFF") // White color + MPVLib.setOptionString("sub-border-size", "3") // Thicker border for readability + MPVLib.setOptionString("sub-border-color", "#FF000000") // Black border + MPVLib.setOptionString("sub-shadow-offset", "2") // Add shadow for better visibility + MPVLib.setOptionString("sub-shadow-color", "#80000000") // Semi-transparent black shadow + + // Font configuration - point to Android system fonts for all language support + MPVLib.setOptionString("osd-fonts-dir", "/system/fonts") + MPVLib.setOptionString("sub-fonts-dir", "/system/fonts") + MPVLib.setOptionString("sub-font", "Roboto") // Default fallback font + // Allow embedded fonts in ASS/SSA but fallback to system fonts + MPVLib.setOptionString("embeddedfonts", "yes") + + // Language/encoding support for various subtitle formats + MPVLib.setOptionString("sub-codepage", "auto") // Auto-detect encoding (supports UTF-8, Latin, CJK, etc.) + + MPVLib.setOptionString("osc", "no") // Disable on screen controller + MPVLib.setOptionString("osd-level", "1") + + // Critical for subtitle rendering on Android GPU + // blend-subtitles=no lets the GPU renderer handle subtitle overlay properly + MPVLib.setOptionString("blend-subtitles", "no") + MPVLib.setOptionString("sub-use-margins", "no") + // Use 'scale' to allow ASS styling but with our scale and font overrides + // This preserves styled subtitles while having font fallbacks + MPVLib.setOptionString("sub-ass-override", "scale") + MPVLib.setOptionString("sub-scale", "1.0") + MPVLib.setOptionString("sub-fix-timing", "yes") // Fix timing for SRT subtitles + + // Force subtitle rendering + MPVLib.setOptionString("sid", "auto") // Auto-select subtitle track + // Disable terminal/input MPVLib.setOptionString("terminal", "no") MPVLib.setOptionString("input-default-bindings", "no") @@ -120,6 +163,11 @@ class MPVView @JvmOverloads constructor( MPVLib.observeProperty("width", MPV_FORMAT_INT64) MPVLib.observeProperty("height", MPV_FORMAT_INT64) MPVLib.observeProperty("track-list", MPV_FORMAT_NONE) + + // Observe subtitle properties for debugging + MPVLib.observeProperty("sid", MPV_FORMAT_INT64) + MPVLib.observeProperty("sub-visibility", MPV_FORMAT_FLAG) + MPVLib.observeProperty("sub-text", MPV_FORMAT_NONE) } private fun loadFile(url: String) { @@ -176,11 +224,52 @@ class MPVView @JvmOverloads constructor( } fun setSubtitleTrack(trackId: Int) { + Log.d(TAG, "setSubtitleTrack called: trackId=$trackId, isMpvInitialized=$isMpvInitialized") if (isMpvInitialized) { if (trackId == -1) { + Log.d(TAG, "Disabling subtitles (sid=no)") MPVLib.setPropertyString("sid", "no") + MPVLib.setPropertyString("sub-visibility", "no") } else { + Log.d(TAG, "Setting subtitle track to: $trackId") MPVLib.setPropertyInt("sid", trackId) + // Ensure subtitles are visible + MPVLib.setPropertyString("sub-visibility", "yes") + + // Debug: Verify the subtitle was set correctly + val currentSid = MPVLib.getPropertyInt("sid") + val subVisibility = MPVLib.getPropertyString("sub-visibility") + val subDelay = MPVLib.getPropertyDouble("sub-delay") + val subScale = MPVLib.getPropertyDouble("sub-scale") + Log.d(TAG, "After setting - sid=$currentSid, sub-visibility=$subVisibility, sub-delay=$subDelay, sub-scale=$subScale") + } + } + } + + fun setResizeMode(mode: String) { + Log.d(TAG, "setResizeMode called: mode=$mode, isMpvInitialized=$isMpvInitialized") + if (isMpvInitialized) { + when (mode) { + "contain" -> { + // Letterbox - show entire video with black bars + MPVLib.setPropertyDouble("panscan", 0.0) + MPVLib.setPropertyString("keepaspect", "yes") + } + "cover" -> { + // Fill/crop - zoom to fill, cropping edges + MPVLib.setPropertyDouble("panscan", 1.0) + MPVLib.setPropertyString("keepaspect", "yes") + } + "stretch" -> { + // Stretch - disable aspect ratio + MPVLib.setPropertyDouble("panscan", 0.0) + MPVLib.setPropertyString("keepaspect", "no") + } + else -> { + // Default to contain + MPVLib.setPropertyDouble("panscan", 0.0) + MPVLib.setPropertyString("keepaspect", "yes") + } } } } @@ -191,10 +280,58 @@ class MPVView @JvmOverloads constructor( Log.d(TAG, "Property changed: $property") when (property) { "track-list" -> { - // Track list updated, could notify JS about available tracks + // Parse track list and notify React Native + parseAndSendTracks() } } } + + private fun parseAndSendTracks() { + try { + val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0 + Log.d(TAG, "Track count: $trackCount") + + val audioTracks = mutableListOf>() + val subtitleTracks = mutableListOf>() + + for (i in 0 until trackCount) { + val type = MPVLib.getPropertyString("track-list/$i/type") ?: continue + val id = MPVLib.getPropertyInt("track-list/$i/id") ?: continue + val title = MPVLib.getPropertyString("track-list/$i/title") ?: "" + val lang = MPVLib.getPropertyString("track-list/$i/lang") ?: "" + val codec = MPVLib.getPropertyString("track-list/$i/codec") ?: "" + + val trackName = when { + title.isNotEmpty() -> title + lang.isNotEmpty() -> lang.uppercase() + else -> "Track $id" + } + + val track = mapOf( + "id" to id, + "name" to trackName, + "language" to lang, + "codec" to codec + ) + + when (type) { + "audio" -> { + Log.d(TAG, "Found audio track: $track") + audioTracks.add(track) + } + "sub" -> { + Log.d(TAG, "Found subtitle track: $track") + subtitleTracks.add(track) + } + } + } + + Log.d(TAG, "Sending tracks - Audio: ${audioTracks.size}, Subtitles: ${subtitleTracks.size}") + onTracksChangedCallback?.invoke(audioTracks, subtitleTracks) + } catch (e: Exception) { + Log.e(TAG, "Error parsing tracks", e) + } + } override fun eventProperty(property: String, value: Long) { Log.d(TAG, "Property $property = $value (Long)") @@ -244,7 +381,22 @@ class MPVView @JvmOverloads constructor( } } MPV_EVENT_END_FILE -> { - onEndCallback?.invoke() + Log.d(TAG, "MPV_EVENT_END_FILE") + + // Heuristic: If duration is effectively 0 at end of file, it's a load error + val duration = MPVLib.getPropertyDouble("duration") ?: 0.0 + val timePos = MPVLib.getPropertyDouble("time-pos") ?: 0.0 + val eofReached = MPVLib.getPropertyBoolean("eof-reached") ?: false + + Log.d(TAG, "End stats - Duration: $duration, Time: $timePos, EOF: $eofReached") + + if (duration < 1.0 && !eofReached) { + val customError = "Unable to play media. Source may be unreachable." + Log.e(TAG, "Playback error detected (heuristic): $customError") + onErrorCallback?.invoke(customError) + } else { + onEndCallback?.invoke() + } } } } diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt b/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt index 45b055e..db5426e 100644 --- a/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt +++ b/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt @@ -58,6 +58,35 @@ class MpvPlayerViewManager( sendEvent(context, view.id, "onError", event) } + view.onTracksChangedCallback = { audioTracks, subtitleTracks -> + val event = Arguments.createMap().apply { + val audioArray = Arguments.createArray() + audioTracks.forEach { track -> + val trackMap = Arguments.createMap().apply { + putInt("id", track["id"] as Int) + putString("name", track["name"] as String) + putString("language", track["language"] as String) + putString("codec", track["codec"] as String) + } + audioArray.pushMap(trackMap) + } + putArray("audioTracks", audioArray) + + val subtitleArray = Arguments.createArray() + subtitleTracks.forEach { track -> + val trackMap = Arguments.createMap().apply { + putInt("id", track["id"] as Int) + putString("name", track["name"] as String) + putString("language", track["language"] as String) + putString("codec", track["codec"] as String) + } + subtitleArray.pushMap(trackMap) + } + putArray("subtitleTracks", subtitleArray) + } + sendEvent(context, view.id, "onTracksChanged", event) + } + return view } @@ -72,6 +101,7 @@ class MpvPlayerViewManager( .put("onProgress", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onProgress"))) .put("onEnd", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onEnd"))) .put("onError", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onError"))) + .put("onTracksChanged", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onTracksChanged"))) .build() } @@ -128,4 +158,9 @@ class MpvPlayerViewManager( // Intentionally ignoring - background color would block the TextureView content // Leave the view transparent } + + @ReactProp(name = "resizeMode") + fun setResizeMode(view: MPVView, resizeMode: String?) { + view.setResizeMode(resizeMode ?: "contain") + } } diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 55c93b1..cb02cab 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -396,11 +396,45 @@ const AndroidVideoPlayer: React.FC = () => { return; } - modals.setErrorDetails(JSON.stringify(err)); + // Determine the actual error message + let displayError = 'An unknown error occurred'; + + if (typeof err?.error === 'string') { + displayError = err.error; + } else if (err?.error?.errorString) { + displayError = err.error.errorString; + } else if (err?.errorString) { + displayError = err.errorString; + } else if (typeof err === 'string') { + displayError = err; + } else { + displayError = JSON.stringify(err); + } + + modals.setErrorDetails(displayError); modals.setShowErrorModal(true); }} onBuffer={(buf) => playerState.setIsBuffering(buf.isBuffering)} onTracksUpdate={vlcHook.handleVlcTracksUpdate} + onTracksChanged={(data) => { + console.log('[AndroidVideoPlayer] onTracksChanged:', data); + if (data?.audioTracks) { + const formatted = data.audioTracks.map((t: any) => ({ + id: t.id, + name: t.name || `Track ${t.id}`, + language: t.language + })); + tracksHook.setRnVideoAudioTracks(formatted); + } + if (data?.subtitleTracks) { + const formatted = data.subtitleTracks.map((t: any) => ({ + id: t.id, + name: t.name || `Track ${t.id}`, + language: t.language + })); + tracksHook.setRnVideoTextTracks(formatted); + } + }} vlcPlayerRef={vlcHook.vlcPlayerRef} mpvPlayerRef={mpvPlayerRef} videoRef={videoRef} @@ -504,8 +538,15 @@ const AndroidVideoPlayer: React.FC = () => { ksAudioTracks={tracksHook.ksAudioTracks} selectedAudioTrack={tracksHook.computedSelectedAudioTrack} selectAudioTrack={(trackId) => { - useVLC ? vlcHook.selectVlcAudioTrack(trackId) : + if (useVLC) { + vlcHook.selectVlcAudioTrack(trackId); + } else { tracksHook.setSelectedAudioTrack(trackId === null ? null : { type: 'index', value: trackId }); + // Actually tell MPV to switch the audio track + if (trackId !== null && mpvPlayerRef.current) { + mpvPlayerRef.current.setAudioTrack(trackId); + } + } }} /> @@ -527,7 +568,15 @@ const AndroidVideoPlayer: React.FC = () => { fetchAvailableSubtitles={() => { }} // Placeholder loadWyzieSubtitle={() => { }} // Placeholder selectTextTrack={(trackId) => { - useVLC ? vlcHook.selectVlcSubtitleTrack(trackId) : tracksHook.setSelectedTextTrack(trackId); + if (useVLC) { + vlcHook.selectVlcSubtitleTrack(trackId); + } else { + tracksHook.setSelectedTextTrack(trackId); + // Actually tell MPV to switch the subtitle track + if (mpvPlayerRef.current) { + mpvPlayerRef.current.setSubtitleTrack(trackId); + } + } modals.setShowSubtitleModal(false); }} disableCustomSubtitles={() => { }} // Placeholder diff --git a/src/components/player/android/MpvPlayer.tsx b/src/components/player/android/MpvPlayer.tsx index 5b9dd1e..efa336f 100644 --- a/src/components/player/android/MpvPlayer.tsx +++ b/src/components/player/android/MpvPlayer.tsx @@ -17,11 +17,13 @@ export interface MpvPlayerProps { paused?: boolean; volume?: number; rate?: number; + resizeMode?: 'contain' | 'cover' | 'stretch'; style?: any; onLoad?: (data: { duration: number; width: number; height: number }) => void; onProgress?: (data: { currentTime: number; duration: number }) => void; onEnd?: () => void; onError?: (error: { error: string }) => void; + onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void; } const MpvPlayer = forwardRef((props, ref) => { @@ -80,6 +82,11 @@ const MpvPlayer = forwardRef((props, ref) => { props.onError?.(event?.nativeEvent); }; + const handleTracksChanged = (event: any) => { + console.log('[MpvPlayer] Native onTracksChanged event:', event?.nativeEvent); + props.onTracksChanged?.(event?.nativeEvent); + }; + return ( ((props, ref) => { paused={props.paused ?? true} volume={props.volume ?? 1.0} rate={props.rate ?? 1.0} + resizeMode={props.resizeMode ?? 'contain'} onLoad={handleLoad} onProgress={handleProgress} onEnd={handleEnd} onError={handleError} + onTracksChanged={handleTracksChanged} /> ); }); diff --git a/src/components/player/android/components/VideoSurface.tsx b/src/components/player/android/components/VideoSurface.tsx index 9a02f73..0899e2e 100644 --- a/src/components/player/android/components/VideoSurface.tsx +++ b/src/components/player/android/components/VideoSurface.tsx @@ -51,6 +51,7 @@ interface VideoSurfaceProps { loadStartAtRef?: any; firstFrameAtRef?: any; zoomScale?: number; + onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void; } export const VideoSurface: React.FC = ({ @@ -72,6 +73,7 @@ export const VideoSurface: React.FC = ({ onPinchGestureEvent, onPinchHandlerStateChange, screenDimensions, + onTracksChanged, }) => { // Use the actual stream URL const streamUrl = currentStreamUrl || processedStreamUrl; @@ -122,11 +124,13 @@ export const VideoSurface: React.FC = ({ paused={paused} volume={volume} rate={playbackSpeed} + resizeMode={resizeMode === 'none' ? 'contain' : resizeMode} style={localStyles.player} onLoad={handleLoad} onProgress={handleProgress} onEnd={handleEnd} onError={handleError} + onTracksChanged={onTracksChanged} /> {/* Gesture overlay - transparent, on top of the player */} From f0f71afd67d70077f8cf6648acffe74e69719684 Mon Sep 17 00:00:00 2001 From: tapframe Date: Tue, 23 Dec 2025 15:16:26 +0530 Subject: [PATCH 07/18] fixed custom subtitle rendering android --- libmpv-android | 1 + src/components/player/AndroidVideoPlayer.tsx | 268 ++++++++++++++++--- 2 files changed, 231 insertions(+), 38 deletions(-) create mode 160000 libmpv-android diff --git a/libmpv-android b/libmpv-android new file mode 160000 index 0000000..8c4778b --- /dev/null +++ b/libmpv-android @@ -0,0 +1 @@ +Subproject commit 8c4778b5aad441bb0449a7f9b3d6d827fd3d6a2a diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index cb02cab..9a0c7d4 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -37,6 +37,7 @@ import { SourcesModal } from './modals/SourcesModal'; import { EpisodesModal } from './modals/EpisodesModal'; import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal'; import { ErrorModal } from './modals/ErrorModal'; +import { CustomSubtitles } from './subtitles/CustomSubtitles'; // Android-specific components import { VideoSurface } from './android/components/VideoSurface'; @@ -45,8 +46,11 @@ import { MpvPlayerRef } from './android/MpvPlayer'; // Utils import { logger } from '../../utils/logger'; import { styles } from './utils/playerStyles'; -import { formatTime, isHlsStream, processUrlForVLC, getHlsHeaders, defaultAndroidHeaders } from './utils/playerUtils'; +import { formatTime, isHlsStream, processUrlForVLC, getHlsHeaders, defaultAndroidHeaders, parseSRT } from './utils/playerUtils'; import { storageService } from '../../services/storageService'; +import stremioService from '../../services/stremioService'; +import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes'; +import axios from 'axios'; const DEBUG_MODE = false; @@ -95,6 +99,29 @@ const AndroidVideoPlayer: React.FC = () => { const [currentStreamProvider, setCurrentStreamProvider] = useState(streamProvider); const [currentStreamName, setCurrentStreamName] = useState(streamName); + // Subtitle addon state + const [availableSubtitles, setAvailableSubtitles] = useState([]); + const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false); + const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false); + const [useCustomSubtitles, setUseCustomSubtitles] = useState(false); + const [customSubtitles, setCustomSubtitles] = useState([]); + const [currentSubtitle, setCurrentSubtitle] = useState(''); + + // Subtitle customization state + const [subtitleSize, setSubtitleSize] = useState(28); + const [subtitleBackground, setSubtitleBackground] = useState(false); + const [subtitleTextColor, setSubtitleTextColor] = useState('#FFFFFF'); + const [subtitleBgOpacity, setSubtitleBgOpacity] = useState(0.7); + const [subtitleTextShadow, setSubtitleTextShadow] = useState(true); + const [subtitleOutline, setSubtitleOutline] = useState(true); + const [subtitleOutlineColor, setSubtitleOutlineColor] = useState('#000000'); + const [subtitleOutlineWidth, setSubtitleOutlineWidth] = useState(3); + const [subtitleAlign, setSubtitleAlign] = useState<'center' | 'left' | 'right'>('center'); + const [subtitleBottomOffset, setSubtitleBottomOffset] = useState(20); + const [subtitleLetterSpacing, setSubtitleLetterSpacing] = useState(0); + const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState(1.2); + const [subtitleOffsetSec, setSubtitleOffsetSec] = useState(0); + 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; @@ -166,6 +193,53 @@ const AndroidVideoPlayer: React.FC = () => { openingAnimation.startOpeningAnimation(); }, []); + // Load subtitle settings on mount + useEffect(() => { + const loadSubtitleSettings = async () => { + const settings = await storageService.getSubtitleSettings(); + if (settings) { + if (settings.subtitleSize !== undefined) setSubtitleSize(settings.subtitleSize); + if (settings.subtitleBackground !== undefined) setSubtitleBackground(settings.subtitleBackground); + if (settings.subtitleTextColor !== undefined) setSubtitleTextColor(settings.subtitleTextColor); + if (settings.subtitleBgOpacity !== undefined) setSubtitleBgOpacity(settings.subtitleBgOpacity); + if (settings.subtitleTextShadow !== undefined) setSubtitleTextShadow(settings.subtitleTextShadow); + if (settings.subtitleOutline !== undefined) setSubtitleOutline(settings.subtitleOutline); + if (settings.subtitleOutlineColor !== undefined) setSubtitleOutlineColor(settings.subtitleOutlineColor); + if (settings.subtitleOutlineWidth !== undefined) setSubtitleOutlineWidth(settings.subtitleOutlineWidth); + if (settings.subtitleAlign !== undefined) setSubtitleAlign(settings.subtitleAlign); + if (settings.subtitleBottomOffset !== undefined) setSubtitleBottomOffset(settings.subtitleBottomOffset); + if (settings.subtitleLetterSpacing !== undefined) setSubtitleLetterSpacing(settings.subtitleLetterSpacing); + if (settings.subtitleLineHeightMultiplier !== undefined) setSubtitleLineHeightMultiplier(settings.subtitleLineHeightMultiplier); + } + }; + loadSubtitleSettings(); + }, []); + + // Save subtitle settings when they change + useEffect(() => { + const saveSettings = async () => { + await storageService.saveSubtitleSettings({ + subtitleSize, + subtitleBackground, + subtitleTextColor, + subtitleBgOpacity, + subtitleTextShadow, + subtitleOutline, + subtitleOutlineColor, + subtitleOutlineWidth, + subtitleAlign, + subtitleBottomOffset, + subtitleLetterSpacing, + subtitleLineHeightMultiplier, + }); + }; + saveSettings(); + }, [ + subtitleSize, subtitleBackground, subtitleTextColor, subtitleBgOpacity, + subtitleTextShadow, subtitleOutline, subtitleOutlineColor, subtitleOutlineWidth, + subtitleAlign, subtitleBottomOffset, subtitleLetterSpacing, subtitleLineHeightMultiplier + ]); + const handleLoad = useCallback((data: any) => { if (!playerState.isMounted.current) return; @@ -236,6 +310,16 @@ const AndroidVideoPlayer: React.FC = () => { } }, [playerState.currentTime, playerState.isDragging, playerState.isSeeking, setupHook.isAppBackgrounded]); + // Sync custom subtitle text with current playback time + useEffect(() => { + if (!useCustomSubtitles || customSubtitles.length === 0) return; + + const cueNow = customSubtitles.find( + cue => playerState.currentTime >= cue.start && playerState.currentTime <= cue.end + ); + setCurrentSubtitle(cueNow ? cueNow.text : ''); + }, [playerState.currentTime, useCustomSubtitles, customSubtitles]); + const toggleControls = useCallback(() => { playerState.setShowControls(prev => !prev); }, []); @@ -313,6 +397,92 @@ const AndroidVideoPlayer: React.FC = () => { }, 100); }; + // Subtitle addon fetching + const fetchAvailableSubtitles = useCallback(async () => { + const targetImdbId = imdbId; + if (!targetImdbId) { + logger.warn('[AndroidVideoPlayer] No IMDB ID for subtitle fetch'); + return; + } + + setIsLoadingSubtitleList(true); + try { + const stremioType = type === 'series' ? 'series' : 'movie'; + const stremioVideoId = stremioType === 'series' && season && episode + ? `series:${targetImdbId}:${season}:${episode}` + : undefined; + const results = await stremioService.getSubtitles(stremioType, targetImdbId, stremioVideoId); + + const subs: WyzieSubtitle[] = (results || []).map((sub: any) => ({ + id: sub.id || `${sub.lang}-${sub.url}`, + url: sub.url, + flagUrl: '', + format: 'srt', + encoding: 'utf-8', + media: sub.addonName || sub.addon || '', + display: sub.lang || 'Unknown', + language: (sub.lang || '').toLowerCase(), + isHearingImpaired: false, + source: sub.addonName || sub.addon || 'Addon', + })); + + setAvailableSubtitles(subs); + logger.info(`[AndroidVideoPlayer] Fetched ${subs.length} addon subtitles`); + } catch (e) { + logger.error('[AndroidVideoPlayer] Error fetching addon subtitles', e); + } finally { + setIsLoadingSubtitleList(false); + } + }, [imdbId, type, season, episode]); + + const loadWyzieSubtitle = useCallback(async (subtitle: WyzieSubtitle) => { + if (!subtitle.url) return; + + modals.setShowSubtitleModal(false); + setIsLoadingSubtitles(true); + try { + // Download subtitle file + let srtContent = ''; + try { + const resp = await axios.get(subtitle.url, { timeout: 10000 }); + srtContent = typeof resp.data === 'string' ? resp.data : String(resp.data); + } catch { + const resp = await fetch(subtitle.url); + srtContent = await resp.text(); + } + + // Parse subtitle file + const parsedCues = parseSRT(srtContent); + setCustomSubtitles(parsedCues); + setUseCustomSubtitles(true); + + // Disable MPV's built-in subtitle track when using custom subtitles + tracksHook.setSelectedTextTrack(-1); + if (mpvPlayerRef.current) { + mpvPlayerRef.current.setSubtitleTrack(-1); + } + + // Set initial subtitle based on current time + const adjustedTime = playerState.currentTime; + const cueNow = parsedCues.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end); + setCurrentSubtitle(cueNow ? cueNow.text : ''); + + logger.info(`[AndroidVideoPlayer] Loaded addon subtitle: ${subtitle.display} (${parsedCues.length} cues)`); + toast.success(`Subtitle loaded: ${subtitle.display}`); + } catch (e) { + logger.error('[AndroidVideoPlayer] Error loading subtitle', e); + toast.error('Failed to load subtitle'); + } finally { + setIsLoadingSubtitles(false); + } + }, [modals, playerState.currentTime, tracksHook]); + + const disableCustomSubtitles = useCallback(() => { + setUseCustomSubtitles(false); + setCustomSubtitles([]); + setCurrentSubtitle(''); + }, []); + const cycleResizeMode = useCallback(() => { if (playerState.resizeMode === 'contain') playerState.setResizeMode('cover'); else playerState.setResizeMode('contain'); @@ -448,6 +618,26 @@ const AndroidVideoPlayer: React.FC = () => { firstFrameAtRef={firstFrameAtRef} /> + {/* Custom Subtitles for addon subtitles */} + { { }} // Placeholder - isLoadingSubtitleList={false} // Placeholder - isLoadingSubtitles={false} // Placeholder - customSubtitles={[]} // Placeholder - availableSubtitles={[]} // Placeholder + showSubtitleLanguageModal={false} + setShowSubtitleLanguageModal={() => { }} + isLoadingSubtitleList={isLoadingSubtitleList} + isLoadingSubtitles={isLoadingSubtitles} + customSubtitles={[]} + availableSubtitles={availableSubtitles} ksTextTracks={tracksHook.ksTextTracks} selectedTextTrack={tracksHook.computedSelectedTextTrack} - useCustomSubtitles={false} + useCustomSubtitles={useCustomSubtitles} isKsPlayerActive={!useVLC} - subtitleSize={30} // Placeholder - subtitleBackground={false} // Placeholder - fetchAvailableSubtitles={() => { }} // Placeholder - loadWyzieSubtitle={() => { }} // Placeholder + subtitleSize={subtitleSize} + subtitleBackground={subtitleBackground} + fetchAvailableSubtitles={fetchAvailableSubtitles} + loadWyzieSubtitle={loadWyzieSubtitle} selectTextTrack={(trackId) => { if (useVLC) { vlcHook.selectVlcSubtitleTrack(trackId); @@ -577,34 +767,36 @@ const AndroidVideoPlayer: React.FC = () => { mpvPlayerRef.current.setSubtitleTrack(trackId); } } + // Disable custom subtitles when selecting built-in track + setUseCustomSubtitles(false); 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 + disableCustomSubtitles={disableCustomSubtitles} + increaseSubtitleSize={() => setSubtitleSize(prev => Math.min(prev + 2, 60))} + decreaseSubtitleSize={() => setSubtitleSize(prev => Math.max(prev - 2, 12))} + toggleSubtitleBackground={() => setSubtitleBackground(prev => !prev)} + subtitleTextColor={subtitleTextColor} + setSubtitleTextColor={setSubtitleTextColor} + subtitleBgOpacity={subtitleBgOpacity} + setSubtitleBgOpacity={setSubtitleBgOpacity} + subtitleTextShadow={subtitleTextShadow} + setSubtitleTextShadow={setSubtitleTextShadow} + subtitleOutline={subtitleOutline} + setSubtitleOutline={setSubtitleOutline} + subtitleOutlineColor={subtitleOutlineColor} + setSubtitleOutlineColor={setSubtitleOutlineColor} + subtitleOutlineWidth={subtitleOutlineWidth} + setSubtitleOutlineWidth={setSubtitleOutlineWidth} + subtitleAlign={subtitleAlign} + setSubtitleAlign={setSubtitleAlign} + subtitleBottomOffset={subtitleBottomOffset} + setSubtitleBottomOffset={setSubtitleBottomOffset} + subtitleLetterSpacing={subtitleLetterSpacing} + setSubtitleLetterSpacing={setSubtitleLetterSpacing} + subtitleLineHeightMultiplier={subtitleLineHeightMultiplier} + setSubtitleLineHeightMultiplier={setSubtitleLineHeightMultiplier} + subtitleOffsetSec={subtitleOffsetSec} + setSubtitleOffsetSec={setSubtitleOffsetSec} /> Date: Tue, 23 Dec 2025 16:42:41 +0530 Subject: [PATCH 08/18] removed vlc lib --- package.json | 3 +- src/components/home/AppleTVHero.tsx | 1 - .../home/ContinueWatchingSection.tsx | 1 - src/components/player/AndroidVideoPlayer.tsx | 137 ++----- src/components/player/KSPlayerCore.tsx | 13 +- src/components/player/VlcVideoPlayer.tsx | 364 ------------------ .../android/components/VideoSurface.tsx | 21 - .../player/android/hooks/usePlayerControls.ts | 47 +-- .../player/android/hooks/usePlayerTracks.ts | 38 +- .../player/android/hooks/useVlcPlayer.ts | 148 ------- .../player/hooks/useCustomSubtitles.ts | 52 ++- src/navigation/AppNavigator.tsx | 2 - src/screens/DownloadsScreen.tsx | 1 - src/screens/StreamsScreen.tsx | 7 +- src/utils/playerSelection.ts | 4 +- 15 files changed, 124 insertions(+), 715 deletions(-) delete mode 100644 src/components/player/VlcVideoPlayer.tsx delete mode 100644 src/components/player/android/hooks/useVlcPlayer.ts diff --git a/package.json b/package.json index e4f9750..54fbb23 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ "expo-glass-effect": "~0.1.4", "expo-haptics": "~15.0.7", "expo-intent-launcher": "~13.0.7", - "expo-libvlc-player": "^2.2.3", "expo-linear-gradient": "~15.0.7", "expo-localization": "~17.0.7", "expo-navigation-bar": "~5.0.10", @@ -104,4 +103,4 @@ "xcode": "^3.0.1" }, "private": true -} +} \ No newline at end of file diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index 8fca9f4..29a53c4 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -649,7 +649,6 @@ const AppleTVHero: React.FC = ({ streamProvider: cachedStream.stream.addonId || cachedStream.stream.addonName || cachedStream.stream.name, streamName: cachedStream.stream.name || cachedStream.stream.title || 'Unnamed Stream', headers: cachedStream.stream.headers || undefined, - forceVlc: false, id: currentItem.id, type: currentItem.type, episodeId: episodeId, diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 7cbfa49..96d132c 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -977,7 +977,6 @@ const ContinueWatchingSection = React.forwardRef((props, re streamProvider: cachedStream.stream.addonId || cachedStream.stream.addonName || cachedStream.stream.name, streamName: cachedStream.stream.name || cachedStream.stream.title || 'Unnamed Stream', headers: cachedStream.stream.headers || undefined, - forceVlc: false, id: item.id, type: item.type, episodeId: episodeId, diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 9a0c7d4..9eea59a 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -13,9 +13,8 @@ import { useOpeningAnimation } from './hooks'; -// Android-specific hooks (VLC integration, dual player support) +// Android-specific hooks 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'; @@ -46,7 +45,7 @@ import { MpvPlayerRef } from './android/MpvPlayer'; // Utils import { logger } from '../../utils/logger'; import { styles } from './utils/playerStyles'; -import { formatTime, isHlsStream, processUrlForVLC, getHlsHeaders, defaultAndroidHeaders, parseSRT } from './utils/playerUtils'; +import { formatTime, isHlsStream, getHlsHeaders, defaultAndroidHeaders, parseSRT } from './utils/playerUtils'; import { storageService } from '../../services/storageService'; import stremioService from '../../services/stremioService'; import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes'; @@ -71,28 +70,12 @@ const AndroidVideoPlayer: React.FC = () => { 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]); - - const useVLC = (Platform.OS === 'android' && forceVlc); - const videoRef = useRef(null); const mpvPlayerRef = useRef(null); - const vlcHook = useVlcPlayer(useVLC, playerState.paused, playerState.currentTime); - const tracksHook = usePlayerTracks( - useVLC, - vlcHook.vlcAudioTracks, - vlcHook.vlcSubtitleTracks, - vlcHook.vlcSelectedAudioTrack, - vlcHook.vlcSelectedSubtitleTrack - ); + const tracksHook = usePlayerTracks(); const [currentStreamUrl, setCurrentStreamUrl] = useState(uri); const [currentVideoType, setCurrentVideoType] = useState((route.params as any).videoType); - const processedStreamUrl = useMemo(() => useVLC ? processUrlForVLC(currentStreamUrl) : currentStreamUrl, [currentStreamUrl, useVLC]); const [availableStreams, setAvailableStreams] = useState(passedAvailableStreams || {}); const [currentQuality, setCurrentQuality] = useState(quality); @@ -132,9 +115,7 @@ const AndroidVideoPlayer: React.FC = () => { const setupHook = usePlayerSetup(playerState.setScreenDimensions, setVolume, setBrightness, playerState.paused); const controlsHook = usePlayerControls( - mpvPlayerRef, // Use mpvPlayerRef for MPV player - vlcHook.vlcPlayerRef, - useVLC, + mpvPlayerRef, playerState.paused, playerState.setPaused, playerState.currentTime, @@ -265,23 +246,21 @@ const AndroidVideoPlayer: React.FC = () => { 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); - } + 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); @@ -299,7 +278,7 @@ const AndroidVideoPlayer: React.FC = () => { } }, 200); } - }, [id, type, episodeId, useVLC, playerState.isMounted, watchProgress.initialPosition]); + }, [id, type, episodeId, playerState.isMounted, watchProgress.initialPosition]); const handleProgress = useCallback((data: any) => { if (playerState.isDragging.current || playerState.isSeeking.current || !playerState.isMounted.current || setupHook.isAppBackgrounded.current) return; @@ -385,7 +364,6 @@ const AndroidVideoPlayer: React.FC = () => { streamProvider: newProvider, streamName: newStreamName, headers: stream.headers || undefined, - forceVlc: false, id, type: 'series', episodeId: ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`, @@ -508,24 +486,12 @@ const AndroidVideoPlayer: React.FC = () => { { onError={(err: any) => { logger.error('Video Error', err); - // Check for decoding errors to switch to VLC - const errorString = err?.errorString || err?.error?.errorString; - const errorCode = err?.errorCode || err?.error?.errorCode; - const causeMessage = err?.error?.cause?.message; - - const isDecodingError = - (errorString && errorString.includes('ERROR_CODE_DECODING_FAILED')) || - errorCode === '24003' || - (causeMessage && causeMessage.includes('MediaCodecVideoRenderer error')); - - if (!useVLC && isDecodingError) { - const toastId = toast.loading('Decoding error. Switching to VLC Player...'); - setTimeout(() => toast.dismiss(toastId), 3000); - - // We can just show a normal toast or use the existing modal system if we want, - // but checking the file imports, I don't see Toast imported. - // Let's implement the navigation replace. - - // Using a simple navigation replace to force VLC - (navigation as any).replace('PlayerAndroid', { - ...route.params, - forceVlc: true - }); - return; - } - // Determine the actual error message let displayError = 'An unknown error occurred'; @@ -585,7 +525,6 @@ const AndroidVideoPlayer: React.FC = () => { modals.setShowErrorModal(true); }} onBuffer={(buf) => playerState.setIsBuffering(buf.isBuffering)} - onTracksUpdate={vlcHook.handleVlcTracksUpdate} onTracksChanged={(data) => { console.log('[AndroidVideoPlayer] onTracksChanged:', data); if (data?.audioTracks) { @@ -605,17 +544,11 @@ const AndroidVideoPlayer: React.FC = () => { tracksHook.setRnVideoTextTracks(formatted); } }} - vlcPlayerRef={vlcHook.vlcPlayerRef} mpvPlayerRef={mpvPlayerRef} - videoRef={videoRef} pinchRef={useRef(null)} onPinchGestureEvent={() => { }} onPinchHandlerStateChange={() => { }} - vlcLoadedRef={vlcHook.vlcLoadedRef} screenDimensions={playerState.screenDimensions} - customVideoStyles={{}} - loadStartAtRef={loadStartAtRef} - firstFrameAtRef={firstFrameAtRef} /> {/* Custom Subtitles for addon subtitles */} @@ -698,7 +631,7 @@ const AndroidVideoPlayer: React.FC = () => { }} buffered={playerState.buffered} formatTime={formatTime} - playerBackend={useVLC ? 'VLC' : 'ExoPlayer'} + playerBackend={'MPV'} /> { ksAudioTracks={tracksHook.ksAudioTracks} selectedAudioTrack={tracksHook.computedSelectedAudioTrack} selectAudioTrack={(trackId) => { - if (useVLC) { - vlcHook.selectVlcAudioTrack(trackId); - } else { - tracksHook.setSelectedAudioTrack(trackId === null ? null : { type: 'index', value: trackId }); - // Actually tell MPV to switch the audio track - if (trackId !== null && mpvPlayerRef.current) { - mpvPlayerRef.current.setAudioTrack(trackId); - } + tracksHook.setSelectedAudioTrack(trackId === null ? null : { type: 'index', value: trackId }); + // Actually tell MPV to switch the audio track + if (trackId !== null && mpvPlayerRef.current) { + mpvPlayerRef.current.setAudioTrack(trackId); } }} /> @@ -752,20 +681,16 @@ const AndroidVideoPlayer: React.FC = () => { ksTextTracks={tracksHook.ksTextTracks} selectedTextTrack={tracksHook.computedSelectedTextTrack} useCustomSubtitles={useCustomSubtitles} - isKsPlayerActive={!useVLC} + isKsPlayerActive={true} subtitleSize={subtitleSize} subtitleBackground={subtitleBackground} fetchAvailableSubtitles={fetchAvailableSubtitles} loadWyzieSubtitle={loadWyzieSubtitle} selectTextTrack={(trackId) => { - if (useVLC) { - vlcHook.selectVlcSubtitleTrack(trackId); - } else { - tracksHook.setSelectedTextTrack(trackId); - // Actually tell MPV to switch the subtitle track - if (mpvPlayerRef.current) { - mpvPlayerRef.current.setSubtitleTrack(trackId); - } + tracksHook.setSelectedTextTrack(trackId); + // Actually tell MPV to switch the subtitle track + if (mpvPlayerRef.current) { + mpvPlayerRef.current.setSubtitleTrack(trackId); } // Disable custom subtitles when selecting built-in track setUseCustomSubtitles(false); diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 0b21996..9e2fe75 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -302,6 +302,17 @@ const KSPlayerCore: React.FC = () => { } }, [imdbId]); + // Sync custom subtitle text with current playback time + useEffect(() => { + if (!customSubs.useCustomSubtitles || customSubs.customSubtitles.length === 0) return; + + const adjustedTime = currentTime + (customSubs.subtitleOffsetSec || 0); + const cueNow = customSubs.customSubtitles.find( + cue => adjustedTime >= cue.start && adjustedTime <= cue.end + ); + customSubs.setCurrentSubtitle(cueNow ? cueNow.text : ''); + }, [currentTime, customSubs.useCustomSubtitles, customSubs.customSubtitles, customSubs.subtitleOffsetSec]); + // Handlers const onLoad = (data: any) => { setDuration(data.duration); @@ -416,7 +427,7 @@ const KSPlayerCore: React.FC = () => { headers: stream.headers || undefined, id, type: 'series', - episodeId: ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`, + episodeId: ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number} `, imdbId: imdbId ?? undefined, backdrop: backdrop || undefined, }); diff --git a/src/components/player/VlcVideoPlayer.tsx b/src/components/player/VlcVideoPlayer.tsx deleted file mode 100644 index 75a7dba..0000000 --- a/src/components/player/VlcVideoPlayer.tsx +++ /dev/null @@ -1,364 +0,0 @@ -import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'; -import { View, Dimensions } from 'react-native'; -import { logger } from '../../utils/logger'; - -// Dynamic import to avoid iOS loading Android native module -let LibVlcPlayerViewComponent: any = null; - -try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const mod = require('expo-libvlc-player'); - LibVlcPlayerViewComponent = mod?.LibVlcPlayerView || null; -} catch { - LibVlcPlayerViewComponent = null; -} - -interface VlcVideoPlayerProps { - source: string; - volume: number; - playbackSpeed: number; - zoomScale: number; - resizeMode: 'contain' | 'cover' | 'none'; - onLoad: (data: any) => void; - onProgress: (data: any) => void; - onSeek: (data: any) => void; - onEnd: () => void; - onError: (error: any) => void; - onTracksUpdate: (tracks: { audio: any[], subtitle: any[] }) => void; - selectedAudioTrack?: number | null; - selectedSubtitleTrack?: number | null; - restoreTime?: number | null; - forceRemount?: boolean; - key?: string; -} - -interface VlcTrack { - id: number; - name: string; - language?: string; -} - -export interface VlcPlayerRef { - seek: (timeInSeconds: number) => void; - pause: () => void; - play: () => void; -} - -const VlcVideoPlayer = forwardRef(({ - source, - volume, - playbackSpeed, - zoomScale, - resizeMode, - onLoad, - onProgress, - onSeek, - onEnd, - onError, - onTracksUpdate, - selectedAudioTrack, - selectedSubtitleTrack, - restoreTime, - forceRemount, - key, -}, ref) => { - const vlcRef = useRef(null); - const [vlcActive, setVlcActive] = useState(true); - const [duration, setDuration] = useState(0); - const [videoAspectRatio, setVideoAspectRatio] = useState(null); - - // Expose imperative methods to parent component - useImperativeHandle(ref, () => ({ - seek: (timeInSeconds: number) => { - if (vlcRef.current && typeof vlcRef.current.seek === 'function') { - const fraction = Math.min(Math.max(timeInSeconds / (duration || 1), 0), 0.999); - vlcRef.current.seek(fraction); - logger.log(`[VLC] Seeked to ${timeInSeconds}s (${fraction.toFixed(3)})`); - } - }, - pause: () => { - if (vlcRef.current && typeof vlcRef.current.pause === 'function') { - vlcRef.current.pause(); - logger.log('[VLC] Paused'); - } - }, - play: () => { - if (vlcRef.current && typeof vlcRef.current.play === 'function') { - vlcRef.current.play(); - logger.log('[VLC] Played'); - } - } - }), [duration]); - - // Compute aspect ratio string for VLC - const toVlcRatio = useCallback((w: number, h: number): string => { - const a = Math.max(1, Math.round(w)); - const b = Math.max(1, Math.round(h)); - const gcd = (x: number, y: number): number => (y === 0 ? x : gcd(y, x % y)); - const g = gcd(a, b); - return `${Math.floor(a / g)}:${Math.floor(b / g)}`; - }, []); - - const screenDimensions = Dimensions.get('screen'); - - const vlcAspectRatio = useMemo(() => { - // For VLC, no forced aspect ratio - let it preserve natural aspect - return undefined; - }, [resizeMode, screenDimensions.width, screenDimensions.height, toVlcRatio]); - - const clientScale = useMemo(() => { - if (!videoAspectRatio || screenDimensions.width <= 0 || screenDimensions.height <= 0) { - return 1; - } - if (resizeMode === 'cover') { - const screenAR = screenDimensions.width / screenDimensions.height; - return Math.max(screenAR / videoAspectRatio, videoAspectRatio / screenAR); - } - return 1; - }, [resizeMode, videoAspectRatio, screenDimensions.width, screenDimensions.height]); - - // VLC options for better playback - const vlcOptions = useMemo(() => { - return [ - '--network-caching=2000', - '--clock-jitter=0', - '--http-reconnect', - '--sout-mux-caching=2000' - ]; - }, []); - - // VLC tracks prop - const vlcTracks = useMemo(() => ({ - audio: selectedAudioTrack, - video: 0, // Use first video track - subtitle: selectedSubtitleTrack - }), [selectedAudioTrack, selectedSubtitleTrack]); - - const handleFirstPlay = useCallback((info: any) => { - try { - logger.log('[VLC] Video loaded, extracting tracks...'); - logger.log('[AndroidVideoPlayer][VLC] Video loaded successfully'); - - // Process VLC tracks using optimized function - if (info?.tracks) { - processVlcTracks(info.tracks); - } - - const lenSec = (info?.length ?? 0) / 1000; - const width = info?.width || 0; - const height = info?.height || 0; - setDuration(lenSec); - onLoad({ duration: lenSec, naturalSize: width && height ? { width, height } : undefined }); - - if (width > 0 && height > 0) { - setVideoAspectRatio(width / height); - } - - // Restore playback position after remount (workaround for surface detach) - if (restoreTime !== undefined && restoreTime !== null && restoreTime > 0) { - setTimeout(() => { - if (vlcRef.current && typeof vlcRef.current.seek === 'function') { - const seekPosition = Math.min(restoreTime / lenSec, 0.999); // Convert to fraction - vlcRef.current.seek(seekPosition); - logger.log('[VLC] Seeked to restore position'); - } - }, 500); // Small delay to ensure player is ready - } - } catch (e) { - logger.error('[VLC] onFirstPlay error:', e); - logger.warn('[AndroidVideoPlayer][VLC] onFirstPlay parse error', e); - } - }, [onLoad, restoreTime]); - - const handlePositionChanged = useCallback((ev: any) => { - const pos = typeof ev?.position === 'number' ? ev.position : 0; - // We need duration to calculate current time, but it's not available here - // The parent component should handle this calculation - onProgress({ position: pos }); - }, [onProgress]); - - const handlePlaying = useCallback(() => { - setVlcActive(true); - }, []); - - const handlePaused = useCallback(() => { - setVlcActive(false); - }, []); - - const handleEndReached = useCallback(() => { - onEnd(); - }, [onEnd]); - - const handleEncounteredError = useCallback((e: any) => { - logger.error('[AndroidVideoPlayer][VLC] Encountered error:', e); - onError(e); - }, [onError]); - - const handleBackground = useCallback(() => { - logger.log('[VLC] App went to background'); - }, []); - - const handleESAdded = useCallback((tracks: any) => { - try { - logger.log('[VLC] ES Added - processing tracks...'); - processVlcTracks(tracks); - } catch (e) { - logger.error('[VLC] onESAdded error:', e); - logger.warn('[AndroidVideoPlayer][VLC] onESAdded parse error', e); - } - }, []); - - // Format VLC tracks to match RN Video format - raw version - const formatVlcTracks = useCallback((vlcTracks: Array<{id: number, name: string}>): VlcTrack[] => { - if (!Array.isArray(vlcTracks)) return []; - return vlcTracks.map(track => { - // Just extract basic language info if available, but keep the full name - let language = undefined; - let displayName = track.name || `Track ${track.id + 1}`; - - // Log the raw track data for debugging - if (__DEV__) { - logger.log(`[VLC] Raw track data:`, { id: track.id, name: track.name }); - } - - // Only extract language from brackets if present, but keep full name - const languageMatch = track.name?.match(/\[([^\]]+)\]/); - if (languageMatch && languageMatch[1]) { - language = languageMatch[1].trim(); - } - - return { - id: track.id, - name: displayName, // Show exactly what VLC provides - language: language - }; - }); - }, []); - - // Optimized VLC track processing function with reduced JSON operations - const processVlcTracks = useCallback((tracks: any) => { - if (!tracks) return; - - // Log raw VLC tracks data for debugging - if (__DEV__) { - logger.log(`[VLC] Raw tracks data:`, tracks); - } - - const { audio = [], subtitle = [] } = tracks; - - // Process audio tracks - if (Array.isArray(audio) && audio.length > 0) { - const formattedAudio = formatVlcTracks(audio); - if (__DEV__) { - logger.log(`[VLC] Audio tracks updated:`, formattedAudio.length); - } - } - - // Process subtitle tracks - if (Array.isArray(subtitle) && subtitle.length > 0) { - const formattedSubs = formatVlcTracks(subtitle); - if (__DEV__) { - logger.log(`[VLC] Subtitle tracks updated:`, formattedSubs.length); - } - } - - // Notify parent of track updates - onTracksUpdate({ audio, subtitle }); - }, [formatVlcTracks, onTracksUpdate]); - - // 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; - } - }, []); - - const processedSource = useMemo(() => processUrlForVLC(source), [source, processUrlForVLC]); - - if (!LibVlcPlayerViewComponent) { - return ( - - {/* VLC not available fallback */} - - ); - } - - return ( - - - - ); -}); - -VlcVideoPlayer.displayName = 'VlcVideoPlayer'; - -export default VlcVideoPlayer; diff --git a/src/components/player/android/components/VideoSurface.tsx b/src/components/player/android/components/VideoSurface.tsx index 0899e2e..2e05f7f 100644 --- a/src/components/player/android/components/VideoSurface.tsx +++ b/src/components/player/android/components/VideoSurface.tsx @@ -30,27 +30,6 @@ interface VideoSurfaceProps { onPinchGestureEvent: any; onPinchHandlerStateChange: any; screenDimensions: { width: number, height: number }; - - // Legacy props (kept for compatibility but unused with MPV) - useVLC?: boolean; - forceVlcRemount?: boolean; - headers?: any; - videoType?: any; - vlcSelectedAudioTrack?: number; - vlcSelectedSubtitleTrack?: number; - vlcRestoreTime?: number; - vlcKey?: string; - selectedAudioTrack?: any; - selectedTextTrack?: any; - useCustomSubtitles?: boolean; - onTracksUpdate?: (tracks: any) => void; - vlcPlayerRef?: any; - videoRef?: any; - vlcLoadedRef?: any; - customVideoStyles?: any; - loadStartAtRef?: any; - firstFrameAtRef?: any; - zoomScale?: number; onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void; } diff --git a/src/components/player/android/hooks/usePlayerControls.ts b/src/components/player/android/hooks/usePlayerControls.ts index 83bc421..3f38af2 100644 --- a/src/components/player/android/hooks/usePlayerControls.ts +++ b/src/components/player/android/hooks/usePlayerControls.ts @@ -7,8 +7,6 @@ const END_EPSILON = 0.3; export const usePlayerControls = ( mpvPlayerRef: any, - vlcPlayerRef: any, - useVLC: boolean, paused: boolean, setPaused: (paused: boolean) => void, currentTime: number, @@ -29,40 +27,31 @@ export const usePlayerControls = ( console.log('[usePlayerControls] seekToTime called:', { rawSeconds, timeInSeconds, - useVLC, hasMpvRef: !!mpvPlayerRef?.current, - hasVlcRef: !!vlcPlayerRef?.current, duration, isSeeking: isSeeking.current }); - if (useVLC) { - if (vlcPlayerRef.current && duration > 0) { - logger.log(`[usePlayerControls][VLC] Seeking to ${timeInSeconds}`); - vlcPlayerRef.current.seek(timeInSeconds); - } + // MPV Player + if (mpvPlayerRef.current && duration > 0) { + console.log(`[usePlayerControls][MPV] Seeking to ${timeInSeconds}`); + + isSeeking.current = true; + mpvPlayerRef.current.seek(timeInSeconds); + + // Reset seeking flag after a delay + setTimeout(() => { + if (isMounted.current) { + isSeeking.current = false; + } + }, 500); } else { - // MPV Player - if (mpvPlayerRef.current && duration > 0) { - console.log(`[usePlayerControls][MPV] Seeking to ${timeInSeconds}`); - - isSeeking.current = true; - mpvPlayerRef.current.seek(timeInSeconds); - - // Reset seeking flag after a delay - setTimeout(() => { - if (isMounted.current) { - isSeeking.current = false; - } - }, 500); - } else { - console.log('[usePlayerControls][MPV] Cannot seek - ref or duration invalid:', { - hasRef: !!mpvPlayerRef?.current, - duration - }); - } + console.log('[usePlayerControls][MPV] Cannot seek - ref or duration invalid:', { + hasRef: !!mpvPlayerRef?.current, + duration + }); } - }, [useVLC, duration, paused, setPaused, mpvPlayerRef, vlcPlayerRef, isSeeking, isMounted]); + }, [duration, paused, setPaused, mpvPlayerRef, isSeeking, isMounted]); const skip = useCallback((seconds: number) => { console.log('[usePlayerControls] skip called:', { seconds, currentTime, newTime: currentTime + seconds }); diff --git a/src/components/player/android/hooks/usePlayerTracks.ts b/src/components/player/android/hooks/usePlayerTracks.ts index 53711b2..9fb0479 100644 --- a/src/components/player/android/hooks/usePlayerTracks.ts +++ b/src/components/player/android/hooks/usePlayerTracks.ts @@ -7,14 +7,8 @@ interface Track { language?: string; } -export const usePlayerTracks = ( - useVLC: boolean, - vlcAudioTracks: Track[], - vlcSubtitleTracks: Track[], - vlcSelectedAudioTrack: number | undefined, - vlcSelectedSubtitleTrack: number | undefined -) => { - // React Native Video Tracks +export const usePlayerTracks = () => { + // Tracks from native player (MPV/RN-Video) const [rnVideoAudioTracks, setRnVideoAudioTracks] = useState([]); const [rnVideoTextTracks, setRnVideoTextTracks] = useState([]); @@ -22,31 +16,19 @@ export const usePlayerTracks = ( 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 Tracks (now just returns native tracks) + const ksAudioTracks = useMemo(() => rnVideoAudioTracks, [rnVideoAudioTracks]); + const ksTextTracks = useMemo(() => rnVideoTextTracks, [rnVideoTextTracks]); // Unified Selection const computedSelectedAudioTrack = useMemo(() => - useVLC - ? (vlcSelectedAudioTrack ?? null) - : (selectedAudioTrack?.type === 'index' && selectedAudioTrack?.value !== undefined - ? Number(selectedAudioTrack?.value) - : null), - [useVLC, vlcSelectedAudioTrack, selectedAudioTrack] + selectedAudioTrack?.type === 'index' && selectedAudioTrack?.value !== undefined + ? Number(selectedAudioTrack?.value) + : null, + [selectedAudioTrack] ); - const computedSelectedTextTrack = useMemo(() => - useVLC ? (vlcSelectedSubtitleTrack ?? -1) : selectedTextTrack, - [useVLC, vlcSelectedSubtitleTrack, selectedTextTrack] - ); + const computedSelectedTextTrack = useMemo(() => selectedTextTrack, [selectedTextTrack]); return { rnVideoAudioTracks, setRnVideoAudioTracks, diff --git a/src/components/player/android/hooks/useVlcPlayer.ts b/src/components/player/android/hooks/useVlcPlayer.ts deleted file mode 100644 index c354744..0000000 --- a/src/components/player/android/hooks/useVlcPlayer.ts +++ /dev/null @@ -1,148 +0,0 @@ -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/hooks/useCustomSubtitles.ts b/src/components/player/hooks/useCustomSubtitles.ts index 5def5c9..43e7bf6 100644 --- a/src/components/player/hooks/useCustomSubtitles.ts +++ b/src/components/player/hooks/useCustomSubtitles.ts @@ -2,13 +2,14 @@ * Shared Custom Subtitles Hook * Used by both Android (VLC) and iOS (KSPlayer) players */ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { DEFAULT_SUBTITLE_SIZE, SubtitleCue, SubtitleSegment, WyzieSubtitle } from '../utils/playerTypes'; +import { storageService } from '../../../services/storageService'; export const useCustomSubtitles = () => { // Data State @@ -32,11 +33,58 @@ export const useCustomSubtitles = () => { const [subtitleOutlineColor, setSubtitleOutlineColor] = useState('#000000'); const [subtitleOutlineWidth, setSubtitleOutlineWidth] = useState(4); const [subtitleAlign, setSubtitleAlign] = useState<'center' | 'left' | 'right'>('center'); - const [subtitleBottomOffset, setSubtitleBottomOffset] = useState(10); + const [subtitleBottomOffset, setSubtitleBottomOffset] = useState(20); const [subtitleLetterSpacing, setSubtitleLetterSpacing] = useState(0); const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState(1.2); const [subtitleOffsetSec, setSubtitleOffsetSec] = useState(0); + // Load subtitle settings on mount + useEffect(() => { + const loadSettings = async () => { + const settings = await storageService.getSubtitleSettings(); + if (settings) { + if (settings.subtitleSize !== undefined) setSubtitleSize(settings.subtitleSize); + if (settings.subtitleBackground !== undefined) setSubtitleBackground(settings.subtitleBackground); + if (settings.subtitleTextColor !== undefined) setSubtitleTextColor(settings.subtitleTextColor); + if (settings.subtitleBgOpacity !== undefined) setSubtitleBgOpacity(settings.subtitleBgOpacity); + if (settings.subtitleTextShadow !== undefined) setSubtitleTextShadow(settings.subtitleTextShadow); + if (settings.subtitleOutline !== undefined) setSubtitleOutline(settings.subtitleOutline); + if (settings.subtitleOutlineColor !== undefined) setSubtitleOutlineColor(settings.subtitleOutlineColor); + if (settings.subtitleOutlineWidth !== undefined) setSubtitleOutlineWidth(settings.subtitleOutlineWidth); + if (settings.subtitleAlign !== undefined) setSubtitleAlign(settings.subtitleAlign); + if (settings.subtitleBottomOffset !== undefined) setSubtitleBottomOffset(settings.subtitleBottomOffset); + if (settings.subtitleLetterSpacing !== undefined) setSubtitleLetterSpacing(settings.subtitleLetterSpacing); + if (settings.subtitleLineHeightMultiplier !== undefined) setSubtitleLineHeightMultiplier(settings.subtitleLineHeightMultiplier); + } + }; + loadSettings(); + }, []); + + // Save subtitle settings when they change + useEffect(() => { + const saveSettings = async () => { + await storageService.saveSubtitleSettings({ + subtitleSize, + subtitleBackground, + subtitleTextColor, + subtitleBgOpacity, + subtitleTextShadow, + subtitleOutline, + subtitleOutlineColor, + subtitleOutlineWidth, + subtitleAlign, + subtitleBottomOffset, + subtitleLetterSpacing, + subtitleLineHeightMultiplier, + }); + }; + saveSettings(); + }, [ + subtitleSize, subtitleBackground, subtitleTextColor, subtitleBgOpacity, + subtitleTextShadow, subtitleOutline, subtitleOutlineColor, subtitleOutlineWidth, + subtitleAlign, subtitleBottomOffset, subtitleLetterSpacing, subtitleLineHeightMultiplier + ]); + return { customSubtitles, setCustomSubtitles, currentSubtitle, setCurrentSubtitle, diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index ef1e395..f3e43e2 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -125,7 +125,6 @@ export type RootStackParamList = { streamProvider?: string; streamName?: string; headers?: { [key: string]: string }; - forceVlc?: boolean; id?: string; type?: string; episodeId?: string; @@ -146,7 +145,6 @@ export type RootStackParamList = { streamProvider?: string; streamName?: string; headers?: { [key: string]: string }; - forceVlc?: boolean; id?: string; type?: string; episodeId?: string; diff --git a/src/screens/DownloadsScreen.tsx b/src/screens/DownloadsScreen.tsx index 96710d3..2b20637 100644 --- a/src/screens/DownloadsScreen.tsx +++ b/src/screens/DownloadsScreen.tsx @@ -525,7 +525,6 @@ const DownloadsScreen: React.FC = () => { streamProvider: 'Downloads', streamName: item.providerName || 'Offline', headers: undefined, - forceVlc: Platform.OS === 'android' ? isMkv : false, id: item.contentId, // Use contentId (base ID) instead of compound id for progress tracking type: item.type, episodeId: episodeId, // Pass episodeId for series progress tracking diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index e27b4b3..6191ca1 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -821,7 +821,7 @@ export const StreamsScreen = () => { fetchIMDbRatings(); }, [type, id, currentEpisode?.season_number, currentEpisode?.episode_number]); - const navigateToPlayer = useCallback(async (stream: Stream, options?: { forceVlc?: boolean; headers?: Record }) => { + const navigateToPlayer = useCallback(async (stream: Stream, options?: { headers?: Record }) => { // Filter headers for Vidrock - only send essential headers // Filter headers for Vidrock - only send essential headers // Filter headers for Vidrock - only send essential headers @@ -859,9 +859,6 @@ export const StreamsScreen = () => { const streamName = stream.name || stream.title || 'Unnamed Stream'; const streamProvider = stream.addonId || stream.addonName || stream.name; - // Do NOT pre-force VLC. Let ExoPlayer try first; fallback occurs on decoder error in the player. - let forceVlc = !!options?.forceVlc; - // Save stream to cache for future use try { const episodeId = (type === 'series' || type === 'other') && selectedEpisode ? selectedEpisode : undefined; @@ -922,8 +919,6 @@ export const StreamsScreen = () => { streamName: streamName, // Use filtered headers for Vidrock compatibility headers: finalHeaders, - // Android will use this to choose VLC path; iOS ignores - forceVlc, id, type, episodeId: (type === 'series' || type === 'other') && selectedEpisode ? selectedEpisode : undefined, diff --git a/src/utils/playerSelection.ts b/src/utils/playerSelection.ts index 7f60ef5..81dbcda 100644 --- a/src/utils/playerSelection.ts +++ b/src/utils/playerSelection.ts @@ -9,7 +9,6 @@ import { isMkvStream } from './mkvDetection'; export interface PlayerSelectionOptions { uri: string; headers?: Record; - forceVlc?: boolean; platform?: typeof Platform.OS; } @@ -19,10 +18,9 @@ export interface PlayerSelectionOptions { export const shouldUseKSPlayer = ({ uri, headers, - forceVlc = false, platform = Platform.OS }: PlayerSelectionOptions): boolean => { - // Android always uses AndroidVideoPlayer (react-native-video) + // Android always uses AndroidVideoPlayer (MPV) if (platform === 'android') { return false; } From 18fa11fd88f0aedcf9f2ab257c747e8e78bf1059 Mon Sep 17 00:00:00 2001 From: tapframe Date: Tue, 23 Dec 2025 17:42:45 +0530 Subject: [PATCH 09/18] added header support --- .../main/java/com/nuvio/app/mpv/MPVView.kt | 34 ++++++++++++++++--- .../com/nuvio/app/mpv/MpvPlayerViewManager.kt | 17 ++++++++++ mpv-android | 1 + src/components/player/AndroidVideoPlayer.tsx | 1 + src/components/player/android/MpvPlayer.tsx | 2 ++ .../android/components/VideoSurface.tsx | 3 ++ 6 files changed, 53 insertions(+), 5 deletions(-) create mode 160000 mpv-android diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt index c983ccc..6f5727e 100644 --- a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt +++ b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt @@ -22,6 +22,7 @@ class MPVView @JvmOverloads constructor( private var pendingDataSource: String? = null private var isPaused: Boolean = true private var surface: Surface? = null + private var httpHeaders: Map? = null // Event listener for React Native var onLoadCallback: ((duration: Double, width: Int, height: Int) -> Unit)? = null @@ -51,6 +52,7 @@ class MPVView @JvmOverloads constructor( // If a data source was set before surface was ready, load it now pendingDataSource?.let { url -> + applyHttpHeaders() loadFile(url) pendingDataSource = null } @@ -93,7 +95,7 @@ class MPVView @JvmOverloads constructor( // Hardware decoding - use mediacodec-copy to allow subtitle overlay // 'mediacodec-copy' copies frames to CPU memory which enables subtitle blending - MPVLib.setOptionString("hwdec", "mediacodec-copy") + MPVLib.setOptionString("hwdec", "auto") MPVLib.setOptionString("hwdec-codecs", "all") // Audio output @@ -105,6 +107,9 @@ class MPVView @JvmOverloads constructor( MPVLib.setOptionString("cache", "yes") MPVLib.setOptionString("cache-secs", "30") + // Network options + MPVLib.setOptionString("network-timeout", "60") // 60 second timeout + // Subtitle configuration - CRITICAL for Android MPVLib.setOptionString("sub-auto", "fuzzy") // Auto-load subtitles MPVLib.setOptionString("sub-visibility", "yes") // Make subtitles visible by default @@ -155,7 +160,7 @@ class MPVView @JvmOverloads constructor( val MPV_FORMAT_DOUBLE = 5 MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE) - MPVLib.observeProperty("duration", MPV_FORMAT_DOUBLE) + MPVLib.observeProperty("duration/full", MPV_FORMAT_DOUBLE) // Use /full for complete HLS duration MPVLib.observeProperty("pause", MPV_FORMAT_FLAG) MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG) MPVLib.observeProperty("eof-reached", MPV_FORMAT_FLAG) @@ -179,12 +184,31 @@ class MPVView @JvmOverloads constructor( fun setDataSource(url: String) { if (isMpvInitialized) { + // Apply headers before loading the file + applyHttpHeaders() loadFile(url) } else { pendingDataSource = url } } + fun setHeaders(headers: Map?) { + httpHeaders = headers + Log.d(TAG, "Headers set: $headers") + } + + private fun applyHttpHeaders() { + httpHeaders?.let { headers -> + if (headers.isNotEmpty()) { + // Format headers for MPV: comma-separated "Key: Value" pairs + val headerList = headers.map { (key, value) -> "$key: $value" } + val headerString = headerList.joinToString(",") + Log.d(TAG, "Applying HTTP headers: $headerString") + MPVLib.setOptionString("http-header-fields", headerString) + } + } + } + fun setPaused(paused: Boolean) { isPaused = paused if (isMpvInitialized) { @@ -341,10 +365,10 @@ class MPVView @JvmOverloads constructor( Log.d(TAG, "Property $property = $value (Double)") when (property) { "time-pos" -> { - val duration = MPVLib.getPropertyDouble("duration") ?: 0.0 + val duration = MPVLib.getPropertyDouble("duration/full") ?: MPVLib.getPropertyDouble("duration") ?: 0.0 onProgressCallback?.invoke(value, duration) } - "duration" -> { + "duration/full", "duration" -> { val width = MPVLib.getPropertyInt("width") ?: 0 val height = MPVLib.getPropertyInt("height") ?: 0 onLoadCallback?.invoke(value, width, height) @@ -384,7 +408,7 @@ class MPVView @JvmOverloads constructor( Log.d(TAG, "MPV_EVENT_END_FILE") // Heuristic: If duration is effectively 0 at end of file, it's a load error - val duration = MPVLib.getPropertyDouble("duration") ?: 0.0 + val duration = MPVLib.getPropertyDouble("duration/full") ?: MPVLib.getPropertyDouble("duration") ?: 0.0 val timePos = MPVLib.getPropertyDouble("time-pos") ?: 0.0 val eofReached = MPVLib.getPropertyBoolean("eof-reached") ?: false diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt b/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt index db5426e..27d4852 100644 --- a/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt +++ b/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt @@ -163,4 +163,21 @@ class MpvPlayerViewManager( fun setResizeMode(view: MPVView, resizeMode: String?) { view.setResizeMode(resizeMode ?: "contain") } + + @ReactProp(name = "headers") + fun setHeaders(view: MPVView, headers: com.facebook.react.bridge.ReadableMap?) { + if (headers != null) { + val headerMap = mutableMapOf() + val iterator = headers.keySetIterator() + while (iterator.hasNextKey()) { + val key = iterator.nextKey() + headers.getString(key)?.let { value -> + headerMap[key] = value + } + } + view.setHeaders(headerMap) + } else { + view.setHeaders(null) + } + } } diff --git a/mpv-android b/mpv-android new file mode 160000 index 0000000..118cd1e --- /dev/null +++ b/mpv-android @@ -0,0 +1 @@ +Subproject commit 118cd1ed3d498265e44230e5dbb015bdd59f9dad diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 9eea59a..568a9c4 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -487,6 +487,7 @@ const AndroidVideoPlayer: React.FC = () => { ((props, ref) => { ref={nativeRef} style={[styles.container, props.style]} source={props.source} + headers={props.headers} paused={props.paused ?? true} volume={props.volume ?? 1.0} rate={props.rate ?? 1.0} diff --git a/src/components/player/android/components/VideoSurface.tsx b/src/components/player/android/components/VideoSurface.tsx index 2e05f7f..c6d0d86 100644 --- a/src/components/player/android/components/VideoSurface.tsx +++ b/src/components/player/android/components/VideoSurface.tsx @@ -7,6 +7,7 @@ import { ResizeModeType } from '../../utils/playerTypes'; interface VideoSurfaceProps { processedStreamUrl: string; + headers?: { [key: string]: string }; volume: number; playbackSpeed: number; resizeMode: ResizeModeType; @@ -35,6 +36,7 @@ interface VideoSurfaceProps { export const VideoSurface: React.FC = ({ processedStreamUrl, + headers, volume, playbackSpeed, resizeMode, @@ -100,6 +102,7 @@ export const VideoSurface: React.FC = ({ Date: Tue, 23 Dec 2025 17:48:52 +0530 Subject: [PATCH 10/18] config added mpv files after prebuild --- app.json | 3 +- ios/Nuvio.xcodeproj/project.pbxproj | 6 +- ios/Podfile.lock | 6 + plugins/mpv-bridge/android/mpv/MPVView.kt | 427 ++++++++++++++++++ plugins/mpv-bridge/android/mpv/MpvPackage.kt | 16 + .../android/mpv/MpvPlayerViewManager.kt | 183 ++++++++ plugins/mpv-bridge/withMpvBridge.js | 94 ++++ 7 files changed, 731 insertions(+), 4 deletions(-) create mode 100644 plugins/mpv-bridge/android/mpv/MPVView.kt create mode 100644 plugins/mpv-bridge/android/mpv/MpvPackage.kt create mode 100644 plugins/mpv-bridge/android/mpv/MpvPlayerViewManager.kt create mode 100644 plugins/mpv-bridge/withMpvBridge.js diff --git a/app.json b/app.json index b606a16..0787269 100644 --- a/app.json +++ b/app.json @@ -97,7 +97,8 @@ "receiverAppId": "CC1AD845", "iosStartDiscoveryAfterFirstTapOnCastButton": true } - ] + ], + "./plugins/mpv-bridge/withMpvBridge" ], "updates": { "enabled": true, diff --git a/ios/Nuvio.xcodeproj/project.pbxproj b/ios/Nuvio.xcodeproj/project.pbxproj index c83f212..cae37b9 100644 --- a/ios/Nuvio.xcodeproj/project.pbxproj +++ b/ios/Nuvio.xcodeproj/project.pbxproj @@ -475,7 +475,7 @@ ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; - PRODUCT_NAME = "Nuvio"; + PRODUCT_NAME = Nuvio; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -506,8 +506,8 @@ "-lc++", ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; - PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app"; - PRODUCT_NAME = "Nuvio"; + PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; + PRODUCT_NAME = Nuvio; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ed449bc..b0ab03c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -213,6 +213,8 @@ PODS: - ExpoModulesCore - ExpoBrightness (14.0.8): - ExpoModulesCore + - ExpoClipboard (8.0.8): + - ExpoModulesCore - ExpoCrypto (15.0.8): - ExpoModulesCore - ExpoDevice (8.0.10): @@ -2749,6 +2751,7 @@ DEPENDENCIES: - ExpoAsset (from `../node_modules/expo-asset/ios`) - ExpoBlur (from `../node_modules/expo-blur/ios`) - ExpoBrightness (from `../node_modules/expo-brightness/ios`) + - ExpoClipboard (from `../node_modules/expo-clipboard/ios`) - ExpoCrypto (from `../node_modules/expo-crypto/ios`) - ExpoDevice (from `../node_modules/expo-device/ios`) - ExpoDocumentPicker (from `../node_modules/expo-document-picker/ios`) @@ -2912,6 +2915,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-blur/ios" ExpoBrightness: :path: "../node_modules/expo-brightness/ios" + ExpoClipboard: + :path: "../node_modules/expo-clipboard/ios" ExpoCrypto: :path: "../node_modules/expo-crypto/ios" ExpoDevice: @@ -3171,6 +3176,7 @@ SPEC CHECKSUMS: ExpoAsset: 23a958e97d3d340919fe6774db35d563241e6c03 ExpoBlur: b90747a3f22a8b6ceffd9cb0dc41a4184efdc656 ExpoBrightness: 46c980463e8a54b9ce77f923c4bff0bb0c9526e0 + ExpoClipboard: b36b287d8356887844bb08ed5c84b5979bb4dd1e ExpoCrypto: b6105ebaa15d6b38a811e71e43b52cd934945322 ExpoDevice: 6327c3c200816795708885adf540d26ecab83d1a ExpoDocumentPicker: 7cd9e71a0f66fb19eb0a586d6f26eee1284692e0 diff --git a/plugins/mpv-bridge/android/mpv/MPVView.kt b/plugins/mpv-bridge/android/mpv/MPVView.kt new file mode 100644 index 0000000..6f5727e --- /dev/null +++ b/plugins/mpv-bridge/android/mpv/MPVView.kt @@ -0,0 +1,427 @@ +package com.nuvio.app.mpv + +import android.content.Context +import android.graphics.SurfaceTexture +import android.util.AttributeSet +import android.util.Log +import android.view.Surface +import android.view.TextureView +import dev.jdtech.mpv.MPVLib + +class MPVView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : TextureView(context, attrs, defStyleAttr), TextureView.SurfaceTextureListener, MPVLib.EventObserver { + + companion object { + private const val TAG = "MPVView" + } + + private var isMpvInitialized = false + private var pendingDataSource: String? = null + private var isPaused: Boolean = true + private var surface: Surface? = null + private var httpHeaders: Map? = null + + // Event listener for React Native + var onLoadCallback: ((duration: Double, width: Int, height: Int) -> Unit)? = null + var onProgressCallback: ((position: Double, duration: Double) -> Unit)? = null + var onEndCallback: (() -> Unit)? = null + var onErrorCallback: ((message: String) -> Unit)? = null + var onTracksChangedCallback: ((audioTracks: List>, subtitleTracks: List>) -> Unit)? = null + + init { + surfaceTextureListener = this + isOpaque = false + } + + override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) { + Log.d(TAG, "Surface texture available: ${width}x${height}") + try { + surface = Surface(surfaceTexture) + + MPVLib.create(context.applicationContext) + initOptions() + MPVLib.init() + MPVLib.attachSurface(surface!!) + MPVLib.addObserver(this) + MPVLib.setPropertyString("android-surface-size", "${width}x${height}") + observeProperties() + isMpvInitialized = true + + // If a data source was set before surface was ready, load it now + pendingDataSource?.let { url -> + applyHttpHeaders() + loadFile(url) + pendingDataSource = null + } + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize MPV", e) + onErrorCallback?.invoke("MPV initialization failed: ${e.message}") + } + } + + override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) { + Log.d(TAG, "Surface texture size changed: ${width}x${height}") + if (isMpvInitialized) { + MPVLib.setPropertyString("android-surface-size", "${width}x${height}") + } + } + + override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { + Log.d(TAG, "Surface texture destroyed") + if (isMpvInitialized) { + MPVLib.removeObserver(this) + MPVLib.detachSurface() + MPVLib.destroy() + isMpvInitialized = false + } + surface?.release() + surface = null + return true + } + + override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) { + // Called when the SurfaceTexture is updated via updateTexImage() + } + + private fun initOptions() { + // Mobile-optimized profile + MPVLib.setOptionString("profile", "fast") + MPVLib.setOptionString("vo", "gpu") + MPVLib.setOptionString("gpu-context", "android") + MPVLib.setOptionString("opengl-es", "yes") + + // Hardware decoding - use mediacodec-copy to allow subtitle overlay + // 'mediacodec-copy' copies frames to CPU memory which enables subtitle blending + MPVLib.setOptionString("hwdec", "auto") + MPVLib.setOptionString("hwdec-codecs", "all") + + // Audio output + MPVLib.setOptionString("ao", "audiotrack,opensles") + + // Network caching for streaming + MPVLib.setOptionString("demuxer-max-bytes", "67108864") // 64MB + MPVLib.setOptionString("demuxer-max-back-bytes", "33554432") // 32MB + MPVLib.setOptionString("cache", "yes") + MPVLib.setOptionString("cache-secs", "30") + + // Network options + MPVLib.setOptionString("network-timeout", "60") // 60 second timeout + + // Subtitle configuration - CRITICAL for Android + MPVLib.setOptionString("sub-auto", "fuzzy") // Auto-load subtitles + MPVLib.setOptionString("sub-visibility", "yes") // Make subtitles visible by default + MPVLib.setOptionString("sub-font-size", "48") // Larger font size for mobile readability + MPVLib.setOptionString("sub-pos", "95") // Position at bottom (0-100, 100 = very bottom) + MPVLib.setOptionString("sub-color", "#FFFFFFFF") // White color + MPVLib.setOptionString("sub-border-size", "3") // Thicker border for readability + MPVLib.setOptionString("sub-border-color", "#FF000000") // Black border + MPVLib.setOptionString("sub-shadow-offset", "2") // Add shadow for better visibility + MPVLib.setOptionString("sub-shadow-color", "#80000000") // Semi-transparent black shadow + + // Font configuration - point to Android system fonts for all language support + MPVLib.setOptionString("osd-fonts-dir", "/system/fonts") + MPVLib.setOptionString("sub-fonts-dir", "/system/fonts") + MPVLib.setOptionString("sub-font", "Roboto") // Default fallback font + // Allow embedded fonts in ASS/SSA but fallback to system fonts + MPVLib.setOptionString("embeddedfonts", "yes") + + // Language/encoding support for various subtitle formats + MPVLib.setOptionString("sub-codepage", "auto") // Auto-detect encoding (supports UTF-8, Latin, CJK, etc.) + + MPVLib.setOptionString("osc", "no") // Disable on screen controller + MPVLib.setOptionString("osd-level", "1") + + // Critical for subtitle rendering on Android GPU + // blend-subtitles=no lets the GPU renderer handle subtitle overlay properly + MPVLib.setOptionString("blend-subtitles", "no") + MPVLib.setOptionString("sub-use-margins", "no") + // Use 'scale' to allow ASS styling but with our scale and font overrides + // This preserves styled subtitles while having font fallbacks + MPVLib.setOptionString("sub-ass-override", "scale") + MPVLib.setOptionString("sub-scale", "1.0") + MPVLib.setOptionString("sub-fix-timing", "yes") // Fix timing for SRT subtitles + + // Force subtitle rendering + MPVLib.setOptionString("sid", "auto") // Auto-select subtitle track + + // Disable terminal/input + MPVLib.setOptionString("terminal", "no") + MPVLib.setOptionString("input-default-bindings", "no") + } + + private fun observeProperties() { + // MPV format constants (from MPVLib source) + val MPV_FORMAT_NONE = 0 + val MPV_FORMAT_FLAG = 3 + val MPV_FORMAT_INT64 = 4 + val MPV_FORMAT_DOUBLE = 5 + + MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE) + MPVLib.observeProperty("duration/full", MPV_FORMAT_DOUBLE) // Use /full for complete HLS duration + MPVLib.observeProperty("pause", MPV_FORMAT_FLAG) + MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG) + MPVLib.observeProperty("eof-reached", MPV_FORMAT_FLAG) + MPVLib.observeProperty("video-params/aspect", MPV_FORMAT_DOUBLE) + MPVLib.observeProperty("width", MPV_FORMAT_INT64) + MPVLib.observeProperty("height", MPV_FORMAT_INT64) + MPVLib.observeProperty("track-list", MPV_FORMAT_NONE) + + // Observe subtitle properties for debugging + MPVLib.observeProperty("sid", MPV_FORMAT_INT64) + MPVLib.observeProperty("sub-visibility", MPV_FORMAT_FLAG) + MPVLib.observeProperty("sub-text", MPV_FORMAT_NONE) + } + + private fun loadFile(url: String) { + Log.d(TAG, "Loading file: $url") + MPVLib.command(arrayOf("loadfile", url)) + } + + // Public API + + fun setDataSource(url: String) { + if (isMpvInitialized) { + // Apply headers before loading the file + applyHttpHeaders() + loadFile(url) + } else { + pendingDataSource = url + } + } + + fun setHeaders(headers: Map?) { + httpHeaders = headers + Log.d(TAG, "Headers set: $headers") + } + + private fun applyHttpHeaders() { + httpHeaders?.let { headers -> + if (headers.isNotEmpty()) { + // Format headers for MPV: comma-separated "Key: Value" pairs + val headerList = headers.map { (key, value) -> "$key: $value" } + val headerString = headerList.joinToString(",") + Log.d(TAG, "Applying HTTP headers: $headerString") + MPVLib.setOptionString("http-header-fields", headerString) + } + } + } + + fun setPaused(paused: Boolean) { + isPaused = paused + if (isMpvInitialized) { + MPVLib.setPropertyBoolean("pause", paused) + } + } + + fun seekTo(positionSeconds: Double) { + Log.d(TAG, "seekTo called: positionSeconds=$positionSeconds, isMpvInitialized=$isMpvInitialized") + if (isMpvInitialized) { + Log.d(TAG, "Executing MPV seek command: seek $positionSeconds absolute") + MPVLib.command(arrayOf("seek", positionSeconds.toString(), "absolute")) + } + } + + fun setSpeed(speed: Double) { + if (isMpvInitialized) { + MPVLib.setPropertyDouble("speed", speed) + } + } + + fun setVolume(volume: Double) { + if (isMpvInitialized) { + // MPV volume is 0-100 + MPVLib.setPropertyDouble("volume", volume * 100.0) + } + } + + fun setAudioTrack(trackId: Int) { + if (isMpvInitialized) { + if (trackId == -1) { + MPVLib.setPropertyString("aid", "no") + } else { + MPVLib.setPropertyInt("aid", trackId) + } + } + } + + fun setSubtitleTrack(trackId: Int) { + Log.d(TAG, "setSubtitleTrack called: trackId=$trackId, isMpvInitialized=$isMpvInitialized") + if (isMpvInitialized) { + if (trackId == -1) { + Log.d(TAG, "Disabling subtitles (sid=no)") + MPVLib.setPropertyString("sid", "no") + MPVLib.setPropertyString("sub-visibility", "no") + } else { + Log.d(TAG, "Setting subtitle track to: $trackId") + MPVLib.setPropertyInt("sid", trackId) + // Ensure subtitles are visible + MPVLib.setPropertyString("sub-visibility", "yes") + + // Debug: Verify the subtitle was set correctly + val currentSid = MPVLib.getPropertyInt("sid") + val subVisibility = MPVLib.getPropertyString("sub-visibility") + val subDelay = MPVLib.getPropertyDouble("sub-delay") + val subScale = MPVLib.getPropertyDouble("sub-scale") + Log.d(TAG, "After setting - sid=$currentSid, sub-visibility=$subVisibility, sub-delay=$subDelay, sub-scale=$subScale") + } + } + } + + fun setResizeMode(mode: String) { + Log.d(TAG, "setResizeMode called: mode=$mode, isMpvInitialized=$isMpvInitialized") + if (isMpvInitialized) { + when (mode) { + "contain" -> { + // Letterbox - show entire video with black bars + MPVLib.setPropertyDouble("panscan", 0.0) + MPVLib.setPropertyString("keepaspect", "yes") + } + "cover" -> { + // Fill/crop - zoom to fill, cropping edges + MPVLib.setPropertyDouble("panscan", 1.0) + MPVLib.setPropertyString("keepaspect", "yes") + } + "stretch" -> { + // Stretch - disable aspect ratio + MPVLib.setPropertyDouble("panscan", 0.0) + MPVLib.setPropertyString("keepaspect", "no") + } + else -> { + // Default to contain + MPVLib.setPropertyDouble("panscan", 0.0) + MPVLib.setPropertyString("keepaspect", "yes") + } + } + } + } + + // MPVLib.EventObserver implementation + + override fun eventProperty(property: String) { + Log.d(TAG, "Property changed: $property") + when (property) { + "track-list" -> { + // Parse track list and notify React Native + parseAndSendTracks() + } + } + } + + private fun parseAndSendTracks() { + try { + val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0 + Log.d(TAG, "Track count: $trackCount") + + val audioTracks = mutableListOf>() + val subtitleTracks = mutableListOf>() + + for (i in 0 until trackCount) { + val type = MPVLib.getPropertyString("track-list/$i/type") ?: continue + val id = MPVLib.getPropertyInt("track-list/$i/id") ?: continue + val title = MPVLib.getPropertyString("track-list/$i/title") ?: "" + val lang = MPVLib.getPropertyString("track-list/$i/lang") ?: "" + val codec = MPVLib.getPropertyString("track-list/$i/codec") ?: "" + + val trackName = when { + title.isNotEmpty() -> title + lang.isNotEmpty() -> lang.uppercase() + else -> "Track $id" + } + + val track = mapOf( + "id" to id, + "name" to trackName, + "language" to lang, + "codec" to codec + ) + + when (type) { + "audio" -> { + Log.d(TAG, "Found audio track: $track") + audioTracks.add(track) + } + "sub" -> { + Log.d(TAG, "Found subtitle track: $track") + subtitleTracks.add(track) + } + } + } + + Log.d(TAG, "Sending tracks - Audio: ${audioTracks.size}, Subtitles: ${subtitleTracks.size}") + onTracksChangedCallback?.invoke(audioTracks, subtitleTracks) + } catch (e: Exception) { + Log.e(TAG, "Error parsing tracks", e) + } + } + + override fun eventProperty(property: String, value: Long) { + Log.d(TAG, "Property $property = $value (Long)") + } + + override fun eventProperty(property: String, value: Double) { + Log.d(TAG, "Property $property = $value (Double)") + when (property) { + "time-pos" -> { + val duration = MPVLib.getPropertyDouble("duration/full") ?: MPVLib.getPropertyDouble("duration") ?: 0.0 + onProgressCallback?.invoke(value, duration) + } + "duration/full", "duration" -> { + val width = MPVLib.getPropertyInt("width") ?: 0 + val height = MPVLib.getPropertyInt("height") ?: 0 + onLoadCallback?.invoke(value, width, height) + } + } + } + + override fun eventProperty(property: String, value: Boolean) { + Log.d(TAG, "Property $property = $value (Boolean)") + when (property) { + "eof-reached" -> { + if (value) { + onEndCallback?.invoke() + } + } + } + } + + override fun eventProperty(property: String, value: String) { + Log.d(TAG, "Property $property = $value (String)") + } + + override fun event(eventId: Int) { + Log.d(TAG, "Event: $eventId") + // MPV event constants (from MPVLib source) + val MPV_EVENT_FILE_LOADED = 8 + val MPV_EVENT_END_FILE = 7 + + when (eventId) { + MPV_EVENT_FILE_LOADED -> { + // File is loaded, start playback if not paused + if (!isPaused) { + MPVLib.setPropertyBoolean("pause", false) + } + } + MPV_EVENT_END_FILE -> { + Log.d(TAG, "MPV_EVENT_END_FILE") + + // Heuristic: If duration is effectively 0 at end of file, it's a load error + val duration = MPVLib.getPropertyDouble("duration/full") ?: MPVLib.getPropertyDouble("duration") ?: 0.0 + val timePos = MPVLib.getPropertyDouble("time-pos") ?: 0.0 + val eofReached = MPVLib.getPropertyBoolean("eof-reached") ?: false + + Log.d(TAG, "End stats - Duration: $duration, Time: $timePos, EOF: $eofReached") + + if (duration < 1.0 && !eofReached) { + val customError = "Unable to play media. Source may be unreachable." + Log.e(TAG, "Playback error detected (heuristic): $customError") + onErrorCallback?.invoke(customError) + } else { + onEndCallback?.invoke() + } + } + } + } +} diff --git a/plugins/mpv-bridge/android/mpv/MpvPackage.kt b/plugins/mpv-bridge/android/mpv/MpvPackage.kt new file mode 100644 index 0000000..49c3dd2 --- /dev/null +++ b/plugins/mpv-bridge/android/mpv/MpvPackage.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.mpv + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class MpvPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return emptyList() + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return listOf(MpvPlayerViewManager(reactContext)) + } +} diff --git a/plugins/mpv-bridge/android/mpv/MpvPlayerViewManager.kt b/plugins/mpv-bridge/android/mpv/MpvPlayerViewManager.kt new file mode 100644 index 0000000..27d4852 --- /dev/null +++ b/plugins/mpv-bridge/android/mpv/MpvPlayerViewManager.kt @@ -0,0 +1,183 @@ +package com.nuvio.app.mpv + +import android.graphics.Color +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.common.MapBuilder +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.uimanager.events.RCTEventEmitter + +class MpvPlayerViewManager( + private val reactContext: ReactApplicationContext +) : SimpleViewManager() { + + companion object { + const val REACT_CLASS = "MpvPlayer" + + // Commands + const val COMMAND_SEEK = 1 + const val COMMAND_SET_AUDIO_TRACK = 2 + const val COMMAND_SET_SUBTITLE_TRACK = 3 + } + + override fun getName(): String = REACT_CLASS + + override fun createViewInstance(context: ThemedReactContext): MPVView { + val view = MPVView(context) + // Note: Do NOT set background color - it will block the SurfaceView content + + // Set up event callbacks + view.onLoadCallback = { duration, width, height -> + val event = Arguments.createMap().apply { + putDouble("duration", duration) + putInt("width", width) + putInt("height", height) + } + sendEvent(context, view.id, "onLoad", event) + } + + view.onProgressCallback = { position, duration -> + val event = Arguments.createMap().apply { + putDouble("currentTime", position) + putDouble("duration", duration) + } + sendEvent(context, view.id, "onProgress", event) + } + + view.onEndCallback = { + sendEvent(context, view.id, "onEnd", Arguments.createMap()) + } + + view.onErrorCallback = { message -> + val event = Arguments.createMap().apply { + putString("error", message) + } + sendEvent(context, view.id, "onError", event) + } + + view.onTracksChangedCallback = { audioTracks, subtitleTracks -> + val event = Arguments.createMap().apply { + val audioArray = Arguments.createArray() + audioTracks.forEach { track -> + val trackMap = Arguments.createMap().apply { + putInt("id", track["id"] as Int) + putString("name", track["name"] as String) + putString("language", track["language"] as String) + putString("codec", track["codec"] as String) + } + audioArray.pushMap(trackMap) + } + putArray("audioTracks", audioArray) + + val subtitleArray = Arguments.createArray() + subtitleTracks.forEach { track -> + val trackMap = Arguments.createMap().apply { + putInt("id", track["id"] as Int) + putString("name", track["name"] as String) + putString("language", track["language"] as String) + putString("codec", track["codec"] as String) + } + subtitleArray.pushMap(trackMap) + } + putArray("subtitleTracks", subtitleArray) + } + sendEvent(context, view.id, "onTracksChanged", event) + } + + return view + } + + private fun sendEvent(context: ThemedReactContext, viewId: Int, eventName: String, params: com.facebook.react.bridge.WritableMap) { + context.getJSModule(RCTEventEmitter::class.java) + .receiveEvent(viewId, eventName, params) + } + + override fun getExportedCustomBubblingEventTypeConstants(): Map { + return MapBuilder.builder() + .put("onLoad", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onLoad"))) + .put("onProgress", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onProgress"))) + .put("onEnd", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onEnd"))) + .put("onError", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onError"))) + .put("onTracksChanged", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onTracksChanged"))) + .build() + } + + override fun getCommandsMap(): Map { + return MapBuilder.of( + "seek", COMMAND_SEEK, + "setAudioTrack", COMMAND_SET_AUDIO_TRACK, + "setSubtitleTrack", COMMAND_SET_SUBTITLE_TRACK + ) + } + + override fun receiveCommand(view: MPVView, commandId: String?, args: ReadableArray?) { + android.util.Log.d("MpvPlayerViewManager", "receiveCommand: $commandId, args: $args") + when (commandId) { + "seek" -> { + val position = args?.getDouble(0) + android.util.Log.d("MpvPlayerViewManager", "Seek command received: position=$position") + position?.let { view.seekTo(it) } + } + "setAudioTrack" -> { + args?.getInt(0)?.let { view.setAudioTrack(it) } + } + "setSubtitleTrack" -> { + args?.getInt(0)?.let { view.setSubtitleTrack(it) } + } + } + } + + // React Props + + @ReactProp(name = "source") + fun setSource(view: MPVView, source: String?) { + source?.let { view.setDataSource(it) } + } + + @ReactProp(name = "paused") + fun setPaused(view: MPVView, paused: Boolean) { + view.setPaused(paused) + } + + @ReactProp(name = "volume", defaultFloat = 1.0f) + fun setVolume(view: MPVView, volume: Float) { + view.setVolume(volume.toDouble()) + } + + @ReactProp(name = "rate", defaultFloat = 1.0f) + fun setRate(view: MPVView, rate: Float) { + view.setSpeed(rate.toDouble()) + } + + // Handle backgroundColor prop to prevent crash from React Native style system + @ReactProp(name = "backgroundColor", customType = "Color") + fun setBackgroundColor(view: MPVView, color: Int?) { + // Intentionally ignoring - background color would block the TextureView content + // Leave the view transparent + } + + @ReactProp(name = "resizeMode") + fun setResizeMode(view: MPVView, resizeMode: String?) { + view.setResizeMode(resizeMode ?: "contain") + } + + @ReactProp(name = "headers") + fun setHeaders(view: MPVView, headers: com.facebook.react.bridge.ReadableMap?) { + if (headers != null) { + val headerMap = mutableMapOf() + val iterator = headers.keySetIterator() + while (iterator.hasNextKey()) { + val key = iterator.nextKey() + headers.getString(key)?.let { value -> + headerMap[key] = value + } + } + view.setHeaders(headerMap) + } else { + view.setHeaders(null) + } + } +} diff --git a/plugins/mpv-bridge/withMpvBridge.js b/plugins/mpv-bridge/withMpvBridge.js new file mode 100644 index 0000000..c898c61 --- /dev/null +++ b/plugins/mpv-bridge/withMpvBridge.js @@ -0,0 +1,94 @@ +const { withDangerousMod, withMainApplication, withMainActivity } = require('@expo/config-plugins'); +const fs = require('fs'); +const path = require('path'); + +/** + * Copy MPV native files to android project + */ +function copyMpvFiles(projectRoot) { + const sourceDir = path.join(projectRoot, 'plugins', 'mpv-bridge', 'android', 'mpv'); + const destDir = path.join(projectRoot, 'android', 'app', 'src', 'main', 'java', 'com', 'nuvio', 'app', 'mpv'); + + // Create destination directory if it doesn't exist + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + + // Copy all files from source to destination + if (fs.existsSync(sourceDir)) { + const files = fs.readdirSync(sourceDir); + files.forEach(file => { + const srcFile = path.join(sourceDir, file); + const destFile = path.join(destDir, file); + if (fs.statSync(srcFile).isFile()) { + fs.copyFileSync(srcFile, destFile); + console.log(`[mpv-bridge] Copied ${file} to android project`); + } + }); + } +} + +/** + * Modify MainApplication.kt to include MpvPackage + */ +function withMpvMainApplication(config) { + return withMainApplication(config, async (config) => { + let contents = config.modResults.contents; + + // Add import for MpvPackage + const mpvImport = 'import com.nuvio.app.mpv.MpvPackage'; + if (!contents.includes(mpvImport)) { + // Add import after the last import statement + const lastImportIndex = contents.lastIndexOf('import '); + const endOfLastImport = contents.indexOf('\n', lastImportIndex); + contents = contents.slice(0, endOfLastImport + 1) + mpvImport + '\n' + contents.slice(endOfLastImport + 1); + } + + // Add MpvPackage to the packages list + const packagesPattern = /override fun getPackages\(\): List \{[\s\S]*?return PackageList\(this\)\.packages\.apply \{/; + if (contents.match(packagesPattern) && !contents.includes('MpvPackage()')) { + contents = contents.replace( + packagesPattern, + (match) => match + '\n add(MpvPackage())' + ); + } + + config.modResults.contents = contents; + return config; + }); +} + +/** + * Modify MainActivity.kt to handle MPV lifecycle if needed + */ +function withMpvMainActivity(config) { + return withMainActivity(config, async (config) => { + // Currently no modifications needed for MainActivity + // But this hook is available for future enhancements + return config; + }); +} + +/** + * Main plugin function + */ +function withMpvBridge(config) { + // Copy native files during prebuild + config = withDangerousMod(config, [ + 'android', + async (config) => { + copyMpvFiles(config.modRequest.projectRoot); + return config; + }, + ]); + + // Modify MainApplication to register the package + config = withMpvMainApplication(config); + + // Modify MainActivity if needed + config = withMpvMainActivity(config); + + return config; +} + +module.exports = withMpvBridge; From 6855a89792cc6149d22fc1bfeedb45d30d3bcc3e Mon Sep 17 00:00:00 2001 From: tapframe Date: Tue, 23 Dec 2025 21:31:10 +0530 Subject: [PATCH 11/18] fixed header issues --- .../java/com/nuvio/app/MainApplication.kt | 1 + .../main/java/com/nuvio/app/mpv/MPVView.kt | 48 ++++++++++++++----- app.json | 7 --- 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/android/app/src/main/java/com/nuvio/app/MainApplication.kt b/android/app/src/main/java/com/nuvio/app/MainApplication.kt index 9e0bf7b..497e21a 100644 --- a/android/app/src/main/java/com/nuvio/app/MainApplication.kt +++ b/android/app/src/main/java/com/nuvio/app/MainApplication.kt @@ -15,6 +15,7 @@ import com.facebook.react.defaults.DefaultReactNativeHost import expo.modules.ApplicationLifecycleDispatcher import expo.modules.ReactNativeHostWrapper +import com.nuvio.app.mpv.MpvPackage class MainApplication : Application(), ReactApplication { diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt index 6f5727e..ebaf8d0 100644 --- a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt +++ b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt @@ -51,8 +51,8 @@ class MPVView @JvmOverloads constructor( isMpvInitialized = true // If a data source was set before surface was ready, load it now + // Headers are already applied in initOptions() before init() pendingDataSource?.let { url -> - applyHttpHeaders() loadFile(url) pendingDataSource = null } @@ -93,9 +93,11 @@ class MPVView @JvmOverloads constructor( MPVLib.setOptionString("gpu-context", "android") MPVLib.setOptionString("opengl-es", "yes") - // Hardware decoding - use mediacodec-copy to allow subtitle overlay - // 'mediacodec-copy' copies frames to CPU memory which enables subtitle blending - MPVLib.setOptionString("hwdec", "auto") + // Hardware decoding configuration + // NOTE: On emulator, mediacodec can cause freezes due to slow GPU translation + // Using 'no' for software decoding which is more reliable on emulator + // For real devices, use 'mediacodec-copy' for hardware acceleration + MPVLib.setOptionString("hwdec", "no") MPVLib.setOptionString("hwdec-codecs", "all") // Audio output @@ -110,6 +112,20 @@ class MPVView @JvmOverloads constructor( // Network options MPVLib.setOptionString("network-timeout", "60") // 60 second timeout + // CRITICAL: Disable youtube-dl/yt-dlp hook + // The ytdl_hook incorrectly tries to parse HLS/direct URLs through youtube-dl + // which fails on Android since yt-dlp is not available, causing playback failure + MPVLib.setOptionString("ytdl", "no") + + // CRITICAL: HTTP headers MUST be set as options before init() + // Apply headers if they were set before surface initialization + applyHttpHeadersAsOptions() + + // FFmpeg HTTP protocol options for better compatibility + MPVLib.setOptionString("tls-verify", "no") // Disable TLS cert verification + MPVLib.setOptionString("http-reconnect", "yes") // Auto-reconnect on network issues + MPVLib.setOptionString("stream-reconnect", "yes") // Reconnect if stream drops + // Subtitle configuration - CRITICAL for Android MPVLib.setOptionString("sub-auto", "fuzzy") // Auto-load subtitles MPVLib.setOptionString("sub-visibility", "yes") // Make subtitles visible by default @@ -184,8 +200,7 @@ class MPVView @JvmOverloads constructor( fun setDataSource(url: String) { if (isMpvInitialized) { - // Apply headers before loading the file - applyHttpHeaders() + // Headers were already set during initialization in initOptions() loadFile(url) } else { pendingDataSource = url @@ -197,13 +212,22 @@ class MPVView @JvmOverloads constructor( Log.d(TAG, "Headers set: $headers") } - private fun applyHttpHeaders() { + private fun applyHttpHeadersAsOptions() { + // Always set user-agent (this works reliably) + val userAgent = httpHeaders?.get("User-Agent") + ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + + Log.d(TAG, "Setting User-Agent: $userAgent") + MPVLib.setOptionString("user-agent", userAgent) + + // Additionally, set other headers via http-header-fields if present + // This is needed for streams that require Referer, Origin, Cookie, etc. httpHeaders?.let { headers -> - if (headers.isNotEmpty()) { - // Format headers for MPV: comma-separated "Key: Value" pairs - val headerList = headers.map { (key, value) -> "$key: $value" } - val headerString = headerList.joinToString(",") - Log.d(TAG, "Applying HTTP headers: $headerString") + val otherHeaders = headers.filterKeys { it != "User-Agent" } + if (otherHeaders.isNotEmpty()) { + // Format as comma-separated "Key: Value" pairs + val headerString = otherHeaders.map { (key, value) -> "$key: $value" }.joinToString(",") + Log.d(TAG, "Setting additional headers: $headerString") MPVLib.setOptionString("http-header-fields", headerString) } } diff --git a/app.json b/app.json index 0787269..4bf4cee 100644 --- a/app.json +++ b/app.json @@ -83,13 +83,6 @@ "username": "nayifleo" } ], - [ - "expo-libvlc-player", - { - "localNetworkPermission": "Allow $(PRODUCT_NAME) to access your local network", - "supportsBackgroundPlayback": true - } - ], "react-native-bottom-tabs", [ "react-native-google-cast", From 1f3b9413cdaf6ebf1f4c3895d9c0559ed110a641 Mon Sep 17 00:00:00 2001 From: tapframe Date: Tue, 23 Dec 2025 21:55:46 +0530 Subject: [PATCH 12/18] added hw/sw toggle for android --- .../main/java/com/nuvio/app/mpv/MPVView.kt | 12 ++-- .../com/nuvio/app/mpv/MpvPlayerViewManager.kt | 5 ++ src/components/player/AndroidVideoPlayer.tsx | 3 + src/components/player/android/MpvPlayer.tsx | 2 + .../android/components/VideoSurface.tsx | 3 + src/hooks/useSettings.ts | 4 ++ src/screens/PlayerSettingsScreen.tsx | 68 ++++++++++++++++++- 7 files changed, 92 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt index ebaf8d0..c94bd0f 100644 --- a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt +++ b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt @@ -23,6 +23,9 @@ class MPVView @JvmOverloads constructor( private var isPaused: Boolean = true private var surface: Surface? = null private var httpHeaders: Map? = null + + // Hardware decoding setting (default: false = software decoding) + var useHardwareDecoding: Boolean = false // Event listener for React Native var onLoadCallback: ((duration: Double, width: Int, height: Int) -> Unit)? = null @@ -94,10 +97,11 @@ class MPVView @JvmOverloads constructor( MPVLib.setOptionString("opengl-es", "yes") // Hardware decoding configuration - // NOTE: On emulator, mediacodec can cause freezes due to slow GPU translation - // Using 'no' for software decoding which is more reliable on emulator - // For real devices, use 'mediacodec-copy' for hardware acceleration - MPVLib.setOptionString("hwdec", "no") + // 'mediacodec-copy' for hardware acceleration (GPU decoding, copies frames to CPU) + // 'no' for software decoding (more compatible, especially on emulators) + val hwdecValue = if (useHardwareDecoding) "mediacodec-copy" else "no" + Log.d(TAG, "Hardware decoding: $useHardwareDecoding, hwdec value: $hwdecValue") + MPVLib.setOptionString("hwdec", hwdecValue) MPVLib.setOptionString("hwdec-codecs", "all") // Audio output diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt b/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt index 27d4852..c50f928 100644 --- a/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt +++ b/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt @@ -180,4 +180,9 @@ class MpvPlayerViewManager( view.setHeaders(null) } } + + @ReactProp(name = "useHardwareDecoding") + fun setUseHardwareDecoding(view: MPVView, useHardwareDecoding: Boolean) { + view.useHardwareDecoding = useHardwareDecoding + } } diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 568a9c4..57588ca 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -24,6 +24,7 @@ import { useNextEpisode } from './android/hooks/useNextEpisode'; import { useTraktAutosync } from '../../hooks/useTraktAutosync'; import { useMetadata } from '../../hooks/useMetadata'; import { usePlayerGestureControls } from '../../hooks/usePlayerGestureControls'; +import { useSettings } from '../../hooks/useSettings'; // Shared Components import { GestureControls, PauseOverlay, SpeedActivatedOverlay } from './components'; @@ -69,6 +70,7 @@ const AndroidVideoPlayer: React.FC = () => { const playerState = usePlayerState(); const modals = usePlayerModals(); const speedControl = useSpeedControl(); + const { settings } = useSettings(); const videoRef = useRef(null); const mpvPlayerRef = useRef(null); @@ -550,6 +552,7 @@ const AndroidVideoPlayer: React.FC = () => { onPinchGestureEvent={() => { }} onPinchHandlerStateChange={() => { }} screenDimensions={playerState.screenDimensions} + useHardwareDecoding={settings.useHardwareDecoding} /> {/* Custom Subtitles for addon subtitles */} diff --git a/src/components/player/android/MpvPlayer.tsx b/src/components/player/android/MpvPlayer.tsx index 9aec1d5..04c7b08 100644 --- a/src/components/player/android/MpvPlayer.tsx +++ b/src/components/player/android/MpvPlayer.tsx @@ -25,6 +25,7 @@ export interface MpvPlayerProps { onEnd?: () => void; onError?: (error: { error: string }) => void; onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void; + useHardwareDecoding?: boolean; } const MpvPlayer = forwardRef((props, ref) => { @@ -103,6 +104,7 @@ const MpvPlayer = forwardRef((props, ref) => { onEnd={handleEnd} onError={handleError} onTracksChanged={handleTracksChanged} + useHardwareDecoding={props.useHardwareDecoding ?? false} /> ); }); diff --git a/src/components/player/android/components/VideoSurface.tsx b/src/components/player/android/components/VideoSurface.tsx index c6d0d86..e4e2d50 100644 --- a/src/components/player/android/components/VideoSurface.tsx +++ b/src/components/player/android/components/VideoSurface.tsx @@ -32,6 +32,7 @@ interface VideoSurfaceProps { onPinchHandlerStateChange: any; screenDimensions: { width: number, height: number }; onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void; + useHardwareDecoding?: boolean; } export const VideoSurface: React.FC = ({ @@ -55,6 +56,7 @@ export const VideoSurface: React.FC = ({ onPinchHandlerStateChange, screenDimensions, onTracksChanged, + useHardwareDecoding, }) => { // Use the actual stream URL const streamUrl = currentStreamUrl || processedStreamUrl; @@ -113,6 +115,7 @@ export const VideoSurface: React.FC = ({ onEnd={handleEnd} onError={handleError} onTracksChanged={onTracksChanged} + useHardwareDecoding={useHardwareDecoding} /> {/* Gesture overlay - transparent, on top of the player */} diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index d893569..21e9e81 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -88,6 +88,8 @@ export interface AppSettings { streamCacheTTL: number; // Stream cache duration in milliseconds (default: 1 hour) enableStreamsBackdrop: boolean; // Enable blurred backdrop background on StreamsScreen mobile useExternalPlayerForDownloads: boolean; // Enable/disable external player for downloaded content + // Android MPV player settings + useHardwareDecoding: boolean; // Enable hardware decoding for MPV player on Android (default: false for software decoding) } export const DEFAULT_SETTINGS: AppSettings = { @@ -149,6 +151,8 @@ export const DEFAULT_SETTINGS: AppSettings = { openMetadataScreenWhenCacheDisabled: true, // Default to StreamsScreen when cache disabled streamCacheTTL: 60 * 60 * 1000, // Default: 1 hour in milliseconds enableStreamsBackdrop: true, // Enable by default (new behavior) + // Android MPV player settings + useHardwareDecoding: false, // Default to software decoding (more compatible) }; const SETTINGS_STORAGE_KEY = 'app_settings'; diff --git a/src/screens/PlayerSettingsScreen.tsx b/src/screens/PlayerSettingsScreen.tsx index a3e57cb..577da4d 100644 --- a/src/screens/PlayerSettingsScreen.tsx +++ b/src/screens/PlayerSettingsScreen.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { View, Text, @@ -14,6 +14,7 @@ import { useNavigation } from '@react-navigation/native'; import { useSettings, AppSettings } from '../hooks/useSettings'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import { useTheme } from '../contexts/ThemeContext'; +import CustomAlert from '../components/CustomAlert'; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; @@ -95,6 +96,17 @@ const PlayerSettingsScreen: React.FC = () => { const { currentTheme } = useTheme(); const navigation = useNavigation(); + // CustomAlert state + const [alertVisible, setAlertVisible] = useState(false); + const [alertTitle, setAlertTitle] = useState(''); + const [alertMessage, setAlertMessage] = useState(''); + + const openAlert = (title: string, message: string) => { + setAlertTitle(title); + setAlertMessage(message); + setAlertVisible(true); + }; + const playerOptions = [ { id: 'internal', @@ -323,6 +335,53 @@ const PlayerSettingsScreen: React.FC = () => { + {/* Hardware Decoding for Android Internal Player */} + {Platform.OS === 'android' && !settings.useExternalPlayer && ( + + + + + + + + Hardware Decoding + + + Use GPU for video decoding. May improve performance but can cause issues on some devices. + + + { + updateSetting('useHardwareDecoding', value); + openAlert( + 'Restart Required', + 'Please restart the app for the decoding change to take effect.' + ); + }} + thumbColor={settings.useHardwareDecoding ? currentTheme.colors.primary : undefined} + /> + + + )} + {/* External Player for Downloads */} {((Platform.OS === 'android' && settings.useExternalPlayer) || (Platform.OS === 'ios' && settings.preferredPlayer !== 'internal')) && ( @@ -367,6 +426,13 @@ const PlayerSettingsScreen: React.FC = () => { + + setAlertVisible(false)} + /> ); }; From c421e46724e9a790e86ce3630ef0eab2d04d7604 Mon Sep 17 00:00:00 2001 From: tapframe Date: Tue, 23 Dec 2025 22:32:19 +0530 Subject: [PATCH 13/18] changes --- .../main/java/com/nuvio/app/mpv/MPVView.kt | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt index c94bd0f..43e32c6 100644 --- a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt +++ b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt @@ -26,6 +26,9 @@ class MPVView @JvmOverloads constructor( // Hardware decoding setting (default: false = software decoding) var useHardwareDecoding: Boolean = false + + // Flag to track if onLoad has been fired (prevents multiple fires for HLS streams) + private var hasLoadEventFired: Boolean = false // Event listener for React Native var onLoadCallback: ((duration: Double, width: Int, height: Int) -> Unit)? = null @@ -130,6 +133,17 @@ class MPVView @JvmOverloads constructor( MPVLib.setOptionString("http-reconnect", "yes") // Auto-reconnect on network issues MPVLib.setOptionString("stream-reconnect", "yes") // Reconnect if stream drops + // CRITICAL: HLS demuxer options for proper VOD stream handling + // Without these, HLS streams may be treated as live and start from the end + // Note: Multiple lavf options separated by comma + MPVLib.setOptionString("demuxer-lavf-o", "live_start_index=0,prefer_x_start=1,http_persistent=0") + MPVLib.setOptionString("demuxer-seekable-cache", "yes") // Allow seeking in cached content + MPVLib.setOptionString("force-seekable", "yes") // Force stream to be seekable + + // Increase probe/analyze duration to help detect full HLS duration + MPVLib.setOptionString("demuxer-lavf-probesize", "10000000") // 10MB probe size + MPVLib.setOptionString("demuxer-lavf-analyzeduration", "10") // 10 seconds analyze + // Subtitle configuration - CRITICAL for Android MPVLib.setOptionString("sub-auto", "fuzzy") // Auto-load subtitles MPVLib.setOptionString("sub-visibility", "yes") // Make subtitles visible by default @@ -197,6 +211,8 @@ class MPVView @JvmOverloads constructor( private fun loadFile(url: String) { Log.d(TAG, "Loading file: $url") + // Reset load event flag for new file + hasLoadEventFired = false MPVLib.command(arrayOf("loadfile", url)) } @@ -397,9 +413,18 @@ class MPVView @JvmOverloads constructor( onProgressCallback?.invoke(value, duration) } "duration/full", "duration" -> { - val width = MPVLib.getPropertyInt("width") ?: 0 - val height = MPVLib.getPropertyInt("height") ?: 0 - onLoadCallback?.invoke(value, width, height) + // Only fire onLoad once when video dimensions are available + // For HLS streams, duration updates incrementally as segments are fetched + if (!hasLoadEventFired) { + val width = MPVLib.getPropertyInt("width") ?: 0 + val height = MPVLib.getPropertyInt("height") ?: 0 + // Wait until we have valid dimensions before firing onLoad + if (width > 0 && height > 0 && value > 0) { + hasLoadEventFired = true + Log.d(TAG, "Firing onLoad event: duration=$value, width=$width, height=$height") + onLoadCallback?.invoke(value, width, height) + } + } } } } From 8b3a1b57bf95e1e6c8f48ea5dadf8012256c8280 Mon Sep 17 00:00:00 2001 From: tapframe Date: Wed, 24 Dec 2025 18:28:39 +0530 Subject: [PATCH 14/18] SDUI modal init --- .gitignore | 1 + App.tsx | 2 + src/components/promotions/CampaignManager.tsx | 355 ++++++++++++++++++ src/components/promotions/PosterModal.tsx | 228 +++++++++++ src/screens/SettingsScreen.tsx | 12 + src/services/campaignService.ts | 217 +++++++++++ 6 files changed, 815 insertions(+) create mode 100644 src/components/promotions/CampaignManager.tsx create mode 100644 src/components/promotions/PosterModal.tsx create mode 100644 src/services/campaignService.ts diff --git a/.gitignore b/.gitignore index 16951d6..2f077ca 100644 --- a/.gitignore +++ b/.gitignore @@ -80,6 +80,7 @@ bottomnav.md mmkv.md fix-android-scroll-lag-summary.md server/cache-server +server/campaign-manager carousal.md node_modules expofs.md diff --git a/App.tsx b/App.tsx index 39207b5..ff4b26f 100644 --- a/App.tsx +++ b/App.tsx @@ -42,6 +42,7 @@ import { AccountProvider, useAccount } from './src/contexts/AccountContext'; import { ToastProvider } from './src/contexts/ToastContext'; import { mmkvStorage } from './src/services/mmkvStorage'; import AnnouncementOverlay from './src/components/AnnouncementOverlay'; +import { CampaignManager } from './src/components/promotions/CampaignManager'; Sentry.init({ dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992', @@ -232,6 +233,7 @@ const ThemedApp = () => { onActionPress={handleNavigateToDebrid} actionButtonText="Connect Now" /> + diff --git a/src/components/promotions/CampaignManager.tsx b/src/components/promotions/CampaignManager.tsx new file mode 100644 index 0000000..8caad7f --- /dev/null +++ b/src/components/promotions/CampaignManager.tsx @@ -0,0 +1,355 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { View, StyleSheet, Text, TouchableOpacity, Image, Linking, Dimensions } from 'react-native'; +import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown, SlideInUp, SlideOutUp } from 'react-native-reanimated'; +import { BlurView } from 'expo-blur'; +import { Ionicons } from '@expo/vector-icons'; +import { campaignService, Campaign, CampaignAction } from '../../services/campaignService'; +import { PosterModal } from './PosterModal'; +import { useNavigation } from '@react-navigation/native'; +import { useAccount } from '../../contexts/AccountContext'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); + +// --- Banner Component --- +interface BannerProps { + campaign: Campaign; + onDismiss: () => void; + onAction: (action: CampaignAction) => void; +} + +const BannerCampaign: React.FC = ({ campaign, onDismiss, onAction }) => { + const insets = useSafeAreaInsets(); + const { content } = campaign; + + const handlePress = () => { + if (content.primaryAction) { + onAction(content.primaryAction); + if (content.primaryAction.type === 'dismiss') { + onDismiss(); + } else if (content.primaryAction.type === 'link' && content.primaryAction.value) { + Linking.openURL(content.primaryAction.value); + onDismiss(); + } + } + }; + + return ( + + + {content.imageUrl && ( + + )} + + {content.title && ( + + {content.title} + + )} + {content.message && ( + + {content.message} + + )} + + {content.primaryAction?.label && ( + + + {content.primaryAction.label} + + + )} + + + + + + ); +}; + +// --- Bottom Sheet Component --- +interface BottomSheetProps { + campaign: Campaign; + onDismiss: () => void; + onAction: (action: CampaignAction) => void; +} + +const BottomSheetCampaign: React.FC = ({ campaign, onDismiss, onAction }) => { + const insets = useSafeAreaInsets(); + const { content } = campaign; + + const handlePrimaryAction = () => { + if (content.primaryAction) { + onAction(content.primaryAction); + if (content.primaryAction.type === 'dismiss') { + onDismiss(); + } else if (content.primaryAction.type === 'link' && content.primaryAction.value) { + Linking.openURL(content.primaryAction.value); + onDismiss(); + } + } + }; + + return ( + + + + + + + + + + + + + + + {content.imageUrl && ( + + )} + + + {content.title && ( + + {content.title} + + )} + {content.message && ( + + {content.message} + + )} + + + {content.primaryAction && ( + + + {content.primaryAction.label} + + + )} + + + ); +}; + +// --- Campaign Manager --- +export const CampaignManager: React.FC = () => { + const [activeCampaign, setActiveCampaign] = useState(null); + const [isVisible, setIsVisible] = useState(false); + const navigation = useNavigation(); + const { user } = useAccount(); + + const checkForCampaigns = useCallback(async () => { + try { + console.log('[CampaignManager] Checking for campaigns...'); + await new Promise(resolve => setTimeout(resolve, 1500)); + + const campaign = await campaignService.getActiveCampaign(); + console.log('[CampaignManager] Got campaign:', campaign?.id, campaign?.type); + + if (campaign) { + setActiveCampaign(campaign); + setIsVisible(true); + campaignService.recordImpression(campaign.id, campaign.rules.showOncePerUser); + } + } catch (error) { + console.warn('[CampaignManager] Failed to check campaigns', error); + } + }, []); + + useEffect(() => { + checkForCampaigns(); + }, [checkForCampaigns]); + + const handleDismiss = useCallback(() => { + setIsVisible(false); + + // After animation completes, check for next campaign + setTimeout(() => { + const nextCampaign = campaignService.getNextCampaign(); + console.log('[CampaignManager] Next campaign:', nextCampaign?.id, nextCampaign?.type); + + if (nextCampaign) { + setActiveCampaign(nextCampaign); + setIsVisible(true); + campaignService.recordImpression(nextCampaign.id, nextCampaign.rules.showOncePerUser); + } else { + setActiveCampaign(null); + } + }, 350); // Wait for exit animation + }, []); + + const handleAction = (action: CampaignAction) => { + console.log('[CampaignManager] Action:', action); + }; + + if (!activeCampaign || !isVisible) return null; + + return ( + + {activeCampaign.type === 'poster_modal' && ( + + )} + {activeCampaign.type === 'banner' && ( + + )} + {activeCampaign.type === 'bottom_sheet' && ( + + )} + + ); +}; + +const styles = StyleSheet.create({ + // Banner styles + bannerContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 1000, + paddingHorizontal: 12, + }, + banner: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + borderRadius: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 6, + elevation: 6, + }, + bannerImage: { + width: 44, + height: 44, + borderRadius: 8, + marginRight: 12, + }, + bannerContent: { + flex: 1, + }, + bannerTitle: { + fontSize: 14, + fontWeight: '600', + marginBottom: 2, + }, + bannerMessage: { + fontSize: 12, + opacity: 0.8, + }, + bannerCta: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 14, + marginLeft: 8, + }, + bannerCtaText: { + fontSize: 12, + fontWeight: '600', + }, + bannerClose: { + padding: 4, + marginLeft: 8, + }, + + // Bottom sheet styles + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.5)', + }, + bottomSheet: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + backgroundColor: '#1a1a1a', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + paddingHorizontal: 20, + paddingTop: 12, + }, + bottomSheetHandle: { + width: 36, + height: 4, + backgroundColor: 'rgba(255,255,255,0.2)', + borderRadius: 2, + alignSelf: 'center', + marginBottom: 16, + }, + bottomSheetClose: { + position: 'absolute', + top: 16, + right: 16, + zIndex: 10, + padding: 4, + }, + bottomSheetImage: { + width: '100%', + borderRadius: 10, + marginBottom: 16, + }, + bottomSheetContent: { + marginBottom: 20, + }, + bottomSheetTitle: { + fontSize: 20, + fontWeight: '600', + marginBottom: 8, + textAlign: 'center', + }, + bottomSheetMessage: { + fontSize: 14, + opacity: 0.8, + textAlign: 'center', + lineHeight: 20, + }, + bottomSheetButton: { + paddingVertical: 14, + borderRadius: 24, + alignItems: 'center', + }, + bottomSheetButtonText: { + fontSize: 15, + fontWeight: '600', + }, +}); diff --git a/src/components/promotions/PosterModal.tsx b/src/components/promotions/PosterModal.tsx new file mode 100644 index 0000000..61cc4c7 --- /dev/null +++ b/src/components/promotions/PosterModal.tsx @@ -0,0 +1,228 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Dimensions, + Image, + Linking, +} from 'react-native'; +import Animated, { + FadeIn, + FadeOut, +} from 'react-native-reanimated'; +import { BlurView } from 'expo-blur'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Campaign } from '../../services/campaignService'; + +interface PosterModalProps { + campaign: Campaign; + onDismiss: () => void; + onAction: (action: any) => void; +} + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); + +export const PosterModal: React.FC = ({ + campaign, + onDismiss, + onAction, +}) => { + const insets = useSafeAreaInsets(); + const { content } = campaign; + const isPosterOnly = !content.title && !content.message; + + const handleAction = () => { + if (content.primaryAction) { + if (content.primaryAction.type === 'link' && content.primaryAction.value) { + Linking.openURL(content.primaryAction.value); + onAction(content.primaryAction); + onDismiss(); + } else if (content.primaryAction.type === 'dismiss') { + onDismiss(); + } else { + onAction(content.primaryAction); + } + } + }; + + return ( + + {/* Backdrop */} + + + + + + {/* Modal Container */} + + + {/* Close Button */} + + + + + + + {/* Main Image */} + {content.imageUrl && ( + + + + )} + + {/* Text Content */} + {!isPosterOnly && ( + + {content.title && ( + + {content.title} + + )} + {content.message && ( + + {content.message} + + )} + + )} + + {/* Primary Action Button */} + {content.primaryAction && ( + + + {content.primaryAction.label} + + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.6)', + zIndex: 998, + }, + modalContainer: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + zIndex: 999, + }, + contentWrapper: { + width: Math.min(SCREEN_WIDTH * 0.85, 340), + alignItems: 'center', + }, + closeButton: { + position: 'absolute', + top: -8, + right: -8, + zIndex: 1000, + }, + closeButtonBg: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: 'rgba(0,0,0,0.5)', + alignItems: 'center', + justifyContent: 'center', + }, + imageContainer: { + width: '100%', + borderRadius: 12, + overflow: 'hidden', + backgroundColor: '#222', + }, + image: { + width: '100%', + height: '100%', + }, + textContainer: { + width: '100%', + padding: 20, + borderBottomLeftRadius: 12, + borderBottomRightRadius: 12, + marginTop: -2, + }, + title: { + fontSize: 20, + fontWeight: '600', + marginBottom: 6, + textAlign: 'center', + }, + message: { + fontSize: 14, + lineHeight: 20, + textAlign: 'center', + opacity: 0.85, + }, + actionButton: { + paddingVertical: 14, + paddingHorizontal: 32, + borderRadius: 24, + marginTop: 16, + minWidth: 180, + alignItems: 'center', + }, + actionButtonText: { + fontSize: 15, + fontWeight: '600', + }, +}); diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index bfc003f..d372798 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -39,6 +39,7 @@ import PluginIcon from '../components/icons/PluginIcon'; import TraktIcon from '../components/icons/TraktIcon'; import TMDBIcon from '../components/icons/TMDBIcon'; import MDBListIcon from '../components/icons/MDBListIcon'; +import { campaignService } from '../services/campaignService'; const { width, height } = Dimensions.get('window'); const isTablet = width >= 768; @@ -801,6 +802,17 @@ const SettingsScreen: React.FC = () => { renderControl={ChevronRight} isTablet={isTablet} /> + { + await campaignService.resetCampaigns(); + openAlert('Success', 'Campaign history reset. Restart app to see posters again.'); + }} + renderControl={ChevronRight} + isTablet={isTablet} + /> ; + private campaignQueue: Campaign[] = []; + private currentIndex: number = 0; + private lastFetch: number = 0; + private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes + + constructor() { + this.sessionImpressions = new Set(); + } + + /** + * Fetches all active campaigns and returns the next valid one in the queue. + */ + async getActiveCampaign(): Promise { + try { + const now = Date.now(); + + // If we have campaigns in queue and cache is still valid, get next valid one + if (this.campaignQueue.length > 0 && (now - this.lastFetch) < this.CACHE_TTL) { + return this.getNextValidCampaign(); + } + + // Fetch all campaigns from server + const platform = Platform.OS; + const response = await fetch( + `${CAMPAIGN_API_URL}/api/campaigns/queue?platform=${platform}`, + { + method: 'GET', + headers: { 'Accept': 'application/json' }, + } + ); + + if (!response.ok) { + console.warn('[CampaignService] Failed to fetch campaigns:', response.status); + return null; + } + + const campaigns = await response.json(); + + if (!campaigns || !Array.isArray(campaigns) || campaigns.length === 0) { + this.campaignQueue = []; + this.currentIndex = 0; + this.lastFetch = now; + return null; + } + + // Resolve relative image URLs + campaigns.forEach((campaign: Campaign) => { + if (campaign.content?.imageUrl && campaign.content.imageUrl.startsWith('/')) { + campaign.content.imageUrl = `${CAMPAIGN_API_URL}${campaign.content.imageUrl}`; + } + }); + + this.campaignQueue = campaigns; + this.currentIndex = 0; + this.lastFetch = now; + + return this.getNextValidCampaign(); + } catch (error) { + console.warn('[CampaignService] Error fetching campaigns:', error); + return null; + } + } + + /** + * Gets the next valid campaign from the queue. + */ + private getNextValidCampaign(): Campaign | null { + while (this.currentIndex < this.campaignQueue.length) { + const campaign = this.campaignQueue[this.currentIndex]; + if (this.isLocallyValid(campaign)) { + return campaign; + } + this.currentIndex++; + } + return null; + } + + /** + * Moves to the next campaign in the queue and returns it. + */ + getNextCampaign(): Campaign | null { + this.currentIndex++; + return this.getNextValidCampaign(); + } + + /** + * Validates campaign against local-only rules. + */ + private isLocallyValid(campaign: Campaign): boolean { + const { rules } = campaign; + + // Show once per user (persisted forever) + if (rules.showOncePerUser && this.hasSeenCampaign(campaign.id)) { + return false; + } + + // Impression limit check + if (rules.maxImpressions) { + const impressionCount = this.getImpressionCount(campaign.id); + if (impressionCount >= rules.maxImpressions) { + return false; + } + } + + // Session check + if (rules.showOncePerSession && this.sessionImpressions.has(campaign.id)) { + return false; + } + + return true; + } + + private hasSeenCampaign(campaignId: string): boolean { + return mmkvStorage.getBoolean(`campaign_seen_${campaignId}`) || false; + } + + private markCampaignSeen(campaignId: string) { + mmkvStorage.setBoolean(`campaign_seen_${campaignId}`, true); + } + + private getImpressionCount(campaignId: string): number { + return mmkvStorage.getNumber(`campaign_impression_${campaignId}`) || 0; + } + + recordImpression(campaignId: string, showOncePerUser?: boolean) { + const current = this.getImpressionCount(campaignId); + mmkvStorage.setNumber(`campaign_impression_${campaignId}`, current + 1); + this.sessionImpressions.add(campaignId); + + if (showOncePerUser) { + this.markCampaignSeen(campaignId); + } + } + + async resetCampaigns() { + this.sessionImpressions.clear(); + this.campaignQueue = []; + this.currentIndex = 0; + this.lastFetch = 0; + } + + clearCache() { + this.campaignQueue = []; + this.currentIndex = 0; + this.lastFetch = 0; + } + + /** + * Returns remaining campaigns in queue count. + */ + getRemainingCount(): number { + let count = 0; + for (let i = this.currentIndex; i < this.campaignQueue.length; i++) { + if (this.isLocallyValid(this.campaignQueue[i])) { + count++; + } + } + return count; + } +} + +export const campaignService = new CampaignService(); From 0165b1f987b623526043e2fe0ded708ea8473b49 Mon Sep 17 00:00:00 2001 From: tapframe Date: Wed, 24 Dec 2025 18:56:11 +0530 Subject: [PATCH 15/18] SDUI prod url init --- src/services/campaignService.ts | 39 +++++---------------------------- 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/src/services/campaignService.ts b/src/services/campaignService.ts index 112e237..47a90b7 100644 --- a/src/services/campaignService.ts +++ b/src/services/campaignService.ts @@ -1,19 +1,13 @@ import { mmkvStorage } from './mmkvStorage'; import { Platform } from 'react-native'; -import Constants from 'expo-constants'; -// --- Configuration --- -// Dev: Uses Mac's LAN IP for physical device testing (run: ipconfig getifaddr en0) -// Prod: Uses EXPO_PUBLIC_CAMPAIGN_API_URL from .env -const CAMPAIGN_API_URL = __DEV__ - ? 'http://192.168.1.5:3000' - : Constants.expoConfig?.extra?.CAMPAIGN_API_URL || process.env.EXPO_PUBLIC_CAMPAIGN_API_URL || ''; - -// --- Types --- +const DEV_URL = 'http://192.168.1.5:3000'; +const PROD_URL = process.env.EXPO_PUBLIC_CAMPAIGN_API_URL || ''; +const CAMPAIGN_API_URL = __DEV__ ? DEV_URL : PROD_URL; export type CampaignAction = { type: 'link' | 'navigate' | 'dismiss'; - value?: string; // URL or Route Name + value?: string; label: string; style?: 'primary' | 'secondary' | 'outline'; }; @@ -49,32 +43,25 @@ export type Campaign = { rules: CampaignRules; }; -// --- Service --- - class CampaignService { private sessionImpressions: Set; private campaignQueue: Campaign[] = []; private currentIndex: number = 0; private lastFetch: number = 0; - private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes + private readonly CACHE_TTL = 5 * 60 * 1000; constructor() { this.sessionImpressions = new Set(); } - /** - * Fetches all active campaigns and returns the next valid one in the queue. - */ async getActiveCampaign(): Promise { try { const now = Date.now(); - // If we have campaigns in queue and cache is still valid, get next valid one if (this.campaignQueue.length > 0 && (now - this.lastFetch) < this.CACHE_TTL) { return this.getNextValidCampaign(); } - // Fetch all campaigns from server const platform = Platform.OS; const response = await fetch( `${CAMPAIGN_API_URL}/api/campaigns/queue?platform=${platform}`, @@ -98,7 +85,6 @@ class CampaignService { return null; } - // Resolve relative image URLs campaigns.forEach((campaign: Campaign) => { if (campaign.content?.imageUrl && campaign.content.imageUrl.startsWith('/')) { campaign.content.imageUrl = `${CAMPAIGN_API_URL}${campaign.content.imageUrl}`; @@ -116,9 +102,6 @@ class CampaignService { } } - /** - * Gets the next valid campaign from the queue. - */ private getNextValidCampaign(): Campaign | null { while (this.currentIndex < this.campaignQueue.length) { const campaign = this.campaignQueue[this.currentIndex]; @@ -130,26 +113,18 @@ class CampaignService { return null; } - /** - * Moves to the next campaign in the queue and returns it. - */ getNextCampaign(): Campaign | null { this.currentIndex++; return this.getNextValidCampaign(); } - /** - * Validates campaign against local-only rules. - */ private isLocallyValid(campaign: Campaign): boolean { const { rules } = campaign; - // Show once per user (persisted forever) if (rules.showOncePerUser && this.hasSeenCampaign(campaign.id)) { return false; } - // Impression limit check if (rules.maxImpressions) { const impressionCount = this.getImpressionCount(campaign.id); if (impressionCount >= rules.maxImpressions) { @@ -157,7 +132,6 @@ class CampaignService { } } - // Session check if (rules.showOncePerSession && this.sessionImpressions.has(campaign.id)) { return false; } @@ -200,9 +174,6 @@ class CampaignService { this.lastFetch = 0; } - /** - * Returns remaining campaigns in queue count. - */ getRemainingCount(): number { let count = 0; for (let i = this.currentIndex; i < this.campaignQueue.length; i++) { From 614597d1bde7049949df6a6135c2caec38c40dea Mon Sep 17 00:00:00 2001 From: tapframe Date: Wed, 24 Dec 2025 19:05:14 +0530 Subject: [PATCH 16/18] SDUI fix --- src/components/promotions/CampaignManager.tsx | 17 +++++++++++++++-- src/services/campaignService.ts | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/components/promotions/CampaignManager.tsx b/src/components/promotions/CampaignManager.tsx index 8caad7f..c5e1b86 100644 --- a/src/components/promotions/CampaignManager.tsx +++ b/src/components/promotions/CampaignManager.tsx @@ -205,9 +205,22 @@ export const CampaignManager: React.FC = () => { }, 350); // Wait for exit animation }, []); - const handleAction = (action: CampaignAction) => { + const handleAction = useCallback((action: CampaignAction) => { console.log('[CampaignManager] Action:', action); - }; + + if (action.type === 'navigate' && action.value) { + handleDismiss(); + setTimeout(() => { + try { + (navigation as any).navigate(action.value); + } catch (error) { + console.warn('[CampaignManager] Navigation failed:', error); + } + }, 400); + } else if (action.type === 'link' && action.value) { + Linking.openURL(action.value); + } + }, [navigation, handleDismiss]); if (!activeCampaign || !isVisible) return null; diff --git a/src/services/campaignService.ts b/src/services/campaignService.ts index 47a90b7..aad80bb 100644 --- a/src/services/campaignService.ts +++ b/src/services/campaignService.ts @@ -1,7 +1,7 @@ import { mmkvStorage } from './mmkvStorage'; import { Platform } from 'react-native'; -const DEV_URL = 'http://192.168.1.5:3000'; +const DEV_URL = 'https://campaign.nuvioapp.space/'; const PROD_URL = process.env.EXPO_PUBLIC_CAMPAIGN_API_URL || ''; const CAMPAIGN_API_URL = __DEV__ ? DEV_URL : PROD_URL; From b4cecee191df5fcf4c1557d505000e17a1fedb6a Mon Sep 17 00:00:00 2001 From: tapframe Date: Wed, 24 Dec 2025 19:05:57 +0530 Subject: [PATCH 17/18] .. --- src/services/campaignService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/campaignService.ts b/src/services/campaignService.ts index aad80bb..47a90b7 100644 --- a/src/services/campaignService.ts +++ b/src/services/campaignService.ts @@ -1,7 +1,7 @@ import { mmkvStorage } from './mmkvStorage'; import { Platform } from 'react-native'; -const DEV_URL = 'https://campaign.nuvioapp.space/'; +const DEV_URL = 'http://192.168.1.5:3000'; const PROD_URL = process.env.EXPO_PUBLIC_CAMPAIGN_API_URL || ''; const CAMPAIGN_API_URL = __DEV__ ? DEV_URL : PROD_URL; From ab720ddae73151d127e9eec9895ec8bbee1e087b Mon Sep 17 00:00:00 2001 From: tapframe Date: Wed, 24 Dec 2025 19:21:53 +0530 Subject: [PATCH 18/18] responsiveness for sdui modal --- src/components/promotions/CampaignManager.tsx | 138 ++++++++++-------- src/components/promotions/PosterModal.tsx | 91 +++++++----- src/services/campaignService.ts | 2 +- 3 files changed, 131 insertions(+), 100 deletions(-) diff --git a/src/components/promotions/CampaignManager.tsx b/src/components/promotions/CampaignManager.tsx index c5e1b86..3b09d8f 100644 --- a/src/components/promotions/CampaignManager.tsx +++ b/src/components/promotions/CampaignManager.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useCallback } from 'react'; -import { View, StyleSheet, Text, TouchableOpacity, Image, Linking, Dimensions } from 'react-native'; +import { View, StyleSheet, Text, TouchableOpacity, Image, Linking, useWindowDimensions } from 'react-native'; import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown, SlideInUp, SlideOutUp } from 'react-native-reanimated'; import { BlurView } from 'expo-blur'; import { Ionicons } from '@expo/vector-icons'; @@ -9,9 +9,6 @@ import { useNavigation } from '@react-navigation/native'; import { useAccount } from '../../contexts/AccountContext'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -const { width: SCREEN_WIDTH } = Dimensions.get('window'); - -// --- Banner Component --- interface BannerProps { campaign: Campaign; onDismiss: () => void; @@ -20,7 +17,10 @@ interface BannerProps { const BannerCampaign: React.FC = ({ campaign, onDismiss, onAction }) => { const insets = useSafeAreaInsets(); + const { width } = useWindowDimensions(); const { content } = campaign; + const isTablet = width >= 768; + const bannerMaxWidth = isTablet ? 600 : width - 24; const handlePress = () => { if (content.primaryAction) { @@ -41,41 +41,51 @@ const BannerCampaign: React.FC = ({ campaign, onDismiss, onAction } style={[styles.bannerContainer, { paddingTop: insets.top + 8 }]} > {content.imageUrl && ( - + )} {content.title && ( - + {content.title} )} {content.message && ( - + {content.message} )} {content.primaryAction?.label && ( - - + + {content.primaryAction.label} )} - + ); }; -// --- Bottom Sheet Component --- interface BottomSheetProps { campaign: Campaign; onDismiss: () => void; @@ -84,7 +94,13 @@ interface BottomSheetProps { const BottomSheetCampaign: React.FC = ({ campaign, onDismiss, onAction }) => { const insets = useSafeAreaInsets(); + const { width, height } = useWindowDimensions(); const { content } = campaign; + const isTablet = width >= 768; + const isLandscape = width > height; + + const sheetMaxWidth = isTablet ? 500 : width; + const imageMaxHeight = isLandscape ? height * 0.35 : height * 0.3; const handlePrimaryAction = () => { if (content.primaryAction) { @@ -113,30 +129,47 @@ const BottomSheetCampaign: React.FC = ({ campaign, onDismiss, - + {content.imageUrl && ( )} {content.title && ( - + {content.title} )} {content.message && ( - + {content.message} )} @@ -144,11 +177,11 @@ const BottomSheetCampaign: React.FC = ({ campaign, onDismiss, {content.primaryAction && ( - + {content.primaryAction.label} @@ -158,7 +191,6 @@ const BottomSheetCampaign: React.FC = ({ campaign, onDismiss, ); }; -// --- Campaign Manager --- export const CampaignManager: React.FC = () => { const [activeCampaign, setActiveCampaign] = useState(null); const [isVisible, setIsVisible] = useState(false); @@ -190,7 +222,6 @@ export const CampaignManager: React.FC = () => { const handleDismiss = useCallback(() => { setIsVisible(false); - // After animation completes, check for next campaign setTimeout(() => { const nextCampaign = campaignService.getNextCampaign(); console.log('[CampaignManager] Next campaign:', nextCampaign?.id, nextCampaign?.type); @@ -202,7 +233,7 @@ export const CampaignManager: React.FC = () => { } else { setActiveCampaign(null); } - }, 350); // Wait for exit animation + }, 350); }, []); const handleAction = useCallback((action: CampaignAction) => { @@ -252,7 +283,6 @@ export const CampaignManager: React.FC = () => { }; const styles = StyleSheet.create({ - // Banner styles bannerContainer: { position: 'absolute', top: 0, @@ -264,48 +294,40 @@ const styles = StyleSheet.create({ banner: { flexDirection: 'row', alignItems: 'center', - padding: 12, - borderRadius: 12, + padding: 14, + borderRadius: 14, shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.2, - shadowRadius: 6, - elevation: 6, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.25, + shadowRadius: 8, + elevation: 8, }, bannerImage: { - width: 44, - height: 44, - borderRadius: 8, + borderRadius: 10, marginRight: 12, }, bannerContent: { flex: 1, }, bannerTitle: { - fontSize: 14, fontWeight: '600', marginBottom: 2, }, bannerMessage: { - fontSize: 12, opacity: 0.8, }, bannerCta: { - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 14, - marginLeft: 8, + paddingVertical: 8, + borderRadius: 16, + marginLeft: 10, }, bannerCtaText: { - fontSize: 12, fontWeight: '600', }, bannerClose: { - padding: 4, + padding: 6, marginLeft: 8, }, - - // Bottom sheet styles backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)', @@ -316,53 +338,49 @@ const styles = StyleSheet.create({ left: 0, right: 0, backgroundColor: '#1a1a1a', - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - paddingHorizontal: 20, - paddingTop: 12, + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + paddingHorizontal: 24, + paddingTop: 14, }, bottomSheetHandle: { - width: 36, + width: 40, height: 4, backgroundColor: 'rgba(255,255,255,0.2)', borderRadius: 2, alignSelf: 'center', - marginBottom: 16, + marginBottom: 18, }, bottomSheetClose: { position: 'absolute', - top: 16, - right: 16, + top: 18, + right: 18, zIndex: 10, padding: 4, }, bottomSheetImage: { width: '100%', - borderRadius: 10, - marginBottom: 16, + borderRadius: 12, + marginBottom: 18, }, bottomSheetContent: { - marginBottom: 20, + marginBottom: 22, }, bottomSheetTitle: { - fontSize: 20, fontWeight: '600', - marginBottom: 8, + marginBottom: 10, textAlign: 'center', }, bottomSheetMessage: { - fontSize: 14, opacity: 0.8, textAlign: 'center', - lineHeight: 20, + lineHeight: 22, }, bottomSheetButton: { - paddingVertical: 14, - borderRadius: 24, + borderRadius: 26, alignItems: 'center', }, bottomSheetButtonText: { - fontSize: 15, fontWeight: '600', }, }); diff --git a/src/components/promotions/PosterModal.tsx b/src/components/promotions/PosterModal.tsx index 61cc4c7..9126fa9 100644 --- a/src/components/promotions/PosterModal.tsx +++ b/src/components/promotions/PosterModal.tsx @@ -4,14 +4,11 @@ import { Text, StyleSheet, TouchableOpacity, - Dimensions, Image, Linking, + useWindowDimensions, } from 'react-native'; -import Animated, { - FadeIn, - FadeOut, -} from 'react-native-reanimated'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { BlurView } from 'expo-blur'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -23,17 +20,27 @@ interface PosterModalProps { onAction: (action: any) => void; } -const { width: SCREEN_WIDTH } = Dimensions.get('window'); - export const PosterModal: React.FC = ({ campaign, onDismiss, onAction, }) => { const insets = useSafeAreaInsets(); + const { width, height } = useWindowDimensions(); const { content } = campaign; const isPosterOnly = !content.title && !content.message; + const isTablet = width >= 768; + const isLandscape = width > height; + + const modalWidth = isTablet + ? Math.min(width * 0.5, 420) + : isLandscape + ? Math.min(width * 0.45, 360) + : Math.min(width * 0.85, 340); + + const maxImageHeight = isLandscape ? height * 0.6 : height * 0.5; + const handleAction = () => { if (content.primaryAction) { if (content.primaryAction.type === 'link' && content.primaryAction.value) { @@ -50,17 +57,12 @@ export const PosterModal: React.FC = ({ return ( - {/* Backdrop */} - + = ({ /> - {/* Modal Container */} = ({ ]} pointerEvents="box-none" > - - {/* Close Button */} + = ({ - {/* Main Image */} {content.imageUrl && ( = ({ )} - {/* Text Content */} {!isPosterOnly && ( {content.title && ( - + {content.title} )} {content.message && ( - + {content.message} )} )} - {/* Primary Action Button */} {content.primaryAction && ( = ({ { backgroundColor: content.textColor || '#fff', marginTop: isPosterOnly ? 16 : 0, + paddingVertical: isTablet ? 16 : 14, + minWidth: isTablet ? 220 : 180, } ]} onPress={handleAction} @@ -142,7 +159,10 @@ export const PosterModal: React.FC = ({ > {content.primaryAction.label} @@ -167,7 +187,6 @@ const styles = StyleSheet.create({ zIndex: 999, }, contentWrapper: { - width: Math.min(SCREEN_WIDTH * 0.85, 340), alignItems: 'center', }, closeButton: { @@ -177,16 +196,16 @@ const styles = StyleSheet.create({ zIndex: 1000, }, closeButtonBg: { - width: 32, - height: 32, - borderRadius: 16, + width: 36, + height: 36, + borderRadius: 18, backgroundColor: 'rgba(0,0,0,0.5)', alignItems: 'center', justifyContent: 'center', }, imageContainer: { width: '100%', - borderRadius: 12, + borderRadius: 14, overflow: 'hidden', backgroundColor: '#222', }, @@ -196,33 +215,27 @@ const styles = StyleSheet.create({ }, textContainer: { width: '100%', - padding: 20, - borderBottomLeftRadius: 12, - borderBottomRightRadius: 12, + borderBottomLeftRadius: 14, + borderBottomRightRadius: 14, marginTop: -2, }, title: { - fontSize: 20, fontWeight: '600', marginBottom: 6, textAlign: 'center', }, message: { - fontSize: 14, - lineHeight: 20, + lineHeight: 22, textAlign: 'center', opacity: 0.85, }, actionButton: { - paddingVertical: 14, paddingHorizontal: 32, borderRadius: 24, marginTop: 16, - minWidth: 180, alignItems: 'center', }, actionButtonText: { - fontSize: 15, fontWeight: '600', }, }); diff --git a/src/services/campaignService.ts b/src/services/campaignService.ts index 47a90b7..aad80bb 100644 --- a/src/services/campaignService.ts +++ b/src/services/campaignService.ts @@ -1,7 +1,7 @@ import { mmkvStorage } from './mmkvStorage'; import { Platform } from 'react-native'; -const DEV_URL = 'http://192.168.1.5:3000'; +const DEV_URL = 'https://campaign.nuvioapp.space/'; const PROD_URL = process.env.EXPO_PUBLIC_CAMPAIGN_API_URL || ''; const CAMPAIGN_API_URL = __DEV__ ? DEV_URL : PROD_URL;