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 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 = {

View File

@@ -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) {

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']} />
Example:
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
### 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.
</details>
<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.
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.
</details>

View File

@@ -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<T>(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<string, unknown>;
const second = obj2 as Record<string, unknown>;
// 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<string, unknown>;
const secondObject = second as Record<string, unknown>;
return (
Object.keys(firstObject).length === Object.keys(secondObject).length &&
Object.entries(firstObject).every(([key, value]) =>
isDeepEqual(value, secondObject[key]),
)
);
}
const Video = forwardRef<VideoRef, ReactVideoProps>(
@@ -136,10 +122,17 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
const shakaPlayerRef = useRef<shaka.Player | null>(null);
const [activeSource, setActiveSource] = useState(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 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<VideoRef, ReactVideoProps>(
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<VideoRef, ReactVideoProps>(
}, []);
useEffect(() => {
if (shallowEqual(source, sourceProp.current)) {
if (isDeepEqual(source, sourceProp.current)) {
return;
}
sourceProp.current = source;
@@ -286,22 +285,22 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
);
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<VideoRef, ReactVideoProps>(
}, [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<VideoRef, ReactVideoProps>(
// 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<VideoRef, ReactVideoProps>(
});
}
}, 'makeNewShaka');
}, [activeSource, paused, onError]);
}, [activeSource, onError]);
const nativeRefDefined = !!nativeRef.current;
@@ -471,20 +525,26 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
);
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<VideoRef, ReactVideoProps>(
console.error('Error destroying Shaka Player', error);
});
}
},
[],
);
};
}, []);
useEffect(() => {
if (
@@ -612,11 +671,19 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
}
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,
});