12 Commits

Author SHA1 Message Date
b01e55488e Replace Shaka Player with hls.js on web
Some checks failed
Check JS / Check TS (tsc) (pull_request) Has been cancelled
Check JS / Lint JS (eslint, prettier) (pull_request) Has been cancelled
The web player carried a Shaka Player integration plus the scaffolding
built up around it while debugging: an ActionQueue that serialized every
play/pause/seek/volume call behind a 2s timeout, a shallowEqual source
comparison, and a large amount of debug logging. All of it existed to
work around Shaka's async attach/load lifecycle.

Every source this fork's consumer plays is HLS, and the only thing Shaka
was providing is HLS playback in browsers without native support. hls.js
does that in a fraction of the code, so drive the video element directly:
set `src` when the browser plays HLS natively (Safari/WebKit) or the
source isn't HLS at all, and attach hls.js otherwise.

Dropping the queue restores the original direct play/pause/seek/volume
implementations. `resume()` now swallows play() rejections, which the
queue used to absorb -- these are routine when play() lands before the
manifest is parsed or when autoplay is denied.

The source effect keys on the URI string rather than the source object.
Consumers pass `source={{uri}}` as a fresh object literal every render,
which is what shallowEqual was defending against.

Carried forward from the Shaka implementation: the onSeekComplete event
and objectFit: 'fill'. Also resolves `poster` to a URL string instead of
suppressing the type error with @ts-ignore.

Dropped: web-side cropStart/cropEnd handling, which was implemented with
Shaka's playRangeStart/playRangeEnd. No consumer passes those props, and
neither upstream's web player nor this file before Shaka supported them.
2026-08-04 01:06:45 -07:00
d7563cfd93 Fix video rendering with React Native 0.86
Fold the mobile repository's react-native-video patch into the maintained fork. Avoid the bridgeless RCTBridge.current() trap, preserve the TypeScript install build, and use the supported absolute-fill style so legacy-interoperability video views receive their layout.
2026-08-03 23:30:41 -07:00
69a9159a43 Load video duration asynchronously
Some checks failed
Build Android / Build Android Example App (pull_request) Has been cancelled
Build Android / Build Android Example App Without 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 CLang / CLang-Format (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
Test Docs build / build-docs (pull_request) Has been cancelled
2026-07-23 13:20:28 -07:00
c6bd3e3ee4 use object fill 2026-01-21 11:35:19 -08:00
2d953c4c46 fix null-safety-checks and f bounded polymorphism 2025-11-26 13:17:41 -07:00
7a6afd52a3 Handle seek completion
Some checks failed
Build Android / Build Android Example App (pull_request) Has been cancelled
Build Android / Build Android Example App Without 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 CLang / CLang-Format (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
Test Docs build / build-docs (pull_request) Has been cancelled
2024-12-04 12:51:28 -07:00
d7977241c9 Account for crop in progress 2024-10-18 03:25:50 -06:00
921ead0f05 Timeout actions 2024-10-17 19:21:06 -06:00
20397d32e6 More logging 2024-10-17 19:11:22 -06:00
e3900e794d What is going on 2024-10-17 19:07:39 -06:00
4dc7bf465f Typescript 2024-10-17 18:59:42 -06:00
e5f182cda9 Use an async queue 2024-10-17 18:56:38 -06:00
12 changed files with 127 additions and 132 deletions

View File

@@ -24,7 +24,7 @@ class SideLoadedTextTrackList {
} }
val sideLoadedTextTrackList = SideLoadedTextTrackList() val sideLoadedTextTrackList = SideLoadedTextTrackList()
for (i in 0 until src.size()) { for (i in 0 until src.size()) {
val textTrack: ReadableMap = src.getMap(i) val textTrack: ReadableMap = src.getMap(i) ?: continue
sideLoadedTextTrackList.tracks.add(SideLoadedTextTrack.parse(textTrack)) sideLoadedTextTrackList.tracks.add(SideLoadedTextTrack.parse(textTrack))
} }
return sideLoadedTextTrackList return sideLoadedTextTrackList

View File

@@ -228,7 +228,7 @@ class Source {
if (propSrcHeadersArray != null) { if (propSrcHeadersArray != null) {
if (propSrcHeadersArray.size() > 0) { if (propSrcHeadersArray.size() > 0) {
for (i in 0 until propSrcHeadersArray.size()) { for (i in 0 until propSrcHeadersArray.size()) {
val current = propSrcHeadersArray.getMap(i) val current = propSrcHeadersArray.getMap(i) ?: continue
val key = if (current.hasKey("key")) current.getString("key") else null val key = if (current.hasKey("key")) current.getString("key") else null
val value = if (current.hasKey("value")) current.getString("value") else null val value = if (current.hasKey("value")) current.getString("value") else null
if (key != null && value != null) { if (key != null && value != null) {

View File

@@ -288,12 +288,22 @@ class VideoEventEmitter {
} }
} }
private class VideoEvent(
surfaceId: Int,
viewId: Int,
private val name: String,
private val data: WritableMap?
) : Event<VideoEvent>(surfaceId, viewId) {
override fun getEventName() = name
override fun getEventData() = data
}
private class EventBuilder(private val surfaceId: Int, private val viewId: Int, private val dispatcher: EventDispatcher) { private class EventBuilder(private val surfaceId: Int, private val viewId: Int, private val dispatcher: EventDispatcher) {
fun dispatch(event: EventTypes, paramsSetter: (WritableMap.() -> Unit)? = null) = fun dispatch(event: EventTypes, paramsSetter: (WritableMap.() -> Unit)? = null) {
dispatcher.dispatchEvent(object : Event<Event<*>>(surfaceId, viewId) { val eventName = "top${event.eventName.removePrefix("on")}"
override fun getEventName() = "top${event.eventName.removePrefix("on")}" val eventData = Arguments.createMap().apply(paramsSetter ?: {})
override fun getEventData() = Arguments.createMap().apply(paramsSetter ?: {}) dispatcher.dispatchEvent(VideoEvent(surfaceId, viewId, eventName, eventData))
}) }
} }
private fun audioTracksToArray(audioTracks: java.util.ArrayList<Track>?): WritableArray = private fun audioTracksToArray(audioTracks: java.util.ArrayList<Track>?): WritableArray =

View File

@@ -34,6 +34,22 @@ enum RCTVideoAssetsUtils {
#endif #endif
} }
} }
static func getDuration(asset: AVAsset) async -> CMTime? {
if #available(iOS 15, tvOS 15, visionOS 1.0, *) {
return try? await asset.load(.duration)
} else {
#if !os(visionOS)
return await withCheckedContinuation { continuation in
asset.loadValuesAsynchronously(forKeys: ["duration"]) {
var error: NSError?
let status = asset.statusOfValue(forKey: "duration", error: &error)
continuation.resume(returning: status == .loaded ? asset.duration : nil)
}
}
#endif
}
}
} }
// MARK: - RCTVideoUtils // MARK: - RCTVideoUtils

View File

@@ -1420,7 +1420,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
if onVideoLoad != nil, self._videoLoadStarted { if onVideoLoad != nil, self._videoLoadStarted {
var duration = Float(CMTimeGetSeconds(_playerItem.asset.duration)) let assetDuration = await RCTVideoAssetsUtils.getDuration(asset: _playerItem.asset)
guard self._playerItem === _playerItem,
self._source?.json === source.json else { return }
var duration = Float(CMTimeGetSeconds(assetDuration ?? .invalid))
if duration.isNaN || duration == 0 { if duration.isNaN || duration == 0 {
// This is a safety check for live video. // This is a safety check for live video.
@@ -1449,6 +1452,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
let audioTracks = await RCTVideoUtils.getAudioTrackInfo(self._player) let audioTracks = await RCTVideoUtils.getAudioTrackInfo(self._player)
let textTracks = await RCTVideoUtils.getTextTrackInfo(self._player) let textTracks = await RCTVideoUtils.getTextTrackInfo(self._player)
guard self._playerItem === _playerItem,
self._source?.json === source.json else { return }
self.onVideoLoad?(["duration": NSNumber(value: duration), self.onVideoLoad?(["duration": NSNumber(value: duration),
"currentTime": NSNumber(value: Float(CMTimeGetSeconds(_playerItem.currentTime()))), "currentTime": NSNumber(value: Float(CMTimeGetSeconds(_playerItem.currentTime()))),
"canPlayReverse": NSNumber(value: _playerItem.canPlayReverse), "canPlayReverse": NSNumber(value: _playerItem.canPlayReverse),
@@ -1699,4 +1704,3 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
@objc @objc
func setOnClick(_: Any) {} func setOnClick(_: Any) {}
} }

View File

@@ -4,7 +4,13 @@ import React
@objc(RCTVideoManager) @objc(RCTVideoManager)
class RCTVideoManager: RCTViewManager { class RCTVideoManager: RCTViewManager {
override func view() -> UIView { override func view() -> UIView {
return RCTVideo(eventDispatcher: (RCTBridge.current().eventDispatcher() as! RCTEventDispatcher)) // `RCTBridge.current()` returns nil under bridgeless (New Architecture), and it
// is nonnull-annotated, so Swift emits an unconditional nil-check trap around
// the call -- optional chaining does not avoid it. Every <Video> mount killed
// the app. RCTVideo only stores this dispatcher and never reads it (events go
// out through the RCT_EXPORT_VIEW_PROPERTY direct event blocks), so skip the
// lookup entirely.
return RCTVideo(eventDispatcher: nil)
} }
func methodQueue() -> DispatchQueue { func methodQueue() -> DispatchQueue {

View File

@@ -32,17 +32,17 @@
"react-native": "0.73.2", "react-native": "0.73.2",
"react-native-windows": "^0.61.0-0", "react-native-windows": "^0.61.0-0",
"release-it": "^16.2.1", "release-it": "^16.2.1",
"typescript": "5.1.6", "typescript": "5.1.6"
"patch-package": "^8.0.0"
}, },
"dependencies": { "dependencies": {
"shaka-player": "^4.11.7" "hls.js": "^1.6.16"
}, },
"peerDependencies": { "peerDependencies": {
"react": "*", "react": "*",
"react-native": "*" "react-native": "*"
}, },
"scripts": { "scripts": {
"install": "tsc --noCheck",
"lint": "yarn eslint .", "lint": "yarn eslint .",
"build": "yarn tsc", "build": "yarn tsc",
"prepare": "yarn build", "prepare": "yarn build",
@@ -61,6 +61,8 @@
"windows", "windows",
"src", "src",
"lib", "lib",
"tsconfig.json",
"tsconfig.build.json",
"react-native-video.podspec", "react-native-video.podspec",
"app.plugin.js", "app.plugin.js",
"!android/build", "!android/build",

View File

@@ -1,39 +0,0 @@
diff --git a/node_modules/shaka-player/dist/shaka-player.compiled.d.ts b/node_modules/shaka-player/dist/shaka-player.compiled.d.ts
index 19c0930..cc0a3fd 100644
--- a/node_modules/shaka-player/dist/shaka-player.compiled.d.ts
+++ b/node_modules/shaka-player/dist/shaka-player.compiled.d.ts
@@ -5117,3 +5117,5 @@ declare namespace shaka.extern {
declare namespace shaka.extern {
type TransmuxerPlugin = ( ) => shaka.extern.Transmuxer ;
}
+
+export default shaka;
diff --git a/node_modules/shaka-player/dist/shaka-player.ui.d.ts b/node_modules/shaka-player/dist/shaka-player.ui.d.ts
index 1618ca0..a6076c6 100644
--- a/node_modules/shaka-player/dist/shaka-player.ui.d.ts
+++ b/node_modules/shaka-player/dist/shaka-player.ui.d.ts
@@ -5830,3 +5830,5 @@ declare namespace shaka.extern {
declare namespace shaka.extern {
type UIVolumeBarColors = { base : string , level : string } ;
}
+
+export default shaka;
diff --git a/node_modules/shaka-player/index.d.ts b/node_modules/shaka-player/index.d.ts
new file mode 100644
index 0000000..3ebfd96
--- /dev/null
+++ b/node_modules/shaka-player/index.d.ts
@@ -0,0 +1,2 @@
+/// <reference path="./dist/shaka-player.compiled.d.ts" />
+/// <reference path="./dist/shaka-player.ui.d.ts" />
\ No newline at end of file
diff --git a/node_modules/shaka-player/ui.d.ts b/node_modules/shaka-player/ui.d.ts
new file mode 100644
index 0000000..84a3be0
--- /dev/null
+++ b/node_modules/shaka-player/ui.d.ts
@@ -0,0 +1,3 @@
+import shaka from 'shaka-player/dist/shaka-player.ui'
+export * from 'shaka-player/dist/shaka-player.ui'
+export default shaka;
\ No newline at end of file

View File

@@ -734,7 +734,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
// poster style // poster style
const baseStyle: StyleProp<ImageStyle> = { const baseStyle: StyleProp<ImageStyle> = {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFill,
resizeMode: _posterResizeMode, resizeMode: _posterResizeMode,
}; };
@@ -787,7 +787,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
const _style: StyleProp<ViewStyle> = useMemo( const _style: StyleProp<ViewStyle> = useMemo(
() => ({ () => ({
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFill,
...(showPoster ? {display: 'none'} : {}), ...(showPoster ? {display: 'none'} : {}),
}), }),
[showPoster], [showPoster],

View File

@@ -4,35 +4,26 @@ import React, {
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
useRef, useRef,
useState,
type RefObject, type RefObject,
} from 'react'; } from 'react';
//@ts-ignore import Hls from 'hls.js';
import shaka from 'shaka-player';
import type {VideoRef, ReactVideoProps, VideoMetadata} from './types'; import type {VideoRef, ReactVideoProps, VideoMetadata} from './types';
function shallowEqual(obj1: any, obj2: any) { const HLS_MIME = 'application/vnd.apple.mpegurl';
// If both are strictly equal (covers primitive types and identical object references)
if (obj1 === obj2) return true;
// If one is not an object (meaning it's a primitive), they must be strictly equal const isHlsSource = (uri: string) => /\.m3u8(\?|#|$)/i.test(uri);
if (typeof obj1 !== 'object' || typeof obj2 !== 'object' || obj1 === null || obj2 === null) {
return false; // `poster` is either a (deprecated) plain URI string or a poster object whose
// `source` follows the RN image-source shape. The DOM only takes a URL string.
const resolvePosterUri = (
poster: ReactVideoProps['poster'],
): string | undefined => {
if (typeof poster === 'string') {
return poster;
} }
const source = poster?.source;
// Get the keys of both objects return typeof source === 'object' && source !== null ? source.uri : undefined;
const keys1 = Object.keys(obj1); };
const keys2 = Object.keys(obj2);
// 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 obj1[key] === obj2[key];
});
}
const Video = forwardRef<VideoRef, ReactVideoProps>( const Video = forwardRef<VideoRef, ReactVideoProps>(
( (
@@ -56,6 +47,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
onError, onError,
onReadyForDisplay, onReadyForDisplay,
onSeek, onSeek,
onSeekComplete,
onVolumeChange, onVolumeChange,
onEnd, onEnd,
onPlaybackStateChanged, onPlaybackStateChanged,
@@ -63,8 +55,6 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
ref, ref,
) => { ) => {
const nativeRef = useRef<HTMLVideoElement>(null); const nativeRef = useRef<HTMLVideoElement>(null);
const shakaPlayerRef = useRef<shaka.Player | null>(null);
const [ currentSource, setCurrentSource ] = useState<object | null>(null);
const isSeeking = useRef(false); const isSeeking = useRef(false);
const seek = useCallback( const seek = useCallback(
@@ -94,7 +84,10 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
if (!nativeRef.current) { if (!nativeRef.current) {
return; return;
} }
nativeRef.current.play(); // play() rejects if it is called before the stream has data (common while
// hls.js is still parsing the manifest) or if autoplay is denied. Neither
// is fatal, so swallow it rather than surfacing an unhandled rejection.
nativeRef.current.play()?.catch(() => {});
}, []); }, []);
const setVolume = useCallback((vol: number) => { const setVolume = useCallback((vol: number) => {
@@ -253,59 +246,54 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
nativeRef.current.playbackRate = rate; nativeRef.current.playbackRate = rate;
}, [rate]); }, [rate]);
// `onError` is typically a fresh closure on every render. Keep it in a ref so
// the source effect below can depend on the URI alone -- callers pass
// `source={{uri}}` as a new object literal each render, so keying the effect
// on anything object-shaped would tear down and reload the stream constantly.
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
const makeNewShaka = useCallback(() => { const uri = source?.uri as string | undefined;
if (shakaPlayerRef.current) {
shakaPlayerRef.current.unload()
}
shakaPlayerRef.current = new shaka.Player();
useEffect(() => {
if (source?.cropStart) { const video = nativeRef.current;
shakaPlayerRef.current.configure({playRangeStart: source?.cropStart / 1000}) if (!video || !uri) {
} return;
if (source?.cropEnd) {
shakaPlayerRef.current.configure({playRangeEnd: source?.cropEnd / 1000})
} }
//@ts-ignore // Safari (and iOS WebKit) plays HLS natively; everywhere else the browser
shakaPlayerRef.current.addEventListener("error", (event) => { // has no HLS support at all, so an .m3u8 has to go through hls.js.
//@ts-ignore const needsMse =
const shakaError = event.detail; isHlsSource(uri) && !video.canPlayType(HLS_MIME) && Hls.isSupported();
console.error('Shaka Player Error', shakaError);
onError?.({ if (!needsMse) {
video.src = uri;
return () => {
video.removeAttribute('src');
video.load();
};
}
const hls = new Hls();
hls.on(Hls.Events.ERROR, (_event, data) => {
// hls.js surfaces plenty of recoverable warnings; only fatal errors are
// worth propagating as a video error.
if (!data.fatal) {
return;
}
onErrorRef.current?.({
error: { error: {
errorString: shakaError.message, errorString: `${data.type}: ${data.details}`,
code: shakaError.code,
}, },
}); });
}); });
hls.loadSource(uri);
hls.attachMedia(video);
console.log("Initializing and attaching shaka") return () => {
//@ts-ignore hls.destroy();
shakaPlayerRef.current.attach(nativeRef.current); };
}, [uri]);
//@ts-ignore
shakaPlayerRef.current.load(source?.uri).then(
() => console.log(`${source?.uri} finished loading`)
);
console.log("Started shaka loading");
}, [source, setCurrentSource]);
const nativeRefDefined = nativeRef.current ? true : false;
useEffect(() => {
if (!nativeRef.current) {
console.log("Not starting shaka yet bc undefined")
return;
}
if (!shallowEqual(source, currentSource)) {
console.log("Making new shaka, Old source: ", currentSource, "New source", source);
//@ts-ignore
setCurrentSource(source);
makeNewShaka()
}
}, [source, nativeRefDefined, currentSource])
useMediaSession(source?.metadata, nativeRef, showNotificationControls); useMediaSession(source?.metadata, nativeRef, showNotificationControls);
@@ -317,8 +305,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
controls={controls} controls={controls}
loop={repeat} loop={repeat}
playsInline playsInline
//@ts-ignore poster={resolvePosterUri(poster)}
poster={poster}
onCanPlay={() => onBuffer?.({isBuffering: false})} onCanPlay={() => onBuffer?.({isBuffering: false})}
onWaiting={() => onBuffer?.({isBuffering: true})} onWaiting={() => onBuffer?.({isBuffering: true})}
onRateChange={() => { onRateChange={() => {
@@ -390,7 +377,15 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
}) })
} }
onSeeking={() => (isSeeking.current = true)} onSeeking={() => (isSeeking.current = true)}
onSeeked={() => (isSeeking.current = false)} onSeeked={() => {
isSeeking.current = false;
const currentTime = nativeRef.current?.currentTime ?? 0;
onSeekComplete?.({
currentTime,
seekTime: currentTime,
target: 0,
});
}}
onVolumeChange={() => { onVolumeChange={() => {
if (!nativeRef.current) { if (!nativeRef.current) {
return; return;
@@ -407,7 +402,9 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
const videoStyle = { const videoStyle = {
position: 'absolute', position: 'absolute',
inset: 0, inset: 0,
objectFit: 'contain', // Use 'fill' instead of 'contain' to force video to match container dimensions.
// This works around browsers miscalculating intrinsic dimensions from rotation matrices.
objectFit: 'fill',
width: '100%', width: '100%',
height: '100%', height: '100%',
} satisfies React.CSSProperties; } satisfies React.CSSProperties;

View File

@@ -1,4 +1,3 @@
{ {
"extends": "./tsconfig", "extends": "./tsconfig",
"exclude": ["examples", "lib"] "exclude": ["examples", "lib"]

View File

@@ -14,8 +14,8 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"jsx": "react", "jsx": "react",
"lib": ["esnext"], "lib": ["esnext"],
"module": "CommonJS", "module": "Node16",
"moduleResolution": "node", "moduleResolution": "Node16",
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noImplicitUseStrict": false, "noImplicitUseStrict": false,