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.
This commit is contained in:
@@ -32,11 +32,10 @@
|
||||
"react-native": "0.73.2",
|
||||
"react-native-windows": "^0.61.0-0",
|
||||
"release-it": "^16.2.1",
|
||||
"typescript": "5.1.6",
|
||||
"patch-package": "^8.0.0"
|
||||
"typescript": "5.1.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"shaka-player": "^4.11.7"
|
||||
"hls.js": "^1.6.16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
|
||||
@@ -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
|
||||
@@ -4,70 +4,26 @@ import React, {
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
//@ts-ignore
|
||||
import shaka from 'shaka-player';
|
||||
import Hls from 'hls.js';
|
||||
import type {VideoRef, ReactVideoProps, VideoMetadata} from './types';
|
||||
|
||||
// Action Queue Class
|
||||
class ActionQueue {
|
||||
private queue: { action: () => Promise<void>; name: string }[] = [];
|
||||
private isRunning = false;
|
||||
const HLS_MIME = 'application/vnd.apple.mpegurl';
|
||||
|
||||
enqueue(action: () => Promise<void>, name: string) {
|
||||
this.queue.push({ action, name });
|
||||
this.runNext();
|
||||
}
|
||||
|
||||
private async runNext() {
|
||||
if (this.isRunning || this.queue.length === 0) {
|
||||
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 isHlsSource = (uri: string) => /\.m3u8(\?|#|$)/i.test(uri);
|
||||
|
||||
// `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;
|
||||
return typeof source === 'object' && source !== null ? source.uri : undefined;
|
||||
};
|
||||
|
||||
const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
(
|
||||
@@ -99,15 +55,10 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
ref,
|
||||
) => {
|
||||
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 seek = useCallback(
|
||||
(time: number, _tolerance?: number) => {
|
||||
actionQueue.current.enqueue(async () => {
|
||||
async (time: number, _tolerance?: number) => {
|
||||
if (isNaN(time)) {
|
||||
throw new Error('Specified time is not a number');
|
||||
}
|
||||
@@ -117,44 +68,33 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
}
|
||||
time = Math.max(0, Math.min(time, nativeRef.current.duration));
|
||||
nativeRef.current.currentTime = time;
|
||||
onSeek?.({
|
||||
seekTime: time,
|
||||
currentTime: nativeRef.current.currentTime,
|
||||
});
|
||||
}, 'seek');
|
||||
onSeek?.({seekTime: time, currentTime: nativeRef.current.currentTime});
|
||||
},
|
||||
[onSeek],
|
||||
);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
actionQueue.current.enqueue(async () => {
|
||||
if (!nativeRef.current) {
|
||||
return;
|
||||
}
|
||||
await nativeRef.current.pause();
|
||||
}, 'pause');
|
||||
nativeRef.current.pause();
|
||||
}, []);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
actionQueue.current.enqueue(async () => {
|
||||
if (!nativeRef.current) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await nativeRef.current.play();
|
||||
} catch (e) {
|
||||
console.error('Error playing video:', e);
|
||||
}
|
||||
}, 'resume');
|
||||
// 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) => {
|
||||
actionQueue.current.enqueue(async () => {
|
||||
if (!nativeRef.current) {
|
||||
return;
|
||||
}
|
||||
nativeRef.current.volume = Math.max(0, Math.min(vol, 100)) / 100;
|
||||
}, 'setVolume');
|
||||
}, []);
|
||||
|
||||
const getCurrentPosition = useCallback(async () => {
|
||||
@@ -209,7 +149,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
console.error('Could not toggle fullscreen/screen lock status', e);
|
||||
}
|
||||
};
|
||||
actionQueue.current.enqueue(run, 'setFullScreen');
|
||||
run();
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -272,7 +212,6 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
resume();
|
||||
}
|
||||
}, [paused, pause, resume]);
|
||||
|
||||
useEffect(() => {
|
||||
if (volume === undefined) {
|
||||
return;
|
||||
@@ -307,107 +246,57 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
nativeRef.current.playbackRate = rate;
|
||||
}, [rate]);
|
||||
|
||||
const makeNewShaka = useCallback(() => {
|
||||
console.log("makeNewShaka");
|
||||
actionQueue.current.enqueue(async () => {
|
||||
console.log("makeNewShaka actionQueue");
|
||||
if (!nativeRef.current) {
|
||||
console.warn('No video element to attach Shaka Player');
|
||||
return;
|
||||
}
|
||||
// `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;
|
||||
|
||||
// Pause the video before changing the source
|
||||
nativeRef.current.pause();
|
||||
|
||||
// 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;
|
||||
const uri = source?.uri as string | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!nativeRef.current) {
|
||||
console.log('Not starting shaka yet because video element is undefined');
|
||||
const video = nativeRef.current;
|
||||
if (!video || !uri) {
|
||||
return;
|
||||
}
|
||||
if (!shallowEqual(source, currentSource)) {
|
||||
console.log(
|
||||
'Making new shaka, Old source: ',
|
||||
currentSource,
|
||||
'New source',
|
||||
source,
|
||||
);
|
||||
//@ts-ignore
|
||||
setCurrentSource(source);
|
||||
makeNewShaka();
|
||||
|
||||
// Safari (and iOS WebKit) plays HLS natively; everywhere else the browser
|
||||
// has no HLS support at all, so an .m3u8 has to go through hls.js.
|
||||
const needsMse =
|
||||
isHlsSource(uri) && !video.canPlayType(HLS_MIME) && Hls.isSupported();
|
||||
|
||||
if (!needsMse) {
|
||||
video.src = uri;
|
||||
return () => {
|
||||
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;
|
||||
}
|
||||
onErrorRef.current?.({
|
||||
error: {
|
||||
errorString: `${data.type}: ${data.details}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
hls.loadSource(uri);
|
||||
hls.attachMedia(video);
|
||||
|
||||
return () => {
|
||||
hls.destroy();
|
||||
};
|
||||
}, [uri]);
|
||||
|
||||
useMediaSession(source?.metadata, nativeRef, showNotificationControls);
|
||||
|
||||
const cropStartSeconds = (source?.cropStart || 0) / 1000;
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={nativeRef}
|
||||
@@ -416,8 +305,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
controls={controls}
|
||||
loop={repeat}
|
||||
playsInline
|
||||
//@ts-ignore
|
||||
poster={poster}
|
||||
poster={resolvePosterUri(poster)}
|
||||
onCanPlay={() => onBuffer?.({isBuffering: false})}
|
||||
onWaiting={() => onBuffer?.({isBuffering: true})}
|
||||
onRateChange={() => {
|
||||
@@ -450,7 +338,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
return;
|
||||
}
|
||||
onProgress?.({
|
||||
currentTime: nativeRef.current.currentTime - cropStartSeconds,
|
||||
currentTime: nativeRef.current.currentTime,
|
||||
playableDuration: nativeRef.current.buffered.length
|
||||
? nativeRef.current.buffered.end(
|
||||
nativeRef.current.buffered.length - 1,
|
||||
@@ -490,14 +378,13 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
}
|
||||
onSeeking={() => (isSeeking.current = true)}
|
||||
onSeeked={() => {
|
||||
(isSeeking.current = false)
|
||||
|
||||
isSeeking.current = false;
|
||||
const currentTime = nativeRef.current?.currentTime ?? 0;
|
||||
onSeekComplete?.({
|
||||
currentTime: (nativeRef.current?.currentTime || 0.0) - cropStartSeconds,
|
||||
seekTime: 0.0,
|
||||
target: 0.0,
|
||||
|
||||
})
|
||||
currentTime,
|
||||
seekTime: currentTime,
|
||||
target: 0,
|
||||
});
|
||||
}}
|
||||
onVolumeChange={() => {
|
||||
if (!nativeRef.current) {
|
||||
|
||||
Reference in New Issue
Block a user