// VideoPlayer.tsx – FINAL FIX: Full m3u8 (HLS) support, PiP & notifications import React, { useCallback, useState, useEffect, useRef, useMemo, useImperativeHandle, } from 'react'; import {View} from 'react-native'; import Video, { OnLoadData, OnLoadStartData, OnProgressData, OnSeekData, ResizeMode, SelectedTrackType, } from 'react-native-video'; const {VlcPlayerView} = require('@wekor/react-native-vlc-player'); import {useControlTimeout, useJSAnimations, usePanResponders} from './hooks'; import { Error, Loader, TopControls, BottomControls, PlayPause, Overlay, } from './components'; import {PlatformSupport} from './OSSupport'; import {_onBack} from './utils'; import {_styles} from './styles'; import type {VideoPlayerProps, WithRequiredProperty} from './types'; import Gestures from './components/Gestures'; const volumeWidth = 150; const iconOffset = 0; export interface VideoPlayerWithVLCProps extends VideoPlayerProps { playerType?: 'native' | 'vlc'; subtitleUri?: string | null; audioTrackIndex?: number; selectedQuality?: 'auto' | number; vlcHardwareDecoding?: boolean; vlcInitOptions?: readonly string[]; vlcMediaOptions?: readonly string[]; pictureInPicture?: boolean; playInBackground?: boolean; onPictureInPictureStatusChanged?: (e: Readonly<{isActive: boolean}>) => void; onRestoreUserInterfaceForPictureInPictureStop?: () => void; showNotificationControls?: boolean; } // ─── Safe TrackPlayer import ────────────────────────────────────────────── let TrackPlayer: any = null; let Capability: any = null; let Event: any = null; let TrackPlayerInstalled = false; try { const tp = require('react-native-track-player'); TrackPlayer = tp.default || tp; Capability = tp.Capability; Event = tp.Event; TrackPlayerInstalled = true; console.log('[Media-Notification] react-native-track-player found.'); } catch (_) { console.log( '[Media-Notification] react-native-track-player not installed – notifications disabled.', ); } // ─── Helper to unwrap native events ────────────────────────────────────── const unwrapNativeEvent = (e: any) => (e && e.nativeEvent ? e.nativeEvent : e); // ─── Helper to detect if URI is a local file ────────────────────────────── const isLocalFile = (uri: string): boolean => { if (!uri) return false; return ( uri.startsWith('file://') || uri.startsWith('/') || uri.startsWith('content://') || !uri.includes('://') || uri.includes('/storage/') || uri.includes('/sdcard/') || uri.includes('/data/') ); }; // ─── Helper to ensure local URI has file:// prefix ──────────────────────── const normalizeLocalUri = (uri: string): string => { if (!uri) return uri; if (isLocalFile(uri) && !uri.startsWith('file://')) { return 'file://' + uri; } return uri; }; const AnimatedVideoPlayer = ( props: WithRequiredProperty, ) => { const { animations, toggleResizeModeOnFullscreen, doubleTapTime = 130, resizeMode = ResizeMode.CONTAIN, isFullscreen = false, showOnStart = false, showOnEnd = false, alwaysShowControls = false, paused = false, muted = false, volume = 2, title = {primary: '', secondary: ''}, rate = 1, showDuration = false, showTimeRemaining = false, showHours = false, onSeek, onError, onBack, onEnd, onEnterFullscreen = () => {}, onExitFullscreen = () => {}, onHideControls = () => {}, onShowControls = () => {}, onPause, onPlay, onLoadStart, controlTimeoutDelay = 15000, tapAnywhereToPause = false, videoStyle = {}, containerStyle = {}, seekColor = '', source, disableBack = false, disableVolume = false, disableFullscreen = false, disableTimer = false, disableSeekbar = false, disablePlayPause = false, disableSeekButtons = false, disableOverlay, navigator, rewindTime = 15, pan: {horizontal: horizontalPan, inverted: invertedPan} = {}, testID, disableGesture = false, hideAllControlls = false, playerType = 'native', subtitleUri, audioTrackIndex: _audioTrackIndex, showNotificationControls = false, selectedQuality = 'auto', vlcHardwareDecoding = true, vlcInitOptions, vlcMediaOptions: vlcMediaOptionsProp, pictureInPicture = false, playInBackground, onPictureInPictureStatusChanged, onRestoreUserInterfaceForPictureInPictureStop, } = props; const effectiveShowNotifications = showNotificationControls; const mounted = useRef(false); const _viewRef = useRef(null); const tapActionTimeout = useRef | null>(null); const seekWatchdogRef = useRef | null>(null); // ─── FIX: Tracks an in-flight NATIVE (ExoPlayer) seek so late/stale // onProgress ticks reporting the pre-seek position can be ignored. // Relaxed tolerance and timeout to avoid dropping legitimate updates. const pendingNativeSeekRef = useRef<{ target: number; issuedAt: number; } | null>(null); const pendingNativeSeekTimeoutRef = useRef | null>(null); const [_resizeMode, setResizeMode] = useState( ResizeMode.CONTAIN, ); const [_paused, setPaused] = useState(paused); const [_muted, setMuted] = useState(muted); const [_volume, setVolume] = useState(volume); const [_isFullscreen, setIsFullscreen] = useState( isFullscreen || resizeMode === 'cover' || false, ); const [_playbackRate, setPlaybackRate] = useState(rate); const [_showTimeRemaining, setShowTimeRemaining] = useState(showTimeRemaining); const initialVolumePos = Math.min( volumeWidth, Math.max(0, volumeWidth * (volume / 2)), ); const [volumeTrackWidth, setVolumeTrackWidth] = useState( Math.max(0, volumeWidth - initialVolumePos), ); const [volumeFillWidth, setVolumeFillWidth] = useState(initialVolumePos); const [seekerFillWidth, setSeekerFillWidth] = useState(0); const [showControls, setShowControls] = useState(showOnStart); const [volumePosition, setVolumePositionState] = useState(initialVolumePos); const [seekerPosition, setSeekerPositionState] = useState(0); const [seekerWidth, setSeekerWidth] = useState(0); const [seeking, setSeeking] = useState(false); const [loading, setLoading] = useState(true); const [currentTime, setCurrentTime] = useState(0); const [error, setError] = useState(false); const [duration, setDuration] = useState(0); const [buffering, setBuffering] = useState(false); const [cachedDuration, setCachedDuration] = useState(0); const [cachedPosition, setCachedPosition] = useState(0); const [currentVideoHeight, setCurrentVideoHeight] = useState( null, ); const [videoTrackApplied, setVideoTrackApplied] = useState(false); // ─── VLC track lists ─────────────────────────────────────────────────────── const [vlcAudioTracks, setVlcAudioTracks] = useState([]); const [vlcTextTracks, setVlcTextTracks] = useState([]); const [vlcVideoTracks, setVlcVideoTracks] = useState([]); const mapVlcTracks = ( tracks: any[], type: 'audio' | 'text' | 'video', ): any[] => { if (!tracks || !Array.isArray(tracks)) return []; return tracks.map((track) => ({ index: track.id, language: track.language || track.name || 'unknown', title: track.name || track.language || '', type, selected: !!track.selected, height: track.height, })); }; const audioTrackValue = useMemo(() => { if (playerType !== 'vlc') return undefined; const selectedAudioTrack = props.selectedAudioTrack; if (!selectedAudioTrack) return undefined; if (selectedAudioTrack.type === SelectedTrackType.INDEX) { const track = vlcAudioTracks[selectedAudioTrack.value as number]; return track ? track.index : undefined; } if (selectedAudioTrack.type === SelectedTrackType.LANGUAGE) { const track = vlcAudioTracks.find( (t) => t.language === selectedAudioTrack.value, ); return track ? track.index : undefined; } return undefined; }, [playerType, props.selectedAudioTrack, vlcAudioTracks]); const textTrackValue = useMemo(() => { if (playerType !== 'vlc') return undefined; const selectedTextTrack = props.selectedTextTrack; if (!selectedTextTrack) return undefined; if (selectedTextTrack.type === SelectedTrackType.DISABLED) { return 'none'; } if (selectedTextTrack.type === SelectedTrackType.INDEX) { const track = vlcTextTracks[selectedTextTrack.value as number]; return track ? track.index : undefined; } if (selectedTextTrack.type === SelectedTrackType.LANGUAGE) { const track = vlcTextTracks.find( (t) => t.language === selectedTextTrack.value, ); return track ? track.index : undefined; } return undefined; }, [playerType, props.selectedTextTrack, vlcTextTracks]); const videoTrackValue = useMemo(() => { if (playerType !== 'vlc') return undefined; if (!vlcVideoTracks || vlcVideoTracks.length === 0) { return undefined; } if (selectedQuality === 'auto') return undefined; const target = Number(selectedQuality); if (isNaN(target)) return undefined; const sorted = [...vlcVideoTracks].sort( (a, b) => Math.abs((a.height || 0) - target) - Math.abs((b.height || 0) - target), ); return sorted.length > 0 ? sorted[0].index : undefined; }, [playerType, selectedQuality, vlcVideoTracks]); const {clearControlTimeout, resetControlTimeout, setControlTimeout} = useControlTimeout({ controlTimeoutDelay, mounted: mounted.current, showControls, setShowControls, alwaysShowControls, }); const toggleFullscreen = useCallback( () => setIsFullscreen((prevState: boolean) => !prevState), [], ); const toggleControls = useCallback( () => setShowControls((prevState: boolean) => alwaysShowControls || !prevState), [alwaysShowControls], ); const toggleTimer = useCallback( () => setShowTimeRemaining((prevState: boolean) => !prevState), [], ); const togglePlayPause = useCallback(() => { setPaused((prevState: boolean) => !prevState); }, []); const styles = useMemo( () => ({ videoStyle, containerStyle: containerStyle, }), [videoStyle, containerStyle], ); const performSeek = useCallback( (timeInSeconds: number) => { if (!_viewRef.current) return; const target = Math.max(0, timeInSeconds); if (!isFinite(target)) { console.warn('[Player] Invalid seek target:', timeInSeconds); return; } try { if (playerType === 'vlc') { if (seekWatchdogRef.current) { clearTimeout(seekWatchdogRef.current); seekWatchdogRef.current = null; } setBuffering(true); setLoading(true); _viewRef.current.seek?.(target); console.log('[VLC-Seek] seek to', target); if (!_paused) { _viewRef.current?.play?.(); } seekWatchdogRef.current = setTimeout(() => { if (buffering || loading) { setBuffering(false); setLoading(false); if (!_paused) { _viewRef.current?.play?.(); } } seekWatchdogRef.current = null; }, 800); } else { // ─── FIX: Remember what time we asked ExoPlayer to seek to. // Relaxed tolerance and timeout to avoid dropping legitimate updates. pendingNativeSeekRef.current = {target, issuedAt: Date.now()}; if (pendingNativeSeekTimeoutRef.current) { clearTimeout(pendingNativeSeekTimeoutRef.current); pendingNativeSeekTimeoutRef.current = null; } // Force clear the guard after 5 seconds (was 3s) to be more lenient pendingNativeSeekTimeoutRef.current = setTimeout(() => { if (pendingNativeSeekRef.current) { console.log( '[Native] Seek guard timeout – clearing pending seek.', ); pendingNativeSeekRef.current = null; } pendingNativeSeekTimeoutRef.current = null; }, 5000); _viewRef.current.seek?.(target); } } catch (e) { console.warn('[Player] seek failed:', e); } }, [playerType, _paused, buffering, loading], ); const _onSeek = useCallback( (obj: OnSeekData) => { try { if (!seeking) { setControlTimeout(); } if (playerType !== 'vlc') { pendingNativeSeekRef.current = null; if (pendingNativeSeekTimeoutRef.current) { clearTimeout(pendingNativeSeekTimeoutRef.current); pendingNativeSeekTimeoutRef.current = null; } } const seekTimeInSeconds = playerType === 'vlc' && obj.currentTime ? obj.currentTime / 1000 : obj.currentTime || 0; const validCurrentTime = Math.max( 0, Math.min(seekTimeInSeconds, duration), ); setCurrentTime(validCurrentTime); if (typeof onSeek === 'function') { onSeek(obj); } if (seekWatchdogRef.current) { clearTimeout(seekWatchdogRef.current); seekWatchdogRef.current = null; } setBuffering(false); setLoading(false); if (!_paused && playerType === 'vlc' && _viewRef.current) { _viewRef.current?.play?.(); } } catch (error) { console.error('Error in _onSeek:', error); } }, [seeking, setControlTimeout, onSeek, duration, playerType, _paused], ); const _onEnd = useCallback(() => { if (currentTime < duration) { setCurrentTime(duration); setPaused(!props.repeat); if (showOnEnd) { setShowControls(!props.repeat); } } if (typeof onEnd === 'function') { onEnd(); } }, [currentTime, duration, props.repeat, showOnEnd, onEnd]); const _onError = useCallback(() => { setError(true); setLoading(false); }, []); const _onLoadStart = useCallback( (e: OnLoadStartData) => { setLoading(true); if (typeof onLoadStart === 'function') { onLoadStart(e); } }, [onLoadStart], ); const _onLoad = useCallback( (data: OnLoadData | any) => { const finalData = data.nativeEvent || data; const durationInSeconds = playerType === 'vlc' && finalData.duration ? finalData.duration / 1000 : finalData.duration; setDuration(durationInSeconds); setLoading(false); if (showControls) { setControlTimeout(); } if (finalData?.naturalSize?.height) { const height = finalData.naturalSize.height; console.log(`[Video] Natural height captured: ${height}`); setCurrentVideoHeight(height); setVideoTrackApplied(false); } if (playerType !== 'vlc') { if ( finalData.audioTracks && Array.isArray(finalData.audioTracks) && finalData.audioTracks.length > 0 && typeof props.onAudioTracks === 'function' ) { props.onAudioTracks({audioTracks: finalData.audioTracks}); } if (finalData.textTracks && typeof props.onTextTracks === 'function') { props.onTextTracks({textTracks: finalData.textTracks}); } } if (typeof props.onLoad === 'function') { props.onLoad({ ...data, duration: durationInSeconds, }); } mounted.current = true; }, [ showControls, setControlTimeout, props.onLoad, props.onAudioTracks, props.onTextTracks, playerType, ], ); const _onTracksChanged = useCallback( (e: any) => { const finalData = unwrapNativeEvent(e); const mappedAudio = mapVlcTracks(finalData?.audioTracks, 'audio'); const mappedText = mapVlcTracks(finalData?.textTracks, 'text'); let mappedVideo = mapVlcTracks(finalData?.videoTracks, 'video'); console.log( `[Player] Raw video tracks: ${mappedVideo.length}, currentVideoHeight: ${currentVideoHeight}`, ); if ( playerType === 'vlc' && mappedVideo.length === 0 && currentVideoHeight && !videoTrackApplied ) { console.log( `[Player] ✅ Creating synthetic video track from source: ${currentVideoHeight}`, ); mappedVideo = [ { index: 0, height: currentVideoHeight, width: Math.round((currentVideoHeight * 16) / 9), bitrate: 0, codecs: '', selected: true, type: 'video', }, ]; setVideoTrackApplied(true); } setVlcAudioTracks(mappedAudio); setVlcTextTracks(mappedText); setVlcVideoTracks(mappedVideo); if (typeof props.onAudioTracks === 'function') { props.onAudioTracks({audioTracks: mappedAudio}); } if (typeof props.onTextTracks === 'function') { props.onTextTracks({textTracks: mappedText}); } if (typeof props.onVideoTracks === 'function') { props.onVideoTracks({videoTracks: mappedVideo}); } }, [ props.onAudioTracks, props.onTextTracks, props.onVideoTracks, currentVideoHeight, playerType, videoTrackApplied, ], ); // ─── FIX: Force synthetic track for HLS after timeout ────────────────── useEffect(() => { if (playerType !== 'vlc') return; if (vlcVideoTracks.length > 0 || videoTrackApplied) return; if (loading) return; const timeout = setTimeout(() => { if (vlcVideoTracks.length === 0 && !videoTrackApplied) { const defaultHeight = 480; console.log( `[Player] ⏳ HLS no tracks – creating synthetic track with height ${defaultHeight}`, ); const syntheticTrack = { index: 0, height: defaultHeight, width: Math.round((defaultHeight * 16) / 9), bitrate: 0, codecs: '', selected: true, type: 'video', }; setVlcVideoTracks([syntheticTrack]); setVideoTrackApplied(true); if (typeof props.onVideoTracks === 'function') { props.onVideoTracks({videoTracks: [syntheticTrack]}); } } }, 2000); return () => clearTimeout(timeout); }, [ playerType, vlcVideoTracks, videoTrackApplied, loading, props.onVideoTracks, ]); const _onProgress = useCallback( (data: OnProgressData | any) => { const finalData = data.nativeEvent || data; setLoading(false); if (buffering) { setBuffering(false); if (seekWatchdogRef.current) { clearTimeout(seekWatchdogRef.current); seekWatchdogRef.current = null; } } if (!seeking) { const currentTimeInSeconds = playerType === 'vlc' && finalData.currentTime ? finalData.currentTime / 1000 : finalData.currentTime || 0; // ─── FIX: While a native seek is in flight, ignore stale ticks // with relaxed tolerance (1.2s) and longer guard (5s). if (playerType !== 'vlc' && pendingNativeSeekRef.current) { const {target, issuedAt} = pendingNativeSeekRef.current; const closeEnoughToTarget = Math.abs(currentTimeInSeconds - target) <= 1.2; // was 0.75 const guardExpired = Date.now() - issuedAt > 5000; // was 2500 if (!closeEnoughToTarget && !guardExpired) { return; } pendingNativeSeekRef.current = null; } const playableDurationInSeconds = playerType === 'vlc' && finalData.playableDuration ? finalData.playableDuration / 1000 : finalData.playableDuration; const clampedTime = Math.max( 0, Math.min(currentTimeInSeconds, duration), ); setCurrentTime(clampedTime); setCachedDuration(playableDurationInSeconds || duration); if (typeof props.onProgress === 'function') { props.onProgress({ ...data, currentTime: clampedTime, playableDuration: playableDurationInSeconds, }); } } }, [seeking, buffering, props.onProgress, duration, playerType], ); const _onScreenTouch = useCallback(() => { if (tapActionTimeout.current) { clearTimeout(tapActionTimeout.current); tapActionTimeout.current = null; toggleFullscreen(); if (showControls) { resetControlTimeout(); } } else { tapActionTimeout.current = setTimeout(() => { if (tapAnywhereToPause && showControls) { togglePlayPause(); resetControlTimeout(); } else { toggleControls(); } tapActionTimeout.current = null; }, doubleTapTime); } }, [ toggleFullscreen, showControls, resetControlTimeout, tapAnywhereToPause, togglePlayPause, toggleControls, doubleTapTime, ]); const _onPlaybackRateChange = useCallback( (playBack: {playbackRate: number}) => { if (playBack.playbackRate === 0 && !buffering) { setTimeout(() => { !buffering && setPaused(true); }); } else { setPaused(false); } }, [buffering], ); const _onVlcPlaybackStateChanged = useCallback( (e: any) => { const finalData = unwrapNativeEvent(e); const isPlaying = !!finalData?.isPlaying; setPaused(!isPlaying); syncTrackPlayerState(!isPlaying); if (typeof props.onPlaybackStateChanged === 'function') { props.onPlaybackStateChanged({isPlaying}); } }, [props.onPlaybackStateChanged], ); const _onVlcPlaying = useCallback(() => { setLoading(false); }, []); const events = useMemo( () => ({ onError: onError || _onError, onBack: (onBack || _onBack(navigator)) as () => void, onEnd: _onEnd, onScreenTouch: _onScreenTouch, onEnterFullscreen, onExitFullscreen, onShowControls, onHideControls, onLoadStart: _onLoadStart, onProgress: _onProgress, onSeek: _onSeek, onLoad: _onLoad, onPause, onPlay, onPlaybackRateChange: _onPlaybackRateChange, }), [ onError, _onError, onBack, navigator, _onEnd, _onScreenTouch, onEnterFullscreen, onExitFullscreen, onShowControls, onHideControls, _onLoadStart, _onProgress, _onSeek, _onLoad, onPause, onPlay, _onPlaybackRateChange, ], ); const constrainToSeekerMinMax = useCallback( (val = 0) => { if (val <= 0) return 0; if (val >= seekerWidth) return seekerWidth; return val; }, [seekerWidth], ); const constrainToVolumeMinMax = useCallback((val = 0) => { if (val <= 0) return 0; if (val >= volumeWidth) return volumeWidth; return val; }, []); const setSeekerPosition = useCallback( (position = 0) => { const positionValue = constrainToSeekerMinMax(position); setSeekerPositionState(positionValue); setSeekerFillWidth(positionValue); }, [constrainToSeekerMinMax], ); const setVolumePosition = useCallback( (position = 0) => { const positionValue = constrainToVolumeMinMax(position); setVolumePositionState(positionValue + iconOffset); setVolumeFillWidth(Math.max(0, positionValue)); }, [constrainToVolumeMinMax], ); const seekVideo = useCallback( (time: number) => { const clampedTime = Math.max(0, Math.min(time, duration)); setCurrentTime(clampedTime); performSeek(clampedTime); }, [performSeek, duration], ); const {volumePanResponder, seekPanResponder} = usePanResponders({ duration, volumePosition, loading, seekerWidth, volumeTrackWidth: volumeWidth, seeking, seekerPosition, seek: seekVideo, clearControlTimeout, setVolumePosition, setSeekerPosition, setSeeking, setControlTimeout, onEnd: events.onEnd, horizontal: horizontalPan, inverted: invertedPan, }); useEffect(() => { if (toggleResizeModeOnFullscreen) { setResizeMode(_isFullscreen ? ResizeMode.CONTAIN : ResizeMode.COVER); } if (mounted.current) { if (_isFullscreen) { events.onEnterFullscreen?.(); } else { events.onExitFullscreen?.(); } } }, [_isFullscreen, toggleResizeModeOnFullscreen, events]); useEffect(() => { setIsFullscreen(isFullscreen); }, [isFullscreen]); useEffect(() => { setPaused(paused); }, [paused]); useEffect(() => { if (_paused) { events.onPause?.(); clearControlTimeout(); setShowControls(true); syncTrackPlayerState(true); } else { events.onPlay?.(); syncTrackPlayerState(false); } }, [_paused]); const updateSeekerPositionRef = useRef(null); useEffect(() => { if (!seeking && currentTime && duration && seekerWidth) { if (updateSeekerPositionRef.current) { cancelAnimationFrame(updateSeekerPositionRef.current); } updateSeekerPositionRef.current = requestAnimationFrame(() => { const percent = currentTime / duration; const position = seekerWidth * percent; const cachedPercent = cachedDuration / duration; const _cachedPosition = seekerWidth * cachedPercent; const newCachedPosition = constrainToSeekerMinMax(_cachedPosition); setCachedPosition(newCachedPosition); setSeekerPosition(position); updateSeekerPositionRef.current = null; }); } return () => { if (updateSeekerPositionRef.current) { cancelAnimationFrame(updateSeekerPositionRef.current); } }; }, [ currentTime, duration, seekerWidth, cachedDuration, constrainToSeekerMinMax, setSeekerPosition, ]); useEffect(() => { if (seeking && seekerPosition && seekerWidth && duration) { const percent = seekerPosition / seekerWidth; const newTime = duration * percent; setCurrentTime(newTime); } }, [seeking, seekerPosition, seekerWidth, duration]); useEffect(() => { if (showControls) { animations.showControlAnimation(); if (!_paused) { setControlTimeout(); } onShowControls?.(); } else { animations.hideControlAnimation(); clearControlTimeout(); onHideControls?.(); } }, [ showControls, _paused, animations, setControlTimeout, clearControlTimeout, onShowControls, onHideControls, ]); useEffect(() => { setMuted(muted); }, [muted]); const updateVolumeRef = useRef(null); useEffect(() => { if (updateVolumeRef.current) { cancelAnimationFrame(updateVolumeRef.current); } updateVolumeRef.current = requestAnimationFrame(() => { const newVolume = (volumePosition / volumeWidth) * 2; setMuted(newVolume <= 0); setVolume(newVolume); setVolumeTrackWidth(Math.max(0, volumeWidth - volumeFillWidth)); updateVolumeRef.current = null; }); return () => { if (updateVolumeRef.current) { cancelAnimationFrame(updateVolumeRef.current); } }; }, [volumeFillWidth, volumePosition]); useEffect(() => { mounted.current = true; return () => { mounted.current = false; clearControlTimeout(); if (updateSeekerPositionRef.current) { cancelAnimationFrame(updateSeekerPositionRef.current); } if (updateVolumeRef.current) { cancelAnimationFrame(updateVolumeRef.current); } if (seekWatchdogRef.current) { clearTimeout(seekWatchdogRef.current); seekWatchdogRef.current = null; } pendingNativeSeekRef.current = null; if (pendingNativeSeekTimeoutRef.current) { clearTimeout(pendingNativeSeekTimeoutRef.current); pendingNativeSeekTimeoutRef.current = null; } }; }, [clearControlTimeout]); useEffect(() => { setPlaybackRate(rate); }, [rate]); const currentTimeRef = useRef(currentTime); currentTimeRef.current = currentTime; const rewind = useCallback( (time?: number) => { const base = currentTimeRef.current; const rawTime = typeof time === 'number' ? base - time : base - rewindTime; const newTime = Math.max(0, rawTime); setCurrentTime(newTime); performSeek(newTime); }, [rewindTime, performSeek], ); const forward = useCallback( (time?: number) => { const base = currentTimeRef.current; const rawTime = typeof time === 'number' ? base + time : base + rewindTime; const newTime = duration > 0 ? Math.min(rawTime, duration) : rawTime; setCurrentTime(newTime); performSeek(newTime); }, [rewindTime, performSeek, duration], ); const onBuffer = useCallback((e: any) => { const data = unwrapNativeEvent(e); setBuffering(!!data?.isBuffering); }, []); const handleGestureVolumeChange = useCallback( (effectiveVolume: number) => { const newSliderPosition = (effectiveVolume / 2) * volumeWidth; setVolumePosition(newSliderPosition); }, [setVolumePosition], ); const sourceUri = useMemo(() => { if (!source) return null; if (typeof source === 'object' && 'uri' in source) { return source.uri; } if (typeof source === 'object') { return JSON.stringify(source); } return String(source); }, [source]); const prevSourceUri = useRef(sourceUri); const hasInitialized = useRef(false); useEffect(() => { if ( hasInitialized.current && sourceUri !== prevSourceUri.current && sourceUri !== null ) { prevSourceUri.current = sourceUri; setLoading(true); setSeekerFillWidth(0); setSeekerPosition(0); setCachedPosition(0); setCurrentTime(0); setVideoTrackApplied(false); pendingNativeSeekRef.current = null; if (pendingNativeSeekTimeoutRef.current) { clearTimeout(pendingNativeSeekTimeoutRef.current); pendingNativeSeekTimeoutRef.current = null; } } else if (!hasInitialized.current) { prevSourceUri.current = sourceUri; hasInitialized.current = true; } }, [sourceUri, setSeekerPosition]); // ─── FIX: Expose enterPictureInPicture method ────────────────────────── useImperativeHandle(props.videoRef as any, () => ({ seek: (time: number) => { if (typeof time === 'number' && !isNaN(time) && time >= 0) { performSeek(time); } }, pause: () => { _viewRef.current?.pause?.(); }, resume: () => { _viewRef.current?.play?.(); }, snapshot: async () => { return await _viewRef.current?.snapshot?.(); }, // ─── NEW: PiP method ────────────────────────────────────────────────── enterPictureInPicture: () => { if (_viewRef.current?.enterPictureInPicture) { _viewRef.current.enterPictureInPicture(); } else { console.warn('[Player] enterPictureInPicture not available'); } }, })); const sourceObject = typeof source === 'object' && source ? source : {}; const vlcHeaders = (sourceObject as any).headers; const vlcSource = useMemo(() => { let uri = ''; if (typeof source === 'object' && (source as any)?.uri) { uri = (source as any).uri; } else if (typeof source === 'string') { uri = source; } uri = String(uri || ''); // ✅ Normalize local URI – ensure file:// prefix uri = normalizeLocalUri(uri); const headerEntries: [string, string][] = vlcHeaders ? Object.entries(vlcHeaders as Record) : []; const findHeader = (names: string[]) => { const hit = headerEntries.find(([key]) => names.includes(key.toLowerCase()), ); return hit ? hit[1] : undefined; }; const referer = findHeader(['referer', 'referrer']); const userAgent = findHeader(['user-agent', 'useragent']); return {uri, referer, userAgent}; }, [source, vlcHeaders]); // ─── VLC media options – Optimised for both local and HLS ──────────────── const vlcMediaOptions = useMemo(() => { const opts: string[] = []; const uri = String(vlcSource.uri || ''); const isLocal = isLocalFile(uri); const isHls = uri.includes('.m3u8') || uri.includes('master.m3u8'); console.log( `[VLC] Media options for ${isLocal ? 'LOCAL' : 'NETWORK'} file: ${uri}`, ); const cachingMs = isLocal ? 2000 : isHls ? 15000 : 10000; opts.push(`:network-caching=${cachingMs}`); opts.push(`:file-caching=${isLocal ? 1000 : 8000}`); opts.push(`:live-caching=${isLocal ? 1000 : 5000}`); if (isHls) { opts.push(':hls-adaptive'); opts.push(':hls-maxbitrate=0'); opts.push(':hls-use-cached'); opts.push(':http-reconnect'); } opts.push(':fast-seek'); opts.push(':clock-jitter=0'); opts.push(':clock-synchro=0'); opts.push(':drop-late-frames'); opts.push(':skip-frames'); opts.push(':avcodec-fast-decode=1'); if (isLocal) { opts.push(':no-http-simple'); opts.push(':no-http-proxy'); opts.push(`:sout-keep`); } if (vlcMediaOptionsProp?.length) { opts.push(...vlcMediaOptionsProp); } return opts.length ? opts : undefined; }, [vlcSource.uri, vlcMediaOptionsProp]); const vlcResizeMode = useMemo(() => { const mode = String(_resizeMode).toLowerCase(); if (mode === 'none') return 'original'; if (mode === 'cover' || mode === 'stretch' || mode === 'original') { return mode; } return 'contain'; }, [_resizeMode]); // ─── Notification Integration (TrackPlayer) ────────────────────────────── const [trackPlayerReady, setTrackPlayerReady] = useState(false); const trackPlayerInitAttempted = useRef(false); const syncTrackPlayerState = useCallback( (isPaused: boolean) => { if (!trackPlayerReady || !TrackPlayer) { return; } console.log( `[Media-Notification] Syncing TrackPlayer ${ isPaused ? 'pause' : 'play' }`, ); (async () => { try { if (isPaused) { await TrackPlayer.pause(); } else { await TrackPlayer.play(); } } catch (e) { console.warn('[Media-Notification] Sync error:', e); } })(); }, [trackPlayerReady], ); useEffect(() => { if ( !TrackPlayerInstalled || !effectiveShowNotifications || trackPlayerInitAttempted.current ) { return; } trackPlayerInitAttempted.current = true; let isMounted = true; const initTrackPlayer = async () => { try { console.log('[Media-Notification] Setting up TrackPlayer...'); let isSetup = false; try { await TrackPlayer.getPlaybackState(); isSetup = true; } catch (e) { isSetup = false; } if (!isSetup) { await TrackPlayer.setupPlayer({ android: { channelId: 'media_player_channel', channelName: 'Media Player', channelDescription: 'Shows playback controls for the player', importance: 4, notificationIcon: 'ic_notification', }, }); } await TrackPlayer.updateOptions({ capabilities: [ Capability.Play, Capability.Pause, Capability.SeekTo, Capability.Stop, ], compactCapabilities: [Capability.Play, Capability.Pause], notificationCapabilities: [ Capability.Play, Capability.Pause, Capability.SeekTo, ], alwaysPauseOnInterruption: true, progressUpdateEventInterval: 2, }); if (isMounted) { setTrackPlayerReady(true); await addTrackToPlayer(); } } catch (e) { console.warn('[Media-Notification] TrackPlayer init failed:', e); setTimeout(() => { if (isMounted && !trackPlayerReady) { initTrackPlayer(); } }, 500); } }; const addTrackToPlayer = async () => { if (!TrackPlayer || !isMounted) return; try { await TrackPlayer.reset(); const trackDuration = duration || 3600; // ─── FIX: ensure poster is a valid URL, fallback to empty string ── let artwork = props.poster || ''; // If poster is a local file, TrackPlayer might not handle it, but we keep as is. await TrackPlayer.add([ { id: 'video', url: sourceUri || 'https://example.com/dummy.mp3', title: title.primary || 'Video Playing', artist: title.secondary || '', duration: trackDuration, artwork, }, ]); await TrackPlayer.setVolume(0); if (!_paused) { await TrackPlayer.play(); } else { await TrackPlayer.pause(); } } catch (e) { console.warn('[Media-Notification] Failed to add track:', e); } }; initTrackPlayer(); return () => { isMounted = false; trackPlayerInitAttempted.current = false; }; }, [ effectiveShowNotifications, sourceUri, title, duration, props.poster, _paused, ]); useEffect(() => { if (!TrackPlayer || !trackPlayerReady || !Event) return; const subPlay = TrackPlayer.addEventListener(Event.RemotePlay, () => { setPaused(false); }); const subPause = TrackPlayer.addEventListener(Event.RemotePause, () => { setPaused(true); }); const subSeek = TrackPlayer.addEventListener(Event.RemoteSeek, (e: any) => { if (e.position !== undefined) { seekVideo(e.position); TrackPlayer.seekTo(e.position); } }); return () => { subPlay?.remove(); subPause?.remove(); subSeek?.remove(); }; }, [trackPlayerReady, seekVideo]); useEffect(() => { if (!TrackPlayer || !trackPlayerReady) return; (async () => { try { const track = await TrackPlayer.getTrack(0); if (track) { await TrackPlayer.updateMetadataForTrack(0, { title: title.primary || 'Video Playing', artist: title.secondary || '', duration: duration || 3600, artwork: props.poster || undefined, }); } } catch (e) { console.warn('[Media-Notification] Metadata update failed:', e); } })(); }, [trackPlayerReady, title, duration, props.poster]); useEffect(() => { if (trackPlayerReady) { syncTrackPlayerState(_paused); } }, [_paused, trackPlayerReady, syncTrackPlayerState]); // ─── PiP: enable background playback if PiP is requested and not overridden ── const effectivePlayInBackground = useMemo(() => { if (playInBackground !== undefined) return playInBackground; return pictureInPicture; }, [playInBackground, pictureInPicture]); return ( {playerType === 'vlc' ? ( ) : ( ); }; const CustomAnimations = ({ useAnimations, controlAnimationTiming = 450, ...props }: WithRequiredProperty) => { const animations = useAnimations(controlAnimationTiming); return ; }; const JSAnimations = (props: VideoPlayerWithVLCProps) => { const animations = useJSAnimations(props.controlAnimationTiming); return ; }; export const VideoPlayer = ( props: Omit, ) => { if (props?.useAnimations) { return ; } return ; };