diff --git a/android/src/main/java/com/brentvatne/common/react/VideoEventEmitter.kt b/android/src/main/java/com/brentvatne/common/react/VideoEventEmitter.kt index 161b79be..acf21832 100644 --- a/android/src/main/java/com/brentvatne/common/react/VideoEventEmitter.kt +++ b/android/src/main/java/com/brentvatne/common/react/VideoEventEmitter.kt @@ -73,7 +73,7 @@ class VideoEventEmitter { lateinit var onVideoBandwidthUpdate: (bitRateEstimate: Long, height: Int, width: Int, trackId: String?) -> Unit lateinit var onVideoPlaybackStateChanged: (isPlaying: Boolean, isSeeking: Boolean) -> Unit lateinit var onVideoSeek: (currentPosition: Long, seekTime: Long) -> Unit - lateinit var onVideoSeekComplete: (currentPosition: Long) -> Unit + lateinit var onVideoSeekComplete: (currentPosition: Long, seekTime: Long) -> Unit lateinit var onVideoEnd: () -> Unit lateinit var onVideoFullscreenPlayerWillPresent: () -> Unit lateinit var onVideoFullscreenPlayerDidPresent: () -> Unit @@ -202,9 +202,11 @@ class VideoEventEmitter { putDouble("seekTime", seekTime / 1000.0) } } - onVideoSeekComplete = { currentPosition -> + onVideoSeekComplete = { currentPosition, seekTime -> event.dispatch(EventTypes.EVENT_SEEK_COMPLETE) { putDouble("currentTime", currentPosition / 1000.0) + putDouble("seekTime", seekTime / 1000.0) + putInt("target", view.id) } } onVideoEnd = { diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index cd7963e5..205b4d94 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -319,7 +319,10 @@ public class ReactExoplayerView extends FrameLayout implements private void handleSeekCompletion() { if (player != null && player.getPlaybackState() == Player.STATE_READY && isSeekInProgress) { Log.d("ReactExoplayerView", "handleSeekCompletion: currentPosition=" + player.getCurrentPosition()); - eventEmitter.onVideoSeekComplete.invoke(player.getCurrentPosition()); + if (isSeeking) { + eventEmitter.onVideoSeek.invoke(player.getCurrentPosition(), seekPosition); + } + eventEmitter.onVideoSeekComplete.invoke(player.getCurrentPosition(), seekPosition); isSeeking = false; seekPosition = -1; isSeekInProgress = false; @@ -1847,6 +1850,7 @@ public class ReactExoplayerView extends FrameLayout implements // We need to update the selected track to make sure that it still matches user selection if track list has changed in this period setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue); } + handleSeekCompletion(); } if (playerNeedsSource) { diff --git a/docs/pages/component/props.mdx b/docs/pages/component/props.mdx index aac1b92e..e6f60d7e 100644 --- a/docs/pages/component/props.mdx +++ b/docs/pages/component/props.mdx @@ -801,8 +801,6 @@ The documentation for this prop is incomplete and will be updated as each option - - Example: Pass the asset directly (deprecated): diff --git a/docs/pages/installation.md b/docs/pages/installation.md index a0fb83f5..3873ec64 100644 --- a/docs/pages/installation.md +++ b/docs/pages/installation.md @@ -22,6 +22,7 @@ Then follow the instructions for your platform to link `react-native-video` into ## iOS ### Standard Method + Run `pod install` in the `ios` directory of your project. ⚠️ From version `6.0.0`, the minimum iOS version required is `13.0`. For more information, see the [updating section](updating.md). @@ -100,6 +101,7 @@ You can enable or disable the following features by setting the corresponding va Each enabled feature increases the APK size, so only enable what you need. By default, the enabled features are: + - `useExoplayerSmoothStreaming` - `useExoplayerDash` - `useExoplayerHls` @@ -222,17 +224,10 @@ Run `pod install` in the `visionos` directory of your project. ## Web -No additional setup is required. Everything should work out of the box. +No additional setup is required. -However, only basic video support is available. HLS, Dash, ads, and DRM are not currently supported. - - - -
-web - -Nothing to do, everything should work out of the box. - -Note that only basic video support is present, no hls/dash or ads/drm for now. +The Railbird fork uses Shaka Player for browser playback, including HLS and DASH +sources. The native ads and DRM props are not currently integrated with the web +player.
diff --git a/src/Video.web.tsx b/src/Video.web.tsx index f7c6e90e..51512026 100644 --- a/src/Video.web.tsx +++ b/src/Video.web.tsx @@ -66,39 +66,25 @@ class ActionQueue { } } -function shallowEqual(obj1: unknown, obj2: unknown): boolean { - // If both are strictly equal (covers primitive types and identical object references) - if (obj1 === obj2) { +function isDeepEqual(first: T, second: T): boolean { + if (first === second) { return true; } - // If one is not an object (meaning it's a primitive), they must be strictly equal - if ( - typeof obj1 !== 'object' || - typeof obj2 !== 'object' || - obj1 === null || - obj2 === null - ) { + const bothAreObjects = + first && second && typeof first === 'object' && typeof second === 'object'; + if (!bothAreObjects) { return false; } - const first = obj1 as Record; - const second = obj2 as Record; - - // Get the keys of both objects - const keys1 = Object.keys(first); - const keys2 = Object.keys(second); - - // If the number of keys is different, the objects are not equal - if (keys1.length !== keys2.length) { - return false; - } - - // Check that all keys and their corresponding values are the same - return keys1.every((key) => { - // If the value is an object, we fall back to reference equality (shallow comparison) - return first[key] === second[key]; - }); + const firstObject = first as Record; + const secondObject = second as Record; + return ( + Object.keys(firstObject).length === Object.keys(secondObject).length && + Object.entries(firstObject).every(([key, value]) => + isDeepEqual(value, secondObject[key]), + ) + ); } const Video = forwardRef( @@ -136,10 +122,17 @@ const Video = forwardRef( const shakaPlayerRef = useRef(null); const [activeSource, setActiveSource] = useState(source); const sourceProp = useRef(source); - const [loadedSource, setLoadedSource] = useState(null); + const [loadedSource, setLoadedSource] = useState<{ + source: ReactVideoSource | undefined; + } | null>(null); const actionQueue = useRef(new ActionQueue()); + const mountedRef = useRef(true); + const sourceGenerationRef = useRef(0); + const pausedRef = useRef(paused); + pausedRef.current = paused; const isSeeking = useRef(false); + const isProgrammaticSeek = useRef(false); const seek = useCallback( (time: number, _tolerance?: number) => { @@ -152,7 +145,13 @@ const Video = forwardRef( return; } time = Math.max(0, Math.min(time, nativeRef.current.duration)); - nativeRef.current.currentTime = time; + isProgrammaticSeek.current = true; + try { + nativeRef.current.currentTime = time; + } catch (error) { + isProgrammaticSeek.current = false; + throw error; + } onSeek?.({ seekTime: time, currentTime: nativeRef.current.currentTime, @@ -210,7 +209,7 @@ const Video = forwardRef( }, []); useEffect(() => { - if (shallowEqual(source, sourceProp.current)) { + if (isDeepEqual(source, sourceProp.current)) { return; } sourceProp.current = source; @@ -286,22 +285,22 @@ const Video = forwardRef( ); const enterPictureInPicture = useCallback(() => { - actionQueue.current.enqueue(async () => { - if (!nativeRef.current) { - console.warn('Video Component is not mounted'); - return; - } - await nativeRef.current.requestPictureInPicture(); - }, 'enterPictureInPicture'); + if (!nativeRef.current) { + console.warn('Video Component is not mounted'); + return; + } + nativeRef.current.requestPictureInPicture().catch((error: unknown) => { + console.error('Could not enter Picture-in-Picture', error); + }); }, []); const exitPictureInPicture = useCallback(() => { - actionQueue.current.enqueue(async () => { - if (nativeRef.current !== document.pictureInPictureElement) { - return; - } - await document.exitPictureInPicture(); - }, 'exitPictureInPicture'); + if (nativeRef.current !== document.pictureInPictureElement) { + return; + } + document.exitPictureInPicture().catch((error: unknown) => { + console.error('Could not exit Picture-in-Picture', error); + }); }, []); useImperativeHandle( @@ -382,46 +381,76 @@ const Video = forwardRef( }, [rate]); const makeNewShaka = useCallback(() => { + const sourceToLoad = activeSource; + const generation = ++sourceGenerationRef.current; + isSeeking.current = false; + isProgrammaticSeek.current = false; + actionQueue.current.enqueue(async () => { - if (!nativeRef.current) { + const video = nativeRef.current; + if ( + !mountedRef.current || + !video || + generation !== sourceGenerationRef.current + ) { console.warn('No video element to attach Shaka Player'); return; } // Pause the video before changing the source - nativeRef.current.pause(); + video.pause(); // Unload the previous Shaka player if it exists - if (shakaPlayerRef.current) { - await shakaPlayerRef.current.unload(); - await shakaPlayerRef.current.destroy(); + const previousPlayer = shakaPlayerRef.current; + if (previousPlayer) { shakaPlayerRef.current = null; + await previousPlayer.unload().catch((error: unknown) => { + console.error('Error unloading previous Shaka Player', error); + }); + await previousPlayer.destroy().catch((error: unknown) => { + console.error('Error destroying previous Shaka Player', error); + }); } - const sourceUri = activeSource?.uri; + if ( + !mountedRef.current || + nativeRef.current !== video || + generation !== sourceGenerationRef.current + ) { + return; + } + + const sourceUri = sourceToLoad?.uri; if (typeof sourceUri !== 'string' || !sourceUri) { - nativeRef.current.removeAttribute('src'); - nativeRef.current.load(); + video.removeAttribute('src'); + video.load(); return; } // Create a new Shaka player and attach it to the video element - shakaPlayerRef.current = new shaka.Player(); + const player = new shaka.Player(); + shakaPlayerRef.current = player; - await shakaPlayerRef.current.attach(nativeRef.current); + const isCurrentPlayer = () => + mountedRef.current && + nativeRef.current === video && + generation === sourceGenerationRef.current && + shakaPlayerRef.current === player; - if (activeSource.cropStart) { - shakaPlayerRef.current.configure({ - playRangeStart: activeSource.cropStart / 1000, + const destroyStalePlayer = async () => { + if (shakaPlayerRef.current !== player) { + return; + } + shakaPlayerRef.current = null; + await player.destroy().catch((error: unknown) => { + console.error('Error destroying stale Shaka Player', error); }); - } - if (activeSource.cropEnd) { - shakaPlayerRef.current.configure({ - playRangeEnd: activeSource.cropEnd / 1000, - }); - } + }; - shakaPlayerRef.current.addEventListener('error', (event) => { + player.addEventListener('error', (event) => { + if (!isCurrentPlayer()) { + return; + } const shakaError = ( event as CustomEvent<{message?: string; code?: number}> ).detail; @@ -438,18 +467,43 @@ const Video = forwardRef( // Load the new source try { - await shakaPlayerRef.current.load(sourceUri); + await player.attach(video); + if (!isCurrentPlayer()) { + await destroyStalePlayer(); + return; + } + + if (sourceToLoad.cropStart) { + player.configure({ + playRangeStart: sourceToLoad.cropStart / 1000, + }); + } + if (sourceToLoad.cropEnd) { + player.configure({ + playRangeEnd: sourceToLoad.cropEnd / 1000, + }); + } + + await player.load(sourceUri); + if (!isCurrentPlayer()) { + await destroyStalePlayer(); + return; + } console.log(`${sourceUri} finished loading`); // Optionally resume playback if not paused - if (!paused) { + if (!pausedRef.current) { try { - await nativeRef.current.play(); + await video.play(); } catch (e) { console.error('Error playing video:', e); } } } catch (e) { + if (!isCurrentPlayer()) { + await destroyStalePlayer(); + return; + } console.error('Error loading video with Shaka Player', e); const shakaError = e as {message?: string; code?: number}; onError?.({ @@ -460,7 +514,7 @@ const Video = forwardRef( }); } }, 'makeNewShaka'); - }, [activeSource, paused, onError]); + }, [activeSource, onError]); const nativeRefDefined = !!nativeRef.current; @@ -471,20 +525,26 @@ const Video = forwardRef( ); return; } - if (!shallowEqual(activeSource, loadedSource)) { + if ( + loadedSource === null || + !isDeepEqual(activeSource, loadedSource.source) + ) { console.log( 'Making new shaka, Old source: ', - loadedSource, + loadedSource?.source, 'New source', activeSource, ); - setLoadedSource(activeSource ?? null); + setLoadedSource({source: activeSource}); makeNewShaka(); } }, [activeSource, nativeRefDefined, loadedSource, makeNewShaka]); - useEffect( - () => () => { + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + sourceGenerationRef.current += 1; const player = shakaPlayerRef.current; shakaPlayerRef.current = null; if (player) { @@ -492,9 +552,8 @@ const Video = forwardRef( console.error('Error destroying Shaka Player', error); }); } - }, - [], - ); + }; + }, []); useEffect(() => { if ( @@ -612,11 +671,19 @@ const Video = forwardRef( } onSeeking={() => (isSeeking.current = true)} onSeeked={() => { + const currentTime = nativeRef.current?.currentTime || 0.0; + if (isSeeking.current && !isProgrammaticSeek.current) { + onSeek?.({ + currentTime, + seekTime: currentTime, + }); + } + isSeeking.current = false; + isProgrammaticSeek.current = false; onSeekComplete?.({ - currentTime: - (nativeRef.current?.currentTime || 0.0) - cropStartSeconds, + currentTime: currentTime - cropStartSeconds, seekTime: 0.0, target: 0.0, });