Merge pull request 'Fix Chromium HLS playback on web' (#12)
All checks were successful
Check JS / Check TS (tsc) (push) Successful in 29s
Check JS / Lint JS (eslint, prettier) (push) Successful in 34s

Restore Chromium HLS playback, preserve Safari native HLS, and surface actionable media diagnostics.
This commit is contained in:
2026-08-12 22:22:05 +00:00
9 changed files with 684 additions and 268 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

@@ -31,11 +31,10 @@
"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"
"patch-package": "^8.0.0"
}, },
"dependencies": { "dependencies": {
"shaka-player": "^4.11.7", "hls.js": "^1.6.16",
"typescript": "5.6.3" "typescript": "5.6.3"
}, },
"peerDependencies": { "peerDependencies": {
@@ -51,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",

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.absoluteFill, 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.absoluteFill, 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) {
@@ -527,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',
);
});