From 24b27dfa4afcc83acff2a8f6732e6464d23ea2bd Mon Sep 17 00:00:00 2001 From: nrlcode Date: Tue, 24 Mar 2026 12:41:15 -0700 Subject: [PATCH] History: fix snapshot timestamp and scrub-release seek behavior (cherry picked from commit 7b6e26fdbbb878ec7443b29a335bff1990d0b9f5) --- web/src/components/player/HlsVideoPlayer.tsx | 39 +++++++++++++++++-- .../player/dynamic/DynamicVideoPlayer.tsx | 3 ++ web/src/views/recording/RecordingView.tsx | 27 ++++++++++++- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index 9a7c8aa9d..aaa5a67fd 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -51,6 +51,7 @@ type HlsVideoPlayerProps = { onTimeUpdate?: (time: number) => void; onPlaying?: () => void; onSeekToTime?: (timestamp: number, play?: boolean) => void; + onGetAbsoluteTimestamp?: (playerSeconds: number) => number | undefined; setFullResolution?: React.Dispatch>; onUploadFrame?: (playTime: number) => Promise | undefined; toggleFullscreen?: () => void; @@ -76,6 +77,7 @@ export default function HlsVideoPlayer({ onTimeUpdate, onPlaying, onSeekToTime, + onGetAbsoluteTimestamp, setFullResolution, onUploadFrame, toggleFullscreen, @@ -282,7 +284,7 @@ export default function HlsVideoPlayer({ const getVideoTime = useCallback(() => { const currentTime = videoRef.current?.currentTime; - if (!currentTime) { + if (currentTime == undefined) { return undefined; } @@ -323,7 +325,7 @@ export default function HlsVideoPlayer({ onSeek={(diff) => { const currentTime = videoRef.current?.currentTime; - if (!videoRef.current || !currentTime) { + if (!videoRef.current || currentTime == undefined) { return; } @@ -339,7 +341,7 @@ export default function HlsVideoPlayer({ onUploadFrame={async () => { const frameTime = getVideoTime(); - if (frameTime && onUploadFrame) { + if (frameTime != undefined && onUploadFrame) { const resp = await onUploadFrame(frameTime); if (resp && resp.status == 200) { @@ -353,6 +355,35 @@ export default function HlsVideoPlayer({ } } }} + onSnapshot={async () => { + const frameTime = getVideoTime(); + const snapshotTimestamp = + frameTime != undefined + ? (onGetAbsoluteTimestamp?.(frameTime) ?? frameTime) + : undefined; + const result = await grabVideoSnapshot(videoRef.current); + + if (result.success) { + downloadSnapshot( + result.data.dataUrl, + generateSnapshotFilename( + camera ?? "recording", + snapshotTimestamp, + ), + ); + toast.success( + t("snapshot.downloadStarted", { ns: "views/live" }), + { + position: "top-center", + }, + ); + } else { + toast.error(t("snapshot.captureFailed", { ns: "views/live" }), { + position: "top-center", + }); + } + }} + snapshotTitle={t("snapshot.takeSnapshot", { ns: "views/live" })} fullscreen={fullscreen} toggleFullscreen={toggleFullscreen} containerRef={containerRef} @@ -484,7 +515,7 @@ export default function HlsVideoPlayer({ const frameTime = getVideoTime(); - if (frameTime) { + if (frameTime != undefined) { onTimeUpdate(frameTime); } }} diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 373e17926..f67cd6b23 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -406,6 +406,9 @@ export default function DynamicVideoPlayer({ onSeekToTime(timestamp, play); } }} + onGetAbsoluteTimestamp={(playerSeconds) => + controller ? controller.getProgress(playerSeconds) : undefined + } onPlaying={() => { if (isScrubbing) { playerRef.current?.pause(); diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index a3466b256..71b544300 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -257,6 +257,8 @@ export function RecordingView({ const [scrubbing, setScrubbing] = useState(false); const [currentTime, setCurrentTime] = useState(startTime); const [playerTime, setPlayerTime] = useState(startTime); + const wasScrubbingRef = useRef(false); + const pendingScrubSeekTimeRef = useRef(null); const updateSelectedSegment = useCallback( (currentTime: number, updateStartTime: boolean) => { @@ -347,6 +349,17 @@ export function RecordingView({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentTime, scrubbing]); + useEffect(() => { + // force an explicit seek when the user releases timeline scrubbing, + // and guard against old player time updates overwriting the new target. + if (wasScrubbingRef.current && !scrubbing) { + pendingScrubSeekTimeRef.current = currentTime; + mainControllerRef.current?.seekToTimestamp(currentTime, true); + } + + wasScrubbingRef.current = scrubbing; + }, [scrubbing, currentTime]); + const [fullResolution, setFullResolution] = useState({ width: 0, height: 0, @@ -820,7 +833,19 @@ export function RecordingView({ fullscreen={fullscreen} onTimestampUpdate={(timestamp) => { setPlayerTime(timestamp); - setCurrentTime(timestamp); + + const pendingScrubSeekTime = + pendingScrubSeekTimeRef.current; + if (pendingScrubSeekTime != null) { + // keep the scrubbed target time anchored until playback catches up. + if (Math.abs(timestamp - pendingScrubSeekTime) <= 1) { + pendingScrubSeekTimeRef.current = null; + setCurrentTime(timestamp); + } + } else { + setCurrentTime(timestamp); + } + Object.values(previewRefs.current ?? {}).forEach((prev) => prev.scrubToTimestamp(Math.floor(timestamp)), );