mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-05 22:57:40 +03:00
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:
parent
a8b6ea5005
commit
b668e802d9
@ -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}`,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user