adaptive playback rate and intelligent switching improvements

This commit is contained in:
Josh Hawkins 2024-08-16 09:27:26 -05:00
parent d10714af4a
commit 2b0c3a7bf6
3 changed files with 84 additions and 15 deletions

View File

@ -13,7 +13,6 @@ import {
LivePlayerMode, LivePlayerMode,
VideoResolutionType, VideoResolutionType,
} from "@/types/live"; } from "@/types/live";
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import Chip from "../indicators/Chip"; import Chip from "../indicators/Chip";
import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { capitalizeFirstLetter } from "@/utils/stringUtil";
@ -25,7 +24,7 @@ type LivePlayerProps = {
containerRef?: React.MutableRefObject<HTMLDivElement | null>; containerRef?: React.MutableRefObject<HTMLDivElement | null>;
className?: string; className?: string;
cameraConfig: CameraConfig; cameraConfig: CameraConfig;
preferredLiveMode?: LivePlayerMode; preferredLiveMode: LivePlayerMode;
showStillWithoutActivity?: boolean; showStillWithoutActivity?: boolean;
windowVisible?: boolean; windowVisible?: boolean;
playAudio?: boolean; playAudio?: boolean;
@ -70,8 +69,6 @@ export default function LivePlayer({
// camera live state // camera live state
const liveMode = useCameraLiveMode(cameraConfig, preferredLiveMode);
const [liveReady, setLiveReady] = useState(false); const [liveReady, setLiveReady] = useState(false);
const liveReadyRef = useRef(liveReady); const liveReadyRef = useRef(liveReady);
@ -152,7 +149,7 @@ export default function LivePlayer({
let player; let player;
if (!autoLive) { if (!autoLive) {
player = null; player = null;
} else if (liveMode == "webrtc") { } else if (preferredLiveMode == "webrtc") {
player = ( player = (
<WebRtcPlayer <WebRtcPlayer
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`} className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
@ -166,7 +163,7 @@ export default function LivePlayer({
onError={onError} onError={onError}
/> />
); );
} else if (liveMode == "mse") { } else if (preferredLiveMode == "mse") {
if ("MediaSource" in window || "ManagedMediaSource" in window) { if ("MediaSource" in window || "ManagedMediaSource" in window) {
player = ( player = (
<MSEPlayer <MSEPlayer
@ -187,7 +184,7 @@ export default function LivePlayer({
</div> </div>
); );
} }
} else if (liveMode == "jsmpeg") { } else if (preferredLiveMode == "jsmpeg") {
if (cameraActive || !showStillWithoutActivity || liveReady) { if (cameraActive || !showStillWithoutActivity || liveReady) {
player = ( player = (
<JSMpegPlayer <JSMpegPlayer
@ -209,6 +206,10 @@ export default function LivePlayer({
player = <ActivityIndicator />; player = <ActivityIndicator />;
} }
useEffect(() => {
console.log(cameraConfig.name, "switching to", preferredLiveMode);
}, [preferredLiveMode]);
return ( return (
<div <div
ref={cameraRef ?? internalContainerRef} ref={cameraRef ?? internalContainerRef}

View File

@ -216,6 +216,7 @@ function MSEPlayer({
type: "mse", type: "mse",
// @ts-expect-error for typing // @ts-expect-error for typing
value: codecs(MediaSource.isTypeSupported), value: codecs(MediaSource.isTypeSupported),
duration: Number.POSITIVE_INFINITY, // https://stackoverflow.com/questions/74461792/mediasource-api-safari-pauses-video-on-buffer-underflow
}, },
3000, 3000,
).catch(() => { ).catch(() => {
@ -245,6 +246,7 @@ function MSEPlayer({
{ {
type: "mse", type: "mse",
value: codecs(MediaSource.isTypeSupported), value: codecs(MediaSource.isTypeSupported),
duration: Number.POSITIVE_INFINITY, // https://stackoverflow.com/questions/74461792/mediasource-api-safari-pauses-video-on-buffer-underflow
}, },
3000, 3000,
).catch(() => { ).catch(() => {
@ -268,7 +270,23 @@ function MSEPlayer({
onmessageRef.current["mse"] = (msg) => { onmessageRef.current["mse"] = (msg) => {
if (msg.type !== "mse") return; if (msg.type !== "mse") return;
const sb = msRef.current?.addSourceBuffer(msg.value); let sb: SourceBuffer | undefined;
try {
sb = msRef.current?.addSourceBuffer(msg.value);
} catch (e) {
// Safari sometimes throws this error
if (e instanceof DOMException && e.name === "InvalidStateError") {
if (wsRef.current) {
onDisconnect();
}
console.log(camera, "threw InvalidStateError");
onError?.("mse-decode");
return;
} else {
throw e; // Re-throw if it's not the error we're handling
}
}
sb?.addEventListener("updateend", () => { sb?.addEventListener("updateend", () => {
if (sb.updating) return; if (sb.updating) return;
@ -322,6 +340,24 @@ function MSEPlayer({
return averageBufferTime * 1.5; return averageBufferTime * 1.5;
}; };
const calculateAdaptivePlaybackRate = (
bufferTime: number,
bufferThreshold: number,
) => {
const alpha = 0.2; // aggressiveness of playback rate increase
const beta = 0.5; // steepness of exponential growth
// don't adjust playback rate if we're close enough to live
if (
(bufferTime <= bufferThreshold && bufferThreshold < 3) ||
bufferTime < 3
) {
return 1;
}
const rate = 1 + alpha * Math.exp(beta * bufferTime - bufferThreshold);
return Math.min(rate, 2);
};
useEffect(() => { useEffect(() => {
if (!playbackEnabled) { if (!playbackEnabled) {
return; return;
@ -407,13 +443,17 @@ function MSEPlayer({
onPlaying?.(); onPlaying?.();
setIsPlaying(true); setIsPlaying(true);
lastJumpTimeRef.current = Date.now(); lastJumpTimeRef.current = Date.now();
console.log(camera, "loaded mse data");
}} }}
muted={!audioEnabled} muted={!audioEnabled}
onPause={handlePause} onPause={handlePause}
onProgress={() => { onProgress={() => {
const bufferTime = getBufferedTime(videoRef.current); const bufferTime = getBufferedTime(videoRef.current);
if (videoRef.current && videoRef.current.playbackRate === 1) { if (
videoRef.current &&
(videoRef.current.playbackRate === 1 || bufferTime < 3)
) {
if (bufferTimes.current.length < MAX_BUFFER_ENTRIES) { if (bufferTimes.current.length < MAX_BUFFER_ENTRIES) {
bufferTimes.current.push(bufferTime); bufferTimes.current.push(bufferTime);
} else { } else {
@ -434,6 +474,21 @@ function MSEPlayer({
onPlaying?.(); onPlaying?.();
} }
// if we have more than 10 seconds of buffer, something's wrong so error out
if (
isPlaying &&
playbackEnabled &&
(bufferThreshold > 10 || bufferTime > 10)
) {
onDisconnect();
onError?.("stalled");
}
const playbackRate = calculateAdaptivePlaybackRate(
bufferTime,
bufferThreshold,
);
// if we're above our rolling average threshold or have > 3 seconds of // if we're above our rolling average threshold or have > 3 seconds of
// buffered data and we're playing, we may have drifted from actual live // buffered data and we're playing, we may have drifted from actual live
// time, so increase playback rate to compensate // time, so increase playback rate to compensate
@ -441,16 +496,25 @@ function MSEPlayer({
videoRef.current && videoRef.current &&
isPlaying && isPlaying &&
playbackEnabled && playbackEnabled &&
(bufferTime > bufferThreshold || bufferTime > 3) &&
Date.now() - lastJumpTimeRef.current > BUFFERING_COOLDOWN_TIMEOUT Date.now() - lastJumpTimeRef.current > BUFFERING_COOLDOWN_TIMEOUT
) { ) {
videoRef.current.playbackRate = 1.1; videoRef.current.playbackRate = playbackRate;
} else {
if (videoRef.current) {
videoRef.current.playbackRate = 1;
}
} }
console.log(
camera,
"isPlaying?",
isPlaying,
"playbackEnabled?",
playbackEnabled,
"bufferTime",
bufferTime,
"bufferThreshold",
bufferThreshold,
"playbackRate",
playbackRate,
);
if (onError != undefined) { if (onError != undefined) {
if (videoRef.current?.paused) { if (videoRef.current?.paused) {
return; return;

View File

@ -227,6 +227,10 @@ export default function LiveCameraView({
return "webrtc"; return "webrtc";
} }
if (!isRestreamed) {
return "jsmpeg";
}
return "mse"; return "mse";
}, [lowBandwidth, mic, webRTC, isRestreamed]); }, [lowBandwidth, mic, webRTC, isRestreamed]);