From b668e802d98da2598bce361239ff8d9f4cb4e1be Mon Sep 17 00:00:00 2001 From: nrlcode Date: Tue, 24 Mar 2026 14:42:43 -0700 Subject: [PATCH] History view Milestone A: sessionized playback readiness Cherry-picked from 5ce96f0f, excluding docs/docs/development/history-view-spec.md for dev branch. --- web/src/components/player/HlsVideoPlayer.tsx | 28 ++- .../player/dynamic/DynamicVideoPlayer.tsx | 207 ++++++++++++++---- 2 files changed, 186 insertions(+), 49 deletions(-) diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index 7d762912c..9a7c8aa9d 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -97,6 +97,7 @@ export default function HlsVideoPlayer({ const hlsRef = useRef(undefined); const [useHlsCompat, setUseHlsCompat] = useState(false); const [loadedMetadata, setLoadedMetadata] = useState(false); + const metadataResolvedRef = useRef(false); const [bufferTimeout, setBufferTimeout] = useState(); const applyVideoDimensions = useCallback( @@ -113,11 +114,20 @@ export default function HlsVideoPlayer({ ); const handleLoadedMetadata = useCallback(() => { - setLoadedMetadata(true); if (!videoRef.current) { return; } + const markMetadataResolved = () => { + if (metadataResolvedRef.current) { + return; + } + + metadataResolvedRef.current = true; + setLoadedMetadata(true); + onPlayerLoaded?.(); + }; + const width = videoRef.current.videoWidth; const height = videoRef.current.videoHeight; @@ -125,6 +135,7 @@ export default function HlsVideoPlayer({ // Poll with requestAnimationFrame until dimensions become available (or timeout). if (width > 0 && height > 0) { applyVideoDimensions(width, height); + markMetadataResolved(); return; } @@ -136,15 +147,19 @@ export default function HlsVideoPlayer({ const h = videoRef.current.videoHeight; if (w > 0 && h > 0) { applyVideoDimensions(w, h); + markMetadataResolved(); return; } if (attempts < maxAttempts) { attempts += 1; requestAnimationFrame(tryGetDims); + } else { + // Fallback: avoid blocking playback forever if dimensions remain unavailable. + markMetadataResolved(); } }; requestAnimationFrame(tryGetDims); - }, [videoRef, applyVideoDimensions]); + }, [videoRef, applyVideoDimensions, onPlayerLoaded]); useEffect(() => { if (!videoRef.current) { @@ -163,7 +178,10 @@ export default function HlsVideoPlayer({ return; } + metadataResolvedRef.current = false; setLoadedMetadata(false); + setVideoDimensions({ width: 0, height: 0 }); + setTallCamera(false); const currentPlaybackRate = videoRef.current.playbackRate; @@ -273,6 +291,7 @@ export default function HlsVideoPlayer({ return ( setZoomScale(zoom.state.scale)} @@ -469,8 +488,7 @@ export default function HlsVideoPlayer({ onTimeUpdate(frameTime); } }} - onLoadedData={() => { - onPlayerLoaded?.(); + onLoadedMetadata={() => { handleLoadedMetadata(); if (videoRef.current) { @@ -483,6 +501,7 @@ export default function HlsVideoPlayer({ } } }} + onLoadedData={handleLoadedMetadata} onEnded={() => { if (onClipEnded) { onClipEnded(getVideoTime() ?? 0); @@ -498,6 +517,7 @@ export default function HlsVideoPlayer({ setLoadedMetadata(false); setUseHlsCompat(true); } else { + onError?.("startup"); toast.error( // @ts-expect-error code does exist `Failed to play recordings (error ${e.target.error.code}): ${e.target.error.message}`, diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index c8d95090d..373e17926 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, + useReducer, useRef, useState, } from "react"; @@ -27,6 +28,56 @@ import { } from "@/utils/videoUtil"; import { isFirefox } from "react-device-detect"; +type PlaybackPhase = + | "idle" + | "loadingSource" + | "awaitingMetadata" + | "ready" + | "buffering" + | "error"; + +type PlaybackSessionState = { + sessionId: number; + phase: PlaybackPhase; +}; + +type PlaybackSessionAction = + | { + type: "beginSession"; + sessionId: number; + phase: Extract; + } + | { + type: "setPhase"; + sessionId: number; + phase: PlaybackPhase; + }; + +function playbackSessionReducer( + state: PlaybackSessionState, + action: PlaybackSessionAction, +): PlaybackSessionState { + if (action.sessionId < state.sessionId) { + return state; + } + + if (action.type === "beginSession") { + if (action.sessionId <= state.sessionId) { + return state; + } + + return { + sessionId: action.sessionId, + phase: action.phase, + }; + } + + return { + sessionId: action.sessionId, + phase: action.phase, + }; +} + /** * Dynamically switches between video playback and scrubbing preview player. */ @@ -118,37 +169,46 @@ export default function DynamicVideoPlayer({ // initial state - const [isLoading, setIsLoading] = useState(false); - const [isBuffering, setIsBuffering] = useState(false); - const [loadingTimeout, setLoadingTimeout] = useState(); + const [playbackSession, dispatchPlaybackSession] = useReducer( + playbackSessionReducer, + { + sessionId: 0, + phase: "idle" as PlaybackPhase, + }, + ); + const playbackSessionCounterRef = useRef(0); // Don't set source until recordings load - we need accurate startPosition // to avoid hls.js clamping to video end when startPosition exceeds duration const [source, setSource] = useState(undefined); - // start at correct time + const beginPlaybackSession = useCallback( + (phase: Extract) => { + playbackSessionCounterRef.current += 1; + const nextSessionId = playbackSessionCounterRef.current; - useEffect(() => { - if (!isScrubbing) { - setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000)); - } - - return () => { - if (loadingTimeout) { - clearTimeout(loadingTimeout); - } - }; - // we only want trigger when scrubbing state changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [camera, isScrubbing]); + dispatchPlaybackSession({ + type: "beginSession", + sessionId: nextSessionId, + phase, + }); + }, + [], + ); const onPlayerLoaded = useCallback(() => { + dispatchPlaybackSession({ + type: "setPhase", + sessionId: playbackSession.sessionId, + phase: "ready", + }); + if (!controller || !startTimestamp) { return; } controller.seekToTimestamp(startTimestamp, true); - }, [startTimestamp, controller]); + }, [startTimestamp, controller, playbackSession.sessionId]); const onTimeUpdate = useCallback( (time: number) => { @@ -156,17 +216,23 @@ export default function DynamicVideoPlayer({ return; } - if (isLoading) { - setIsLoading(false); - } - - if (isBuffering) { - setIsBuffering(false); + if (playbackSession.phase === "buffering") { + dispatchPlaybackSession({ + type: "setPhase", + sessionId: playbackSession.sessionId, + phase: "ready", + }); } onTimestampUpdate(controller.getProgress(time)); }, - [controller, onTimestampUpdate, isBuffering, isLoading, isScrubbing], + [ + controller, + onTimestampUpdate, + isScrubbing, + playbackSession.phase, + playbackSession.sessionId, + ], ); const onUploadFrameToPlus = useCallback( @@ -196,11 +262,29 @@ export default function DynamicVideoPlayer({ ); useEffect(() => { - if (!recordings?.length) { - if (recordings?.length == 0) { - setNoRecording(true); - } + beginPlaybackSession("loadingSource"); + setNoRecording(false); + setSource(undefined); + }, [ + beginPlaybackSession, + camera, + recordingParams.after, + recordingParams.before, + ]); + useEffect(() => { + if (!recordings) { + return; + } + + if (recordings.length == 0) { + setSource(undefined); + setNoRecording(true); + dispatchPlaybackSession({ + type: "setPhase", + sessionId: playbackSession.sessionId, + phase: "idle", + }); return; } @@ -219,13 +303,21 @@ export default function DynamicVideoPlayer({ ); } - setSource({ + const nextSource = { playlist: `${apiHost}vod/${camera}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`, startPosition, + }; + + setSource(nextSource); + setNoRecording(false); + dispatchPlaybackSession({ + type: "setPhase", + sessionId: playbackSession.sessionId, + phase: "awaitingMetadata", }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [recordings]); + }, [recordings, playbackSession.sessionId]); useEffect(() => { if (!controller || !recordings?.length) { @@ -236,8 +328,6 @@ export default function DynamicVideoPlayer({ playerRef.current.autoplay = !isScrubbing; } - setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000)); - controller.newPlayback({ recordings: recordings ?? [], timeRange, @@ -279,13 +369,30 @@ export default function DynamicVideoPlayer({ [onClipEnded, controller, recordings], ); + const showPreview = + isScrubbing || + (!noRecording && + playbackSession.phase !== "ready" && + playbackSession.phase !== "buffering"); + const showLoadingIndicator = + !isScrubbing && + !noRecording && + (playbackSession.phase === "loadingSource" || + playbackSession.phase === "awaitingMetadata" || + playbackSession.phase === "buffering"); + const showNoRecordings = !isScrubbing && noRecording; + return ( <> {source && ( { if (error == "stalled" && !isScrubbing) { - setIsBuffering(true); + dispatchPlaybackSession({ + type: "setPhase", + sessionId: playbackSession.sessionId, + phase: "buffering", + }); + } else { + dispatchPlaybackSession({ + type: "setPhase", + sessionId: playbackSession.sessionId, + phase: "error", + }); } }} isDetailMode={isDetailMode} @@ -325,10 +445,7 @@ export default function DynamicVideoPlayer({ /> )} - {!isScrubbing && (isLoading || isBuffering) && !noRecording && ( + {showLoadingIndicator && ( )} - {!isScrubbing && !isLoading && noRecording && ( + {showNoRecordings && (
{t("noRecordingsFoundForThisTime")}