History view Milestone A: sessionized playback readiness

Cherry-picked from 5ce96f0f, excluding docs/docs/development/history-view-spec.md for dev branch.
This commit is contained in:
nrlcode 2026-03-24 14:42:43 -07:00
parent a8b6ea5005
commit b668e802d9
2 changed files with 186 additions and 49 deletions

View File

@ -97,6 +97,7 @@ export default function HlsVideoPlayer({
const hlsRef = useRef<Hls>(undefined); const hlsRef = useRef<Hls>(undefined);
const [useHlsCompat, setUseHlsCompat] = useState(false); const [useHlsCompat, setUseHlsCompat] = useState(false);
const [loadedMetadata, setLoadedMetadata] = useState(false); const [loadedMetadata, setLoadedMetadata] = useState(false);
const metadataResolvedRef = useRef(false);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>(); const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
const applyVideoDimensions = useCallback( const applyVideoDimensions = useCallback(
@ -113,11 +114,20 @@ export default function HlsVideoPlayer({
); );
const handleLoadedMetadata = useCallback(() => { const handleLoadedMetadata = useCallback(() => {
setLoadedMetadata(true);
if (!videoRef.current) { if (!videoRef.current) {
return; return;
} }
const markMetadataResolved = () => {
if (metadataResolvedRef.current) {
return;
}
metadataResolvedRef.current = true;
setLoadedMetadata(true);
onPlayerLoaded?.();
};
const width = videoRef.current.videoWidth; const width = videoRef.current.videoWidth;
const height = videoRef.current.videoHeight; const height = videoRef.current.videoHeight;
@ -125,6 +135,7 @@ export default function HlsVideoPlayer({
// Poll with requestAnimationFrame until dimensions become available (or timeout). // Poll with requestAnimationFrame until dimensions become available (or timeout).
if (width > 0 && height > 0) { if (width > 0 && height > 0) {
applyVideoDimensions(width, height); applyVideoDimensions(width, height);
markMetadataResolved();
return; return;
} }
@ -136,15 +147,19 @@ export default function HlsVideoPlayer({
const h = videoRef.current.videoHeight; const h = videoRef.current.videoHeight;
if (w > 0 && h > 0) { if (w > 0 && h > 0) {
applyVideoDimensions(w, h); applyVideoDimensions(w, h);
markMetadataResolved();
return; return;
} }
if (attempts < maxAttempts) { if (attempts < maxAttempts) {
attempts += 1; attempts += 1;
requestAnimationFrame(tryGetDims); requestAnimationFrame(tryGetDims);
} else {
// Fallback: avoid blocking playback forever if dimensions remain unavailable.
markMetadataResolved();
} }
}; };
requestAnimationFrame(tryGetDims); requestAnimationFrame(tryGetDims);
}, [videoRef, applyVideoDimensions]); }, [videoRef, applyVideoDimensions, onPlayerLoaded]);
useEffect(() => { useEffect(() => {
if (!videoRef.current) { if (!videoRef.current) {
@ -163,7 +178,10 @@ export default function HlsVideoPlayer({
return; return;
} }
metadataResolvedRef.current = false;
setLoadedMetadata(false); setLoadedMetadata(false);
setVideoDimensions({ width: 0, height: 0 });
setTallCamera(false);
const currentPlaybackRate = videoRef.current.playbackRate; const currentPlaybackRate = videoRef.current.playbackRate;
@ -273,6 +291,7 @@ export default function HlsVideoPlayer({
return ( return (
<TransformWrapper <TransformWrapper
key={`${currentSource.playlist}-${currentSource.startPosition ?? "auto"}`}
minScale={1.0} minScale={1.0}
wheel={{ smoothStep: 0.005 }} wheel={{ smoothStep: 0.005 }}
onZoom={(zoom) => setZoomScale(zoom.state.scale)} onZoom={(zoom) => setZoomScale(zoom.state.scale)}
@ -469,8 +488,7 @@ export default function HlsVideoPlayer({
onTimeUpdate(frameTime); onTimeUpdate(frameTime);
} }
}} }}
onLoadedData={() => { onLoadedMetadata={() => {
onPlayerLoaded?.();
handleLoadedMetadata(); handleLoadedMetadata();
if (videoRef.current) { if (videoRef.current) {
@ -483,6 +501,7 @@ export default function HlsVideoPlayer({
} }
} }
}} }}
onLoadedData={handleLoadedMetadata}
onEnded={() => { onEnded={() => {
if (onClipEnded) { if (onClipEnded) {
onClipEnded(getVideoTime() ?? 0); onClipEnded(getVideoTime() ?? 0);
@ -498,6 +517,7 @@ export default function HlsVideoPlayer({
setLoadedMetadata(false); setLoadedMetadata(false);
setUseHlsCompat(true); setUseHlsCompat(true);
} else { } else {
onError?.("startup");
toast.error( toast.error(
// @ts-expect-error code does exist // @ts-expect-error code does exist
`Failed to play recordings (error ${e.target.error.code}): ${e.target.error.message}`, `Failed to play recordings (error ${e.target.error.code}): ${e.target.error.message}`,

View File

@ -3,6 +3,7 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useReducer,
useRef, useRef,
useState, useState,
} from "react"; } from "react";
@ -27,6 +28,56 @@ import {
} from "@/utils/videoUtil"; } from "@/utils/videoUtil";
import { isFirefox } from "react-device-detect"; 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<PlaybackPhase, "loadingSource" | "awaitingMetadata">;
}
| {
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. * Dynamically switches between video playback and scrubbing preview player.
*/ */
@ -118,37 +169,46 @@ export default function DynamicVideoPlayer({
// initial state // initial state
const [isLoading, setIsLoading] = useState(false); const [playbackSession, dispatchPlaybackSession] = useReducer(
const [isBuffering, setIsBuffering] = useState(false); playbackSessionReducer,
const [loadingTimeout, setLoadingTimeout] = useState<NodeJS.Timeout>(); {
sessionId: 0,
phase: "idle" as PlaybackPhase,
},
);
const playbackSessionCounterRef = useRef(0);
// Don't set source until recordings load - we need accurate startPosition // Don't set source until recordings load - we need accurate startPosition
// to avoid hls.js clamping to video end when startPosition exceeds duration // to avoid hls.js clamping to video end when startPosition exceeds duration
const [source, setSource] = useState<HlsSource | undefined>(undefined); const [source, setSource] = useState<HlsSource | undefined>(undefined);
// start at correct time const beginPlaybackSession = useCallback(
(phase: Extract<PlaybackPhase, "loadingSource" | "awaitingMetadata">) => {
playbackSessionCounterRef.current += 1;
const nextSessionId = playbackSessionCounterRef.current;
useEffect(() => { dispatchPlaybackSession({
if (!isScrubbing) { type: "beginSession",
setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000)); sessionId: nextSessionId,
} phase,
});
return () => { },
if (loadingTimeout) { [],
clearTimeout(loadingTimeout); );
}
};
// we only want trigger when scrubbing state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [camera, isScrubbing]);
const onPlayerLoaded = useCallback(() => { const onPlayerLoaded = useCallback(() => {
dispatchPlaybackSession({
type: "setPhase",
sessionId: playbackSession.sessionId,
phase: "ready",
});
if (!controller || !startTimestamp) { if (!controller || !startTimestamp) {
return; return;
} }
controller.seekToTimestamp(startTimestamp, true); controller.seekToTimestamp(startTimestamp, true);
}, [startTimestamp, controller]); }, [startTimestamp, controller, playbackSession.sessionId]);
const onTimeUpdate = useCallback( const onTimeUpdate = useCallback(
(time: number) => { (time: number) => {
@ -156,17 +216,23 @@ export default function DynamicVideoPlayer({
return; return;
} }
if (isLoading) { if (playbackSession.phase === "buffering") {
setIsLoading(false); dispatchPlaybackSession({
} type: "setPhase",
sessionId: playbackSession.sessionId,
if (isBuffering) { phase: "ready",
setIsBuffering(false); });
} }
onTimestampUpdate(controller.getProgress(time)); onTimestampUpdate(controller.getProgress(time));
}, },
[controller, onTimestampUpdate, isBuffering, isLoading, isScrubbing], [
controller,
onTimestampUpdate,
isScrubbing,
playbackSession.phase,
playbackSession.sessionId,
],
); );
const onUploadFrameToPlus = useCallback( const onUploadFrameToPlus = useCallback(
@ -196,11 +262,29 @@ export default function DynamicVideoPlayer({
); );
useEffect(() => { useEffect(() => {
if (!recordings?.length) { beginPlaybackSession("loadingSource");
if (recordings?.length == 0) { setNoRecording(false);
setNoRecording(true); 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; return;
} }
@ -219,13 +303,21 @@ export default function DynamicVideoPlayer({
); );
} }
setSource({ const nextSource = {
playlist: `${apiHost}vod/${camera}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`, playlist: `${apiHost}vod/${camera}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`,
startPosition, startPosition,
};
setSource(nextSource);
setNoRecording(false);
dispatchPlaybackSession({
type: "setPhase",
sessionId: playbackSession.sessionId,
phase: "awaitingMetadata",
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [recordings]); }, [recordings, playbackSession.sessionId]);
useEffect(() => { useEffect(() => {
if (!controller || !recordings?.length) { if (!controller || !recordings?.length) {
@ -236,8 +328,6 @@ export default function DynamicVideoPlayer({
playerRef.current.autoplay = !isScrubbing; playerRef.current.autoplay = !isScrubbing;
} }
setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000));
controller.newPlayback({ controller.newPlayback({
recordings: recordings ?? [], recordings: recordings ?? [],
timeRange, timeRange,
@ -279,13 +369,30 @@ export default function DynamicVideoPlayer({
[onClipEnded, controller, recordings], [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 ( return (
<> <>
{source && ( {source && (
<HlsVideoPlayer <HlsVideoPlayer
videoRef={playerRef} videoRef={playerRef}
containerRef={containerRef} containerRef={containerRef}
visible={!(isScrubbing || isLoading)} visible={
!isScrubbing &&
(playbackSession.phase === "ready" ||
playbackSession.phase === "buffering")
}
currentSource={source} currentSource={source}
hotKeys={hotKeys} hotKeys={hotKeys}
supportsFullscreen={supportsFullscreen} supportsFullscreen={supportsFullscreen}
@ -304,18 +411,31 @@ export default function DynamicVideoPlayer({
playerRef.current?.pause(); playerRef.current?.pause();
} }
if (loadingTimeout) {
clearTimeout(loadingTimeout);
}
setNoRecording(false); setNoRecording(false);
if (playbackSession.phase === "buffering") {
dispatchPlaybackSession({
type: "setPhase",
sessionId: playbackSession.sessionId,
phase: "ready",
});
}
}} }}
setFullResolution={setFullResolution} setFullResolution={setFullResolution}
onUploadFrame={onUploadFrameToPlus} onUploadFrame={onUploadFrameToPlus}
toggleFullscreen={toggleFullscreen} toggleFullscreen={toggleFullscreen}
onError={(error) => { onError={(error) => {
if (error == "stalled" && !isScrubbing) { 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} isDetailMode={isDetailMode}
@ -325,10 +445,7 @@ export default function DynamicVideoPlayer({
/> />
)} )}
<PreviewPlayer <PreviewPlayer
className={cn( className={cn(className, showPreview ? "visible" : "hidden")}
className,
isScrubbing || isLoading ? "visible" : "hidden",
)}
camera={camera} camera={camera}
timeRange={timeRange} timeRange={timeRange}
cameraPreviews={cameraPreviews} cameraPreviews={cameraPreviews}
@ -338,10 +455,10 @@ export default function DynamicVideoPlayer({
setPreviewController(previewController) setPreviewController(previewController)
} }
/> />
{!isScrubbing && (isLoading || isBuffering) && !noRecording && ( {showLoadingIndicator && (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" /> <ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
)} )}
{!isScrubbing && !isLoading && noRecording && ( {showNoRecordings && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"> <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
{t("noRecordingsFoundForThisTime")} {t("noRecordingsFoundForThisTime")}
</div> </div>