From 7164f4b4a2cae8674d90390479948ce17b7ab15d Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:48:19 -0500 Subject: [PATCH] fix recording playback quality switches and silent codec failures A quality switch changes `bufferLength` a commit before the new source arrives, and since it was a dependency of the hls.js setup effect, the player rebuilt once on the outgoing playlist (jumping back to its original `startPosition`) and again on the new one. The buffer length now updates the running instance's config instead. A fatal codec error with no lower quality stream to fall back to did nothing at all, so playback just sat there with no message. It now shows the playback failure toast, which is also limited to once per source since hls.js and the video element can both report the same failure. --- web/src/components/player/HlsVideoPlayer.tsx | 64 ++++++++++++++++---- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index e0d606cffb..7b0a66004e 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -26,6 +26,7 @@ import { useIsAdmin } from "@/hooks/use-is-admin"; // Android native hls does not seek correctly const USE_NATIVE_HLS = false; const HLS_MIME_TYPE = "application/vnd.apple.mpegurl" as const; +const DEFAULT_MAX_BUFFER_LENGTH_S = 10; const unsupportedErrorCodes: number[] = [ MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED, MediaError.MEDIA_ERR_DECODE, @@ -148,6 +149,42 @@ export default function HlsVideoPlayer({ // a ref rather than an effect-scoped counter so the element error // handler can hold its toast while a recovery is still possible const mediaRecoveryBudgetRef = useRef(0); + // hls.js and the element can both report one failure, so each source + // toasts at most once + const failureReportedRef = useRef(false); + + const reportPlaybackFailure = useCallback( + (code: number, message: string) => { + if (failureReportedRef.current) { + return; + } + + failureReportedRef.current = true; + toast.error(t("toast.error.playRecordingsFailed", { code, message }), { + position: "top-center", + }); + }, + [t], + ); + const reportPlaybackFailureRef = useRef(reportPlaybackFailure); + + useEffect(() => { + reportPlaybackFailureRef.current = reportPlaybackFailure; + }, [reportPlaybackFailure]); + + // a quality switch changes the buffer length a commit before the new + // source arrives, so it updates the live instance instead of rebuilding + // it on the outgoing playlist + const bufferLengthRef = useRef(bufferLength); + + useEffect(() => { + bufferLengthRef.current = bufferLength; + + if (hlsRef.current) { + hlsRef.current.config.maxBufferLength = + bufferLength ?? DEFAULT_MAX_BUFFER_LENGTH_S; + } + }, [bufferLength]); const applyVideoDimensions = useCallback( (width: number, height: number) => { @@ -225,6 +262,7 @@ export default function HlsVideoPlayer({ // the element already holds a decoded frame, and keeping it visible // bridges the gap while the new source loads const currentPlaybackRate = videoRef.current.playbackRate; + failureReportedRef.current = false; if (!useHlsCompat) { nativeRetryRef.current = 0; @@ -236,7 +274,7 @@ export default function HlsVideoPlayer({ // Base HLS configuration const hlsConfig: Partial = { - maxBufferLength: bufferLength ?? 10, + maxBufferLength: bufferLengthRef.current ?? DEFAULT_MAX_BUFFER_LENGTH_S, maxBufferSize: 20 * 1000 * 1000, startPosition: currentSource.startPosition, }; @@ -269,10 +307,18 @@ export default function HlsVideoPlayer({ data.details === Hls.ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR || data.details === Hls.ErrorDetails.BUFFER_ADD_CODEC_ERROR; - if (isCodecError && qualitySignalsRef.current.onFatalCodecError?.()) { + if (isCodecError) { + // with no stream to fall back to, the element may never raise + // an error of its own, so report the failure here + if (!qualitySignalsRef.current.onFatalCodecError?.()) { + reportPlaybackFailureRef.current( + MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED, + data.details, + ); + } return; } - if (!isCodecError && mediaRecoveryBudgetRef.current > 0) { + if (mediaRecoveryBudgetRef.current > 0) { mediaRecoveryBudgetRef.current -= 1; hls.recoverMediaError(); } @@ -308,7 +354,7 @@ export default function HlsVideoPlayer({ hlsRef.current.destroy(); } }; - }, [videoRef, hlsRef, useHlsCompat, currentSource, bufferLength]); + }, [videoRef, hlsRef, useHlsCompat, currentSource]); // state handling @@ -714,15 +760,7 @@ export default function HlsVideoPlayer({ } } - toast.error( - t("toast.error.playRecordingsFailed", { - code: mediaError.code, - message: mediaError.message, - }), - { - position: "top-center", - }, - ); + reportPlaybackFailure(mediaError.code, mediaError.message); }} />