Route Chromium HLS through hls.js

This commit is contained in:
2026-08-12 13:59:59 -07:00
parent 04682d02c7
commit 42608bd8d1
3 changed files with 131 additions and 8 deletions

View File

@@ -12,7 +12,8 @@ import {
createWebPlaybackErrorReporter,
describeHlsError,
describeMediaError,
isHlsSource,
isAppleWebKit,
selectWebHlsPlaybackMode,
type WebHlsPlaybackMode,
} from './webHls';
@@ -270,6 +271,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
}>();
const uri = source?.uri as string | undefined;
const sourceType = source?.type;
useEffect(() => {
const video = nativeRef.current;
@@ -279,10 +281,11 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
playbackErrorReporterRef.current?.reset();
const nativeHlsSupport = video.canPlayType(HLS_MIME);
const mode: WebHlsPlaybackMode =
isHlsSource(uri) && !nativeHlsSupport && Hls.isSupported()
? 'hls.js'
: 'native';
const mode = selectWebHlsPlaybackMode(uri, sourceType, {
hlsJsSupported: Hls.isSupported(),
nativeHlsSupport,
preferNativeHls: isAppleWebKit(navigator.userAgent, navigator.vendor),
});
playbackDiagnosticsRef.current = {mode, nativeHlsSupport};
if (mode === 'native') {
@@ -316,7 +319,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
playbackErrorReporterRef.current?.reset();
hls.destroy();
};
}, [uri]);
}, [sourceType, uri]);
useMediaSession(source?.metadata, nativeRef, showNotificationControls);

View File

@@ -1,5 +1,11 @@
export type WebHlsPlaybackMode = 'hls.js' | 'native';
export interface WebHlsPlaybackCapabilities {
hlsJsSupported: boolean;
nativeHlsSupport: string;
preferNativeHls: boolean;
}
export interface HlsErrorDetails {
type: string;
details: string;
@@ -30,6 +36,12 @@ export interface WebVideoError {
}
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',
@@ -38,8 +50,36 @@ const MEDIA_ERROR_NAMES: Record<number, string> = {
4: 'MEDIA_ERR_SRC_NOT_SUPPORTED',
};
export const isHlsSource = (uri: string): boolean =>
HLS_SOURCE_PATTERN.test(uri);
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();

View File

@@ -6,13 +6,93 @@ 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', () => {