diff --git a/src/Video.web.tsx b/src/Video.web.tsx index 1e381a95..6802b9c5 100644 --- a/src/Video.web.tsx +++ b/src/Video.web.tsx @@ -12,7 +12,8 @@ import { createWebPlaybackErrorReporter, describeHlsError, describeMediaError, - isHlsSource, + isAppleWebKit, + selectWebHlsPlaybackMode, type WebHlsPlaybackMode, } from './webHls'; @@ -270,6 +271,7 @@ const Video = forwardRef( }>(); const uri = source?.uri as string | undefined; + const sourceType = source?.type; useEffect(() => { const video = nativeRef.current; @@ -279,10 +281,11 @@ const Video = forwardRef( 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( playbackErrorReporterRef.current?.reset(); hls.destroy(); }; - }, [uri]); + }, [sourceType, uri]); useMediaSession(source?.metadata, nativeRef, showNotificationControls); diff --git a/src/webHls.ts b/src/webHls.ts index e3ac990c..fdcdde18 100644 --- a/src/webHls.ts +++ b/src/webHls.ts @@ -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 = { 1: 'MEDIA_ERR_ABORTED', @@ -38,8 +50,36 @@ const MEDIA_ERROR_NAMES: Record = { 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(); diff --git a/test/web-hls.test.js b/test/web-hls.test.js index 35f88b8b..ac240a9c 100644 --- a/test/web-hls.test.js +++ b/test/web-hls.test.js @@ -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', () => {