From 9eef369dd0fc2e32d1bd7bc2f1bcfd8bb0a89f30 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:11:36 -0500 Subject: [PATCH] fix latched loading spinner after cancelling a timeline selection (#24162) isLoading and isBuffering only clear on playback progress, and scrubbing holds the player paused, so a source rebuild during a timeline selection left them set with nothing able to clear them. Cancelling made them visible as a spinner over an already-loaded frame, which stayed until the next manual seek. Leaving a scrub now clears them when the source is loaded and the element holds a frame. --- .../player/dynamic/DynamicVideoPlayer.tsx | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 62b95b313f..88153aaad2 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -158,6 +158,7 @@ export default function DynamicVideoPlayer({ const [isLoading, setIsLoading] = useState(false); const [isBuffering, setIsBuffering] = useState(false); const loadingTimeoutRef = useRef(undefined); + const prevCameraRef = useRef(camera); // Don't set source until recordings load - we need accurate startPosition // to avoid hls.js clamping to video end when startPosition exceeds duration @@ -166,13 +167,34 @@ export default function DynamicVideoPlayer({ // start at correct time useEffect(() => { + // isLoading/isBuffering only clear on playback progress, which a + // paused scrub never reaches. A camera switch is excluded: its old + // source still reads as settled while the new one is fetched + const scrubExit = prevCameraRef.current === camera; + prevCameraRef.current = camera; + const settled = () => + sourceLoadedRef.current && + (playerRef.current?.readyState ?? 0) >= + HTMLMediaElement.HAVE_CURRENT_DATA; + + if (!isScrubbing && scrubExit && settled()) { + setIsLoading(false); + setIsBuffering(false); + } + if (!isScrubbing) { // never overwrite a pending timer: an orphaned one escapes // onPlaying's clearTimeout and flashes loading mid-playback if (loadingTimeoutRef.current) { clearTimeout(loadingTimeoutRef.current); } - loadingTimeoutRef.current = setTimeout(() => setIsLoading(true), 1000); + loadingTimeoutRef.current = setTimeout(() => { + // re-checked here: readyState drops while a seek loads + if (scrubExit && settled()) { + return; + } + setIsLoading(true); + }, 1000); } return () => {