Fix Railbird regressions found during v6.19.2 review
Some checks failed
Build Android / Build Android Example App (pull_request) Has been cancelled
Build Android / Build Android Example App With Ads (pull_request) Has been cancelled
Build iOS / Build iOS Example App (pull_request) Has been cancelled
Build iOS / Build iOS Example App With Ads (pull_request) Has been cancelled
Build iOS / Build iOS Example App With Caching (pull_request) Has been cancelled
Check Android / Kotlin-Lint (pull_request) Has been cancelled
Check iOS / Swift-Lint (pull_request) Has been cancelled
Check iOS / Swift-Format (pull_request) Has been cancelled
Check JS / Check TS (tsc) (pull_request) Has been cancelled
Check JS / Lint JS (eslint, prettier) (pull_request) Has been cancelled

This commit is contained in:
2026-08-04 02:55:23 -07:00
parent 5a1be8ba4c
commit 1f513d4b2b
5 changed files with 159 additions and 93 deletions

View File

@@ -73,7 +73,7 @@ class VideoEventEmitter {
lateinit var onVideoBandwidthUpdate: (bitRateEstimate: Long, height: Int, width: Int, trackId: String?) -> Unit lateinit var onVideoBandwidthUpdate: (bitRateEstimate: Long, height: Int, width: Int, trackId: String?) -> Unit
lateinit var onVideoPlaybackStateChanged: (isPlaying: Boolean, isSeeking: Boolean) -> Unit lateinit var onVideoPlaybackStateChanged: (isPlaying: Boolean, isSeeking: Boolean) -> Unit
lateinit var onVideoSeek: (currentPosition: Long, seekTime: Long) -> 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 onVideoEnd: () -> Unit
lateinit var onVideoFullscreenPlayerWillPresent: () -> Unit lateinit var onVideoFullscreenPlayerWillPresent: () -> Unit
lateinit var onVideoFullscreenPlayerDidPresent: () -> Unit lateinit var onVideoFullscreenPlayerDidPresent: () -> Unit
@@ -202,9 +202,11 @@ class VideoEventEmitter {
putDouble("seekTime", seekTime / 1000.0) putDouble("seekTime", seekTime / 1000.0)
} }
} }
onVideoSeekComplete = { currentPosition -> onVideoSeekComplete = { currentPosition, seekTime ->
event.dispatch(EventTypes.EVENT_SEEK_COMPLETE) { event.dispatch(EventTypes.EVENT_SEEK_COMPLETE) {
putDouble("currentTime", currentPosition / 1000.0) putDouble("currentTime", currentPosition / 1000.0)
putDouble("seekTime", seekTime / 1000.0)
putInt("target", view.id)
} }
} }
onVideoEnd = { onVideoEnd = {

View File

@@ -319,7 +319,10 @@ public class ReactExoplayerView extends FrameLayout implements
private void handleSeekCompletion() { private void handleSeekCompletion() {
if (player != null && player.getPlaybackState() == Player.STATE_READY && isSeekInProgress) { if (player != null && player.getPlaybackState() == Player.STATE_READY && isSeekInProgress) {
Log.d("ReactExoplayerView", "handleSeekCompletion: currentPosition=" + player.getCurrentPosition()); 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; isSeeking = false;
seekPosition = -1; seekPosition = -1;
isSeekInProgress = false; 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 // 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); setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue);
} }
handleSeekCompletion();
} }
if (playerNeedsSource) { if (playerNeedsSource) {

View File

@@ -801,8 +801,6 @@ The documentation for this prop is incomplete and will be updated as each option
<PlatformsList types={['Android', 'iOS', 'visionOS', 'Windows UWP']} /> <PlatformsList types={['Android', 'iOS', 'visionOS', 'Windows UWP']} />
<PlatformsList types={['Android', 'iOS', 'visionOS', 'Windows UWP']} />
Example: Example:
Pass the asset directly (deprecated): Pass the asset directly (deprecated):

View File

@@ -22,6 +22,7 @@ Then follow the instructions for your platform to link `react-native-video` into
## iOS ## iOS
### Standard Method ### Standard Method
Run `pod install` in the `ios` directory of your project. 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). ⚠️ 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. Each enabled feature increases the APK size, so only enable what you need.
By default, the enabled features are: By default, the enabled features are:
- `useExoplayerSmoothStreaming` - `useExoplayerSmoothStreaming`
- `useExoplayerDash` - `useExoplayerDash`
- `useExoplayerHls` - `useExoplayerHls`
@@ -222,17 +224,10 @@ Run `pod install` in the `visionos` directory of your project.
## Web ## 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. 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
</details> player.
<details>
<summary>web</summary>
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.
</details> </details>

View File

@@ -66,39 +66,25 @@ class ActionQueue {
} }
} }
function shallowEqual(obj1: unknown, obj2: unknown): boolean { function isDeepEqual<T>(first: T, second: T): boolean {
// If both are strictly equal (covers primitive types and identical object references) if (first === second) {
if (obj1 === obj2) {
return true; return true;
} }
// If one is not an object (meaning it's a primitive), they must be strictly equal const bothAreObjects =
if ( first && second && typeof first === 'object' && typeof second === 'object';
typeof obj1 !== 'object' || if (!bothAreObjects) {
typeof obj2 !== 'object' ||
obj1 === null ||
obj2 === null
) {
return false; return false;
} }
const first = obj1 as Record<string, unknown>; const firstObject = first as Record<string, unknown>;
const second = obj2 as Record<string, unknown>; const secondObject = second as Record<string, unknown>;
return (
// Get the keys of both objects Object.keys(firstObject).length === Object.keys(secondObject).length &&
const keys1 = Object.keys(first); Object.entries(firstObject).every(([key, value]) =>
const keys2 = Object.keys(second); isDeepEqual(value, secondObject[key]),
)
// 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 Video = forwardRef<VideoRef, ReactVideoProps>( const Video = forwardRef<VideoRef, ReactVideoProps>(
@@ -136,10 +122,17 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
const shakaPlayerRef = useRef<shaka.Player | null>(null); const shakaPlayerRef = useRef<shaka.Player | null>(null);
const [activeSource, setActiveSource] = useState(source); const [activeSource, setActiveSource] = useState(source);
const sourceProp = useRef(source); const sourceProp = useRef(source);
const [loadedSource, setLoadedSource] = useState<object | null>(null); const [loadedSource, setLoadedSource] = useState<{
source: ReactVideoSource | undefined;
} | null>(null);
const actionQueue = useRef(new ActionQueue()); 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 isSeeking = useRef(false);
const isProgrammaticSeek = useRef(false);
const seek = useCallback( const seek = useCallback(
(time: number, _tolerance?: number) => { (time: number, _tolerance?: number) => {
@@ -152,7 +145,13 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
return; return;
} }
time = Math.max(0, Math.min(time, nativeRef.current.duration)); 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?.({ onSeek?.({
seekTime: time, seekTime: time,
currentTime: nativeRef.current.currentTime, currentTime: nativeRef.current.currentTime,
@@ -210,7 +209,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
}, []); }, []);
useEffect(() => { useEffect(() => {
if (shallowEqual(source, sourceProp.current)) { if (isDeepEqual(source, sourceProp.current)) {
return; return;
} }
sourceProp.current = source; sourceProp.current = source;
@@ -286,22 +285,22 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
); );
const enterPictureInPicture = useCallback(() => { const enterPictureInPicture = useCallback(() => {
actionQueue.current.enqueue(async () => { if (!nativeRef.current) {
if (!nativeRef.current) { console.warn('Video Component is not mounted');
console.warn('Video Component is not mounted'); return;
return; }
} nativeRef.current.requestPictureInPicture().catch((error: unknown) => {
await nativeRef.current.requestPictureInPicture(); console.error('Could not enter Picture-in-Picture', error);
}, 'enterPictureInPicture'); });
}, []); }, []);
const exitPictureInPicture = useCallback(() => { const exitPictureInPicture = useCallback(() => {
actionQueue.current.enqueue(async () => { if (nativeRef.current !== document.pictureInPictureElement) {
if (nativeRef.current !== document.pictureInPictureElement) { return;
return; }
} document.exitPictureInPicture().catch((error: unknown) => {
await document.exitPictureInPicture(); console.error('Could not exit Picture-in-Picture', error);
}, 'exitPictureInPicture'); });
}, []); }, []);
useImperativeHandle( useImperativeHandle(
@@ -382,46 +381,76 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
}, [rate]); }, [rate]);
const makeNewShaka = useCallback(() => { const makeNewShaka = useCallback(() => {
const sourceToLoad = activeSource;
const generation = ++sourceGenerationRef.current;
isSeeking.current = false;
isProgrammaticSeek.current = false;
actionQueue.current.enqueue(async () => { 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'); console.warn('No video element to attach Shaka Player');
return; return;
} }
// Pause the video before changing the source // Pause the video before changing the source
nativeRef.current.pause(); video.pause();
// Unload the previous Shaka player if it exists // Unload the previous Shaka player if it exists
if (shakaPlayerRef.current) { const previousPlayer = shakaPlayerRef.current;
await shakaPlayerRef.current.unload(); if (previousPlayer) {
await shakaPlayerRef.current.destroy();
shakaPlayerRef.current = null; 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) { if (typeof sourceUri !== 'string' || !sourceUri) {
nativeRef.current.removeAttribute('src'); video.removeAttribute('src');
nativeRef.current.load(); video.load();
return; return;
} }
// Create a new Shaka player and attach it to the video element // 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) { const destroyStalePlayer = async () => {
shakaPlayerRef.current.configure({ if (shakaPlayerRef.current !== player) {
playRangeStart: activeSource.cropStart / 1000, 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 = ( const shakaError = (
event as CustomEvent<{message?: string; code?: number}> event as CustomEvent<{message?: string; code?: number}>
).detail; ).detail;
@@ -438,18 +467,43 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
// Load the new source // Load the new source
try { 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`); console.log(`${sourceUri} finished loading`);
// Optionally resume playback if not paused // Optionally resume playback if not paused
if (!paused) { if (!pausedRef.current) {
try { try {
await nativeRef.current.play(); await video.play();
} catch (e) { } catch (e) {
console.error('Error playing video:', e); console.error('Error playing video:', e);
} }
} }
} catch (e) { } catch (e) {
if (!isCurrentPlayer()) {
await destroyStalePlayer();
return;
}
console.error('Error loading video with Shaka Player', e); console.error('Error loading video with Shaka Player', e);
const shakaError = e as {message?: string; code?: number}; const shakaError = e as {message?: string; code?: number};
onError?.({ onError?.({
@@ -460,7 +514,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
}); });
} }
}, 'makeNewShaka'); }, 'makeNewShaka');
}, [activeSource, paused, onError]); }, [activeSource, onError]);
const nativeRefDefined = !!nativeRef.current; const nativeRefDefined = !!nativeRef.current;
@@ -471,20 +525,26 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
); );
return; return;
} }
if (!shallowEqual(activeSource, loadedSource)) { if (
loadedSource === null ||
!isDeepEqual(activeSource, loadedSource.source)
) {
console.log( console.log(
'Making new shaka, Old source: ', 'Making new shaka, Old source: ',
loadedSource, loadedSource?.source,
'New source', 'New source',
activeSource, activeSource,
); );
setLoadedSource(activeSource ?? null); setLoadedSource({source: activeSource});
makeNewShaka(); makeNewShaka();
} }
}, [activeSource, nativeRefDefined, loadedSource, makeNewShaka]); }, [activeSource, nativeRefDefined, loadedSource, makeNewShaka]);
useEffect( useEffect(() => {
() => () => { mountedRef.current = true;
return () => {
mountedRef.current = false;
sourceGenerationRef.current += 1;
const player = shakaPlayerRef.current; const player = shakaPlayerRef.current;
shakaPlayerRef.current = null; shakaPlayerRef.current = null;
if (player) { if (player) {
@@ -492,9 +552,8 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
console.error('Error destroying Shaka Player', error); console.error('Error destroying Shaka Player', error);
}); });
} }
}, };
[], }, []);
);
useEffect(() => { useEffect(() => {
if ( if (
@@ -612,11 +671,19 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
} }
onSeeking={() => (isSeeking.current = true)} onSeeking={() => (isSeeking.current = true)}
onSeeked={() => { onSeeked={() => {
const currentTime = nativeRef.current?.currentTime || 0.0;
if (isSeeking.current && !isProgrammaticSeek.current) {
onSeek?.({
currentTime,
seekTime: currentTime,
});
}
isSeeking.current = false; isSeeking.current = false;
isProgrammaticSeek.current = false;
onSeekComplete?.({ onSeekComplete?.({
currentTime: currentTime: currentTime - cropStartSeconds,
(nativeRef.current?.currentTime || 0.0) - cropStartSeconds,
seekTime: 0.0, seekTime: 0.0,
target: 0.0, target: 0.0,
}); });