13 Commits

Author SHA1 Message Date
7c0669190f Validate package lifecycle in CI
All checks were successful
Check JS / Check TS (tsc) (pull_request) Successful in 30s
Check JS / Lint JS (eslint, prettier) (pull_request) Successful in 33s
2026-08-12 15:18:49 -07:00
452a9748d5 Run Gitea JS checks on Node 22 2026-08-12 15:16:29 -07:00
b81dae8878 Provision JS checks with Nix 2026-08-12 15:16:29 -07:00
ba3dea885d Enable Gitea JS checks 2026-08-12 15:16:29 -07:00
a1579eebad Guard unsupported web orientation methods 2026-08-12 15:16:29 -07:00
42608bd8d1 Route Chromium HLS through hls.js 2026-08-12 15:16:29 -07:00
04682d02c7 Improve web HLS playback diagnostics 2026-08-12 15:16:29 -07:00
ec3cb15984 Replace Shaka Player with hls.js on web
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-12 15:16:29 -07:00
ff6c4316cf Merge pull request 'Fix Git dependency build lifecycle' (#13)
Make the maintained fork build reliably when consumed by exact Git commit.
2026-08-12 22:15:46 +00:00
dfe2957087 Fix git dependency build lifecycle
Some checks failed
Check JS / Check TS (tsc) (pull_request) Has been cancelled
Check JS / Lint JS (eslint, prettier) (pull_request) Has been cancelled
2026-08-12 15:10:08 -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
14 changed files with 723 additions and 277 deletions

View File

@@ -0,0 +1,38 @@
name: Check JS
on:
pull_request:
push:
branches:
- railbird-v6
jobs:
typescript:
name: Check TS (tsc)
runs-on: nixos-x86_64-linux
defaults:
run:
shell: nix shell nixpkgs#nodejs_22 nixpkgs#yarn -c bash -e {0}
steps:
- uses: actions/checkout@v4
- name: Install node_modules and build package
run: yarn install --immutable
- name: Check TypeScript
run: yarn tsc
- name: Test web HLS
run: yarn test:web
lint:
name: Lint JS (eslint, prettier)
runs-on: nixos-x86_64-linux
defaults:
run:
shell: nix shell nixpkgs#nodejs_22 nixpkgs#yarn -c bash -e {0}
steps:
- uses: actions/checkout@v4
- name: Install node_modules and build package
run: yarn install --immutable
- name: Run ESLint
run: yarn lint
- name: Verify auto-fix produces no changes
run: yarn lint --fix && git diff --exit-code HEAD

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

@@ -31,18 +31,18 @@
"react": "18.2.0", "react": "18.2.0",
"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",
"patch-package": "^8.0.0"
}, },
"dependencies": { "dependencies": {
"shaka-player": "^4.11.7" "hls.js": "^1.6.16",
"typescript": "5.6.3"
}, },
"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",
@@ -50,6 +50,7 @@
"docs": "yarn --cwd docs build", "docs": "yarn --cwd docs build",
"release": "release-it", "release": "release-it",
"test": "echo no test available", "test": "echo no test available",
"test:web": "yarn tsc -p test/tsconfig.json && node --test test/web-hls.test.js",
"check-ios": "scripts/swift-format.sh && scripts/swift-lint.sh && scripts/clang-format.sh", "check-ios": "scripts/swift-format.sh && scripts/swift-lint.sh && scripts/clang-format.sh",
"check-android": "scripts/kotlin-lint.sh", "check-android": "scripts/kotlin-lint.sh",
"check-all": "yarn check-android; yarn check-ios; yarn lint", "check-all": "yarn check-android; yarn check-ios; yarn lint",
@@ -61,6 +62,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

@@ -475,7 +475,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
(e: NativeSyntheticEvent<OnSeekCompleteData>) => { (e: NativeSyntheticEvent<OnSeekCompleteData>) => {
onSeekComplete?.(e.nativeEvent); onSeekComplete?.(e.nativeEvent);
}, },
[onSeekComplete] [onSeekComplete],
); );
const onVideoPlaybackStateChanged = useCallback( const onVideoPlaybackStateChanged = useCallback(
@@ -733,10 +733,10 @@ 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},
}; ];
let posterStyle: StyleProp<ImageStyle> = baseStyle; let posterStyle: StyleProp<ImageStyle> = baseStyle;
@@ -786,10 +786,10 @@ 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'} : undefined,
}), ],
[showPoster], [showPoster],
); );

View File

@@ -4,70 +4,32 @@ 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';
import {
createWebPlaybackErrorReporter,
describeHlsError,
describeMediaError,
isAppleWebKit,
selectWebHlsPlaybackMode,
type WebHlsPlaybackMode,
} from './webHls';
// Action Queue Class const HLS_MIME = 'application/vnd.apple.mpegurl';
class ActionQueue {
private queue: { action: () => Promise<void>; name: string }[] = [];
private isRunning = false;
enqueue(action: () => Promise<void>, name: string) { // `poster` is either a (deprecated) plain URI string or a poster object whose
this.queue.push({ action, name }); // `source` follows the RN image-source shape. The DOM only takes a URL string.
this.runNext(); const resolvePosterUri = (
} poster: ReactVideoProps['poster'],
): string | undefined => {
private async runNext() { if (typeof poster === 'string') {
if (this.isRunning || this.queue.length === 0) { return poster;
console.log("Refusing to run in runNext", this.queue.length, this.isRunning);
return;
}
this.isRunning = true;
const { action, name } = this.queue.shift()!;
console.log(`Running action: ${name}`);
const actionPromise = action();
const timeoutPromise = new Promise<void>((_, reject) =>
setTimeout(() => reject(new Error(`Action ${name} timed out`)), 2000)
);
try {
await Promise.race([actionPromise, timeoutPromise]);
} catch (e) {
console.error('Error in queued action:', e);
} finally {
this.isRunning = false;
this.runNext();
}
}
}
function shallowEqual(obj1: any, obj2: any) {
// 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
if (typeof obj1 !== 'object' || typeof obj2 !== 'object' || obj1 === null || obj2 === null) {
return false;
}
// Get the keys of both objects
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 source = poster?.source;
return typeof source === 'object' && source !== null ? source.uri : undefined;
};
const Video = forwardRef<VideoRef, ReactVideoProps>( const Video = forwardRef<VideoRef, ReactVideoProps>(
( (
@@ -99,15 +61,10 @@ 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 actionQueue = useRef(new ActionQueue());
const isSeeking = useRef(false); const isSeeking = useRef(false);
const seek = useCallback( const seek = useCallback(
(time: number, _tolerance?: number) => { async (time: number, _tolerance?: number) => {
actionQueue.current.enqueue(async () => {
if (isNaN(time)) { if (isNaN(time)) {
throw new Error('Specified time is not a number'); throw new Error('Specified time is not a number');
} }
@@ -117,44 +74,33 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
} }
time = Math.max(0, Math.min(time, nativeRef.current.duration)); time = Math.max(0, Math.min(time, nativeRef.current.duration));
nativeRef.current.currentTime = time; nativeRef.current.currentTime = time;
onSeek?.({ onSeek?.({seekTime: time, currentTime: nativeRef.current.currentTime});
seekTime: time,
currentTime: nativeRef.current.currentTime,
});
}, 'seek');
}, },
[onSeek], [onSeek],
); );
const pause = useCallback(() => { const pause = useCallback(() => {
actionQueue.current.enqueue(async () => {
if (!nativeRef.current) { if (!nativeRef.current) {
return; return;
} }
await nativeRef.current.pause(); nativeRef.current.pause();
}, 'pause');
}, []); }, []);
const resume = useCallback(() => { const resume = useCallback(() => {
actionQueue.current.enqueue(async () => {
if (!nativeRef.current) { if (!nativeRef.current) {
return; return;
} }
try { // play() rejects if it is called before the stream has data (common while
await nativeRef.current.play(); // hls.js is still parsing the manifest) or if autoplay is denied. Neither
} catch (e) { // is fatal, so swallow it rather than surfacing an unhandled rejection.
console.error('Error playing video:', e); nativeRef.current.play()?.catch(() => {});
}
}, 'resume');
}, []); }, []);
const setVolume = useCallback((vol: number) => { const setVolume = useCallback((vol: number) => {
actionQueue.current.enqueue(async () => {
if (!nativeRef.current) { if (!nativeRef.current) {
return; return;
} }
nativeRef.current.volume = Math.max(0, Math.min(vol, 100)) / 100; nativeRef.current.volume = Math.max(0, Math.min(vol, 100)) / 100;
}, 'setVolume');
}, []); }, []);
const getCurrentPosition = useCallback(async () => { const getCurrentPosition = useCallback(async () => {
@@ -187,21 +133,31 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
autorotate ??= fsPrefs.current.fullscreenAutorotate; autorotate ??= fsPrefs.current.fullscreenAutorotate;
const run = async () => { const run = async () => {
const browserOrientation = screen.orientation as
| {
lock?: (orientation: 'landscape' | 'portrait') => Promise<void>;
unlock?: () => void;
}
| undefined;
try { try {
if (newVal) { if (newVal) {
await nativeRef.current?.requestFullscreen({ await nativeRef.current?.requestFullscreen({
navigationUI: 'hide', navigationUI: 'hide',
}); });
if (orientation === 'all' || !orientation || autorotate) { if (orientation === 'all' || !orientation || autorotate) {
screen.orientation.unlock(); if (typeof browserOrientation?.unlock === 'function') {
} else { browserOrientation.unlock();
await screen.orientation.lock(orientation); }
} else if (typeof browserOrientation?.lock === 'function') {
await browserOrientation.lock(orientation);
} }
} else { } else {
if (document.fullscreenElement) { if (document.fullscreenElement) {
await document.exitFullscreen(); await document.exitFullscreen();
} }
screen.orientation.unlock(); if (typeof browserOrientation?.unlock === 'function') {
browserOrientation.unlock();
}
} }
} catch (e) { } catch (e) {
// Changing fullscreen status without a button click is not allowed so it throws. // Changing fullscreen status without a button click is not allowed so it throws.
@@ -209,7 +165,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
console.error('Could not toggle fullscreen/screen lock status', e); console.error('Could not toggle fullscreen/screen lock status', e);
} }
}; };
actionQueue.current.enqueue(run, 'setFullScreen'); run();
}, },
[], [],
); );
@@ -272,7 +228,6 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
resume(); resume();
} }
}, [paused, pause, resume]); }, [paused, pause, resume]);
useEffect(() => { useEffect(() => {
if (volume === undefined) { if (volume === undefined) {
return; return;
@@ -307,107 +262,77 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
nativeRef.current.playbackRate = rate; nativeRef.current.playbackRate = rate;
}, [rate]); }, [rate]);
const makeNewShaka = useCallback(() => { // `onError` is typically a fresh closure on every render. Keep it in a ref so
console.log("makeNewShaka"); // the source effect below can depend on the URI alone -- callers pass
actionQueue.current.enqueue(async () => { // `source={{uri}}` as a new object literal each render, so keying the effect
console.log("makeNewShaka actionQueue"); // on anything object-shaped would tear down and reload the stream constantly.
if (!nativeRef.current) { const onErrorRef = useRef(onError);
console.warn('No video element to attach Shaka Player'); onErrorRef.current = onError;
return; const playbackErrorReporterRef =
useRef<ReturnType<typeof createWebPlaybackErrorReporter>>();
if (!playbackErrorReporterRef.current) {
playbackErrorReporterRef.current = createWebPlaybackErrorReporter(
(error) => onErrorRef.current?.({error}),
);
} }
const playbackDiagnosticsRef = useRef<{
mode: WebHlsPlaybackMode;
nativeHlsSupport: string;
}>();
// Pause the video before changing the source const uri = source?.uri as string | undefined;
nativeRef.current.pause(); const sourceType = source?.type;
// Unload the previous Shaka player if it exists
if (shakaPlayerRef.current) {
await shakaPlayerRef.current.unload();
shakaPlayerRef.current = null;
}
// Create a new Shaka player and attach it to the video element
shakaPlayerRef.current = new shaka.Player();
shakaPlayerRef.current.attach(nativeRef.current);
if (source?.cropStart) {
shakaPlayerRef.current.configure({
playRangeStart: source?.cropStart / 1000,
});
}
if (source?.cropEnd) {
shakaPlayerRef.current.configure({
playRangeEnd: source?.cropEnd / 1000,
});
}
//@ts-ignore
shakaPlayerRef.current.addEventListener('error', event => {
//@ts-ignore
const shakaError = event.detail;
console.error('Shaka Player Error', shakaError);
onError?.({
error: {
errorString: shakaError.message,
code: shakaError.code,
},
});
});
console.log('Initializing and attaching shaka');
// Load the new source
try {
//@ts-ignore
await shakaPlayerRef.current.load(source?.uri);
console.log(`${source?.uri} finished loading`);
// Optionally resume playback if not paused
if (!paused) {
try {
await nativeRef.current.play();
} catch (e) {
console.error('Error playing video:', e);
}
}
} catch (e) {
console.error('Error loading video with Shaka Player', e);
onError?.({
error: {
//@ts-ignore
errorString: e.message,
//@ts-ignore
code: e.code,
},
});
}
}, 'makeNewShaka');
}, [source, paused, onError]);
const nativeRefDefined = !!nativeRef.current;
useEffect(() => { useEffect(() => {
if (!nativeRef.current) { const video = nativeRef.current;
console.log('Not starting shaka yet because video element is undefined'); if (!video || !uri) {
return; return;
} }
if (!shallowEqual(source, currentSource)) {
console.log( playbackErrorReporterRef.current?.reset();
'Making new shaka, Old source: ', const nativeHlsSupport = video.canPlayType(HLS_MIME);
currentSource, const mode = selectWebHlsPlaybackMode(uri, sourceType, {
'New source', hlsJsSupported: Hls.isSupported(),
source, nativeHlsSupport,
); preferNativeHls: isAppleWebKit(navigator.userAgent, navigator.vendor),
//@ts-ignore });
setCurrentSource(source); playbackDiagnosticsRef.current = {mode, nativeHlsSupport};
makeNewShaka();
if (mode === 'native') {
video.src = uri;
return () => {
playbackErrorReporterRef.current?.reset();
video.removeAttribute('src');
video.load();
};
} }
}, [source, nativeRefDefined, currentSource, makeNewShaka]);
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;
}
const errorString = `${describeHlsError(
data,
)}; mode=hls.js; nativeHlsSupport=${nativeHlsSupport || 'none'}`;
playbackErrorReporterRef.current?.addHls(
errorString,
`hls.js/${data.type}`,
);
});
hls.loadSource(uri);
hls.attachMedia(video);
return () => {
playbackErrorReporterRef.current?.reset();
hls.destroy();
};
}, [sourceType, uri]);
useMediaSession(source?.metadata, nativeRef, showNotificationControls); useMediaSession(source?.metadata, nativeRef, showNotificationControls);
const cropStartSeconds = (source?.cropStart || 0) / 1000;
return ( return (
<video <video
ref={nativeRef} ref={nativeRef}
@@ -416,8 +341,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={() => {
@@ -450,7 +374,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
return; return;
} }
onProgress?.({ onProgress?.({
currentTime: nativeRef.current.currentTime - cropStartSeconds, currentTime: nativeRef.current.currentTime,
playableDuration: nativeRef.current.buffered.length playableDuration: nativeRef.current.buffered.length
? nativeRef.current.buffered.end( ? nativeRef.current.buffered.end(
nativeRef.current.buffered.length - 1, nativeRef.current.buffered.length - 1,
@@ -461,15 +385,23 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
}} }}
onLoadedData={() => onReadyForDisplay?.()} onLoadedData={() => onReadyForDisplay?.()}
onError={() => { onError={() => {
if (!nativeRef.current?.error) { const video = nativeRef.current;
if (!video?.error) {
return; return;
} }
onError?.({ const diagnostics = playbackDiagnosticsRef.current;
error: { const mediaError = describeMediaError({
errorString: nativeRef.current.error.message ?? 'Unknown error', currentSrc: video.currentSrc,
code: nativeRef.current.error.code, networkState: video.networkState,
}, readyState: video.readyState,
error: video.error,
playbackMode: diagnostics?.mode,
nativeHlsSupport: diagnostics?.nativeHlsSupport,
}); });
playbackErrorReporterRef.current?.addMedia(
mediaError,
video.error.code,
);
}} }}
onLoadedMetadata={() => { onLoadedMetadata={() => {
if (source?.startPosition) { if (source?.startPosition) {
@@ -490,14 +422,13 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
} }
onSeeking={() => (isSeeking.current = true)} onSeeking={() => (isSeeking.current = true)}
onSeeked={() => { onSeeked={() => {
(isSeeking.current = false) isSeeking.current = false;
const currentTime = nativeRef.current?.currentTime ?? 0;
onSeekComplete?.({ onSeekComplete?.({
currentTime: (nativeRef.current?.currentTime || 0.0) - cropStartSeconds, currentTime,
seekTime: 0.0, seekTime: currentTime,
target: 0.0, target: 0,
});
})
}} }}
onVolumeChange={() => { onVolumeChange={() => {
if (!nativeRef.current) { if (!nativeRef.current) {
@@ -515,7 +446,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;
@@ -525,7 +458,7 @@ const useMediaSession = (
nativeRef: RefObject<HTMLVideoElement>, nativeRef: RefObject<HTMLVideoElement>,
showNotification: boolean, showNotification: boolean,
) => { ) => {
const isPlaying = !nativeRef.current?.paused ?? false; const isPlaying = nativeRef.current ? !nativeRef.current.paused : false;
const progress = nativeRef.current?.currentTime ?? 0; const progress = nativeRef.current?.currentTime ?? 0;
const duration = Number.isFinite(nativeRef.current?.duration) const duration = Number.isFinite(nativeRef.current?.duration)
? nativeRef.current?.duration ? nativeRef.current?.duration

248
src/webHls.ts Normal file
View File

@@ -0,0 +1,248 @@
export type WebHlsPlaybackMode = 'hls.js' | 'native';
export interface WebHlsPlaybackCapabilities {
hlsJsSupported: boolean;
nativeHlsSupport: string;
preferNativeHls: boolean;
}
export interface HlsErrorDetails {
type: string;
details: string;
reason?: string;
error?: {message?: string};
response?: {
code?: number;
text?: string;
url?: string;
data?: unknown;
};
url?: string;
}
export interface MediaElementDiagnostics {
currentSrc: string;
networkState: number;
readyState: number;
error: {code: number; message?: string} | null;
playbackMode?: WebHlsPlaybackMode;
nativeHlsSupport?: string;
}
export interface WebVideoError {
errorString: string;
code?: number;
domain: string;
}
const HLS_SOURCE_PATTERN = /\.m3u8(\?|#|$)/i;
const HLS_SOURCE_TYPES = new Set([
'application/vnd.apple.mpegurl',
'application/x-mpegurl',
'hls',
'm3u8',
]);
const MEDIA_ERROR_NAMES: Record<number, string> = {
1: 'MEDIA_ERR_ABORTED',
2: 'MEDIA_ERR_NETWORK',
3: 'MEDIA_ERR_DECODE',
4: 'MEDIA_ERR_SRC_NOT_SUPPORTED',
};
export const isHlsSource = (uri: string, sourceType?: string): boolean => {
const normalizedType = sourceType?.split(';', 1)[0]?.trim().toLowerCase();
return (
HLS_SOURCE_PATTERN.test(uri) ||
(normalizedType !== undefined && HLS_SOURCE_TYPES.has(normalizedType))
);
};
export const isAppleWebKit = (userAgent: string, vendor: string): boolean =>
/AppleWebKit/i.test(userAgent) && /Apple/i.test(vendor);
export const selectWebHlsPlaybackMode = (
uri: string,
sourceType: string | undefined,
{
hlsJsSupported,
nativeHlsSupport,
preferNativeHls,
}: WebHlsPlaybackCapabilities,
): WebHlsPlaybackMode => {
if (!isHlsSource(uri, sourceType)) {
return 'native';
}
if (preferNativeHls && nativeHlsSupport) {
return 'native';
}
return hlsJsSupported ? 'hls.js' : 'native';
};
const compactText = (value: string): string =>
value.replace(/\s+/g, ' ').trim();
const limitedText = (value: string, maxLength = 160): string => {
const compact = compactText(value);
return compact.length > maxLength
? `${compact.slice(0, maxLength)}`
: compact;
};
const SAFE_RESPONSE_MESSAGES = new Set([
'Playlist does not exist',
'Video stream not found',
]);
export const sanitizeDiagnosticUrl = (value: string): string => {
try {
const url = new URL(value);
url.username = '';
url.password = '';
url.search = url.search ? '?<redacted>' : '';
url.hash = url.hash ? '#<redacted>' : '';
return url.toString().replaceAll('%3Credacted%3E', '<redacted>');
} catch {
return value.replace(/[?#].*$/, '?<redacted>');
}
};
const sanitizeHlsText = (value: string, data: HlsErrorDetails): string => {
let sanitized = value;
for (const url of [data.response?.url, data.url]) {
if (url) {
sanitized = sanitized.replaceAll(url, sanitizeDiagnosticUrl(url));
}
}
return limitedText(sanitized);
};
export const describeHlsError = (data: HlsErrorDetails): string => {
const parts = [`hls.js fatal ${data.type}: ${data.details}`];
if (data.reason && data.reason !== data.details) {
parts.push(`reason=${sanitizeHlsText(data.reason, data)}`);
}
if (data.error?.message && data.error.message !== data.reason) {
parts.push(`message=${sanitizeHlsText(data.error.message, data)}`);
}
const response = data.response;
if (response) {
if (response.code !== undefined) {
const statusText = response.text ? ` ${limitedText(response.text)}` : '';
parts.push(`HTTP ${response.code}${statusText}`);
}
if (typeof response.data === 'string' && response.data.trim()) {
const responseBody = compactText(response.data);
if (SAFE_RESPONSE_MESSAGES.has(responseBody)) {
parts.push(`response=${responseBody}`);
} else {
parts.push(`responseBodyLength=${response.data.length}`);
}
}
if (response.url) {
parts.push(`url=${sanitizeDiagnosticUrl(response.url)}`);
}
} else if (data.url) {
parts.push(`url=${sanitizeDiagnosticUrl(data.url)}`);
}
return parts.join('; ');
};
export const createWebPlaybackErrorReporter = (
report: (error: WebVideoError) => void,
debounceMs = 50,
) => {
let hlsError: string | undefined;
let hlsDomain: string | undefined;
let mediaError: string | undefined;
let mediaCode: number | undefined;
let timeout: ReturnType<typeof setTimeout> | undefined;
let reported = false;
const clearTimer = () => {
if (timeout !== undefined) {
clearTimeout(timeout);
timeout = undefined;
}
};
const schedule = () => {
if (reported) {
return;
}
clearTimer();
timeout = setTimeout(() => {
timeout = undefined;
reported = true;
const errorString = [hlsError, mediaError].filter(Boolean).join('; ');
report({
errorString,
code: mediaCode,
domain: hlsError
? mediaError
? 'hls.js/HTMLMediaElement'
: hlsDomain || 'hls.js'
: 'HTMLMediaElement',
});
}, debounceMs);
};
const reset = () => {
clearTimer();
hlsError = undefined;
hlsDomain = undefined;
mediaError = undefined;
mediaCode = undefined;
reported = false;
};
return {
addHls(errorString: string, domain: string) {
if (reported) {
return;
}
hlsError = errorString;
hlsDomain = domain;
schedule();
},
addMedia(errorString: string, code: number) {
if (reported) {
return;
}
mediaError = errorString;
mediaCode = code;
schedule();
},
reset,
};
};
export const describeMediaError = (media: MediaElementDiagnostics): string => {
const code = media.error?.code ?? 0;
const errorName = MEDIA_ERROR_NAMES[code] ?? 'MEDIA_ERR_UNKNOWN';
const parts = [`HTMLMediaElement ${errorName} (${code})`];
const message = media.error?.message?.trim();
if (message) {
parts.push(`message=${limitedText(message)}`);
}
if (media.playbackMode) {
parts.push(`mode=${media.playbackMode}`);
}
if (media.nativeHlsSupport !== undefined) {
parts.push(`nativeHlsSupport=${media.nativeHlsSupport || 'none'}`);
}
if (media.currentSrc) {
parts.push(`src=${sanitizeDiagnosticUrl(media.currentSrc)}`);
}
parts.push(`networkState=${media.networkState}`);
parts.push(`readyState=${media.readyState}`);
return parts.join('; ');
};

1
test/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
lib/

9
test/tsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"composite": false,
"rootDir": "../src",
"outDir": "./lib"
},
"include": ["../src/webHls.ts"]
}

228
test/web-hls.test.js Normal file
View File

@@ -0,0 +1,228 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const assert = require('node:assert/strict');
const test = require('node:test');
const {
createWebPlaybackErrorReporter,
describeHlsError,
describeMediaError,
isAppleWebKit,
isHlsSource,
sanitizeDiagnosticUrl,
selectWebHlsPlaybackMode,
} = require('./lib/webHls');
test('recognizes HLS playlist URLs with query strings', () => {
assert.equal(isHlsSource('https://api.example/playlist/42.m3u8?vod=1'), true);
assert.equal(isHlsSource('https://cdn.example/video.mp4'), false);
assert.equal(
isHlsSource('https://api.example/playlist/42', 'application/x-mpegURL'),
true,
);
});
test('uses hls.js in Chromium even when native HLS support reports maybe', () => {
const userAgent =
'Mozilla/5.0 Chrome/146.0.0.0 Safari/537.36 AppleWebKit/537.36';
assert.equal(isAppleWebKit(userAgent, 'Google Inc.'), false);
assert.equal(
selectWebHlsPlaybackMode(
'https://api.example/playlist/42.m3u8',
undefined,
{
hlsJsSupported: true,
nativeHlsSupport: 'maybe',
preferNativeHls: isAppleWebKit(userAgent, 'Google Inc.'),
},
),
'hls.js',
);
});
test('uses hls.js for an extensionless HLS source with an explicit type', () => {
assert.equal(
selectWebHlsPlaybackMode(
'https://api.example/playlist/42',
'application/vnd.apple.mpegurl; charset=utf-8',
{
hlsJsSupported: true,
nativeHlsSupport: '',
preferNativeHls: false,
},
),
'hls.js',
);
});
test('preserves native HLS in Apple WebKit browsers', () => {
const userAgent =
'Mozilla/5.0 Version/18.6 Safari/605.1.15 AppleWebKit/605.1.15';
assert.equal(isAppleWebKit(userAgent, 'Apple Computer, Inc.'), true);
assert.equal(
selectWebHlsPlaybackMode(
'https://api.example/playlist/42.m3u8',
undefined,
{
hlsJsSupported: true,
nativeHlsSupport: 'maybe',
preferNativeHls: isAppleWebKit(userAgent, 'Apple Computer, Inc.'),
},
),
'native',
);
});
test('falls back to native playback when hls.js is unavailable', () => {
assert.equal(
selectWebHlsPlaybackMode(
'https://api.example/playlist/42.m3u8',
undefined,
{
hlsJsSupported: false,
nativeHlsSupport: 'probably',
preferNativeHls: false,
},
),
'native',
);
assert.equal(
selectWebHlsPlaybackMode('https://cdn.example/video.mp4', undefined, {
hlsJsSupported: true,
nativeHlsSupport: '',
preferNativeHls: false,
}),
'native',
);
});
test('includes fatal HLS network and response details', () => {
assert.equal(
describeHlsError({
type: 'networkError',
details: 'manifestParsingError',
reason: 'no EXTM3U delimiter',
error: {message: 'manifest parsing failed'},
response: {
code: 200,
text: 'OK',
data: 'Playlist does not exist',
url: 'https://api.example/playlist/42.m3u8',
},
}),
'hls.js fatal networkError: manifestParsingError; ' +
'reason=no EXTM3U delimiter; message=manifest parsing failed; HTTP 200 OK; ' +
'response=Playlist does not exist; ' +
'url=https://api.example/playlist/42.m3u8',
);
});
test('redacts URL credentials, query values, and unknown response bodies', () => {
assert.equal(
sanitizeDiagnosticUrl(
'https://user:password@cdn.example/42.m3u8?token=secret#private',
),
'https://cdn.example/42.m3u8?<redacted>#<redacted>',
);
assert.equal(
describeHlsError({
type: 'networkError',
details: 'manifestLoadError',
response: {
code: 403,
data: 'internal credential: secret',
url: 'https://cdn.example/42.m3u8?token=secret',
},
}),
'hls.js fatal networkError: manifestLoadError; HTTP 403; ' +
'responseBodyLength=27; url=https://cdn.example/42.m3u8?<redacted>',
);
assert.equal(
describeHlsError({
type: 'keySystemError',
details: 'licenseRequestFailed',
error: {
message:
'License request failed (https://license.example/key?token=secret)',
},
url: 'https://license.example/key?token=secret',
}),
'hls.js fatal keySystemError: licenseRequestFailed; ' +
'message=License request failed (https://license.example/key?<redacted>); ' +
'url=https://license.example/key?<redacted>',
);
});
const wait = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
for (const order of ['hls-first', 'media-first']) {
test(`combines HLS and media errors once when ${order}`, async () => {
const reports = [];
const reporter = createWebPlaybackErrorReporter(
(error) => reports.push(error),
5,
);
const addHls = () =>
reporter.addHls(
'hls.js fatal mediaError: bufferAppendError',
'hls.js/mediaError',
);
const addMedia = () =>
reporter.addMedia('HTMLMediaElement MEDIA_ERR_DECODE (3)', 3);
if (order === 'hls-first') {
addHls();
addMedia();
} else {
addMedia();
addHls();
}
await wait(15);
assert.deepEqual(reports, [
{
errorString:
'hls.js fatal mediaError: bufferAppendError; ' +
'HTMLMediaElement MEDIA_ERR_DECODE (3)',
code: 3,
domain: 'hls.js/HTMLMediaElement',
},
]);
});
}
test('reset cancels a pending source error and permits the next source', async () => {
const reports = [];
const reporter = createWebPlaybackErrorReporter(
(error) => reports.push(error),
5,
);
reporter.addHls('stale source failure', 'hls.js/networkError');
reporter.reset();
reporter.addMedia('current source failure', 4);
await wait(15);
assert.deepEqual(reports, [
{
errorString: 'current source failure',
code: 4,
domain: 'HTMLMediaElement',
},
]);
});
test('names media error code 4 and records the selected playback path', () => {
assert.equal(
describeMediaError({
currentSrc: 'https://api.example/playlist/42.m3u8',
networkState: 3,
readyState: 0,
error: {code: 4},
playbackMode: 'native',
nativeHlsSupport: 'maybe',
}),
'HTMLMediaElement MEDIA_ERR_SRC_NOT_SUPPORTED (4); mode=native; ' +
'nativeHlsSupport=maybe; src=https://api.example/playlist/42.m3u8; ' +
'networkState=3; readyState=0',
);
});

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,