Skip to content

Commit c5e816f

Browse files
committed
History: add snapshot support in recording player
1 parent 80c4ce2 commit c5e816f

File tree

5 files changed

+85
-12
lines changed

5 files changed

+85
-12
lines changed

web/src/components/player/HlsVideoPlayer.tsx

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record";
2222
import { useTranslation } from "react-i18next";
2323
import ObjectTrackOverlay from "@/components/overlay/ObjectTrackOverlay";
2424
import { useIsAdmin } from "@/hooks/use-is-admin";
25+
import {
26+
downloadSnapshot,
27+
generateSnapshotFilename,
28+
grabVideoSnapshot,
29+
} from "@/utils/snapshotUtil";
2530

2631
// Android native hls does not seek correctly
2732
const USE_NATIVE_HLS = false;
@@ -58,6 +63,7 @@ type HlsVideoPlayerProps = {
5863
isDetailMode?: boolean;
5964
camera?: string;
6065
currentTimeOverride?: number;
66+
supportsSnapshot?: boolean;
6167
transformedOverlay?: ReactNode;
6268
};
6369

@@ -83,9 +89,10 @@ export default function HlsVideoPlayer({
8389
isDetailMode = false,
8490
camera,
8591
currentTimeOverride,
92+
supportsSnapshot = false,
8693
transformedOverlay,
8794
}: HlsVideoPlayerProps) {
88-
const { t } = useTranslation("components/player");
95+
const { t } = useTranslation(["components/player", "views/live"]);
8996
const { data: config } = useSWR<FrigateConfig>("config");
9097
const isAdmin = useIsAdmin();
9198

@@ -264,13 +271,36 @@ export default function HlsVideoPlayer({
264271
const getVideoTime = useCallback(() => {
265272
const currentTime = videoRef.current?.currentTime;
266273

267-
if (!currentTime) {
274+
if (currentTime == undefined) {
268275
return undefined;
269276
}
270277

271278
return currentTime + inpointOffset;
272279
}, [videoRef, inpointOffset]);
273280

281+
const handleSnapshot = useCallback(async () => {
282+
const frameTime = getVideoTime();
283+
const result = await grabVideoSnapshot(videoRef.current);
284+
285+
if (result.success) {
286+
downloadSnapshot(
287+
result.data.dataUrl,
288+
generateSnapshotFilename(
289+
camera ?? "recording",
290+
currentTime ?? frameTime,
291+
config?.ui?.timezone,
292+
),
293+
);
294+
toast.success(t("snapshot.downloadStarted", { ns: "views/live" }), {
295+
position: "top-center",
296+
});
297+
} else {
298+
toast.error(t("snapshot.captureFailed", { ns: "views/live" }), {
299+
position: "top-center",
300+
});
301+
}
302+
}, [camera, config?.ui?.timezone, currentTime, getVideoTime, t, videoRef]);
303+
274304
return (
275305
<TransformWrapper
276306
minScale={1.0}
@@ -294,6 +324,7 @@ export default function HlsVideoPlayer({
294324
seek: true,
295325
playbackRate: true,
296326
plusUpload: isAdmin && config?.plus?.enabled == true,
327+
snapshot: supportsSnapshot,
297328
fullscreen: supportsFullscreen,
298329
}}
299330
setControlsOpen={setControlsOpen}
@@ -320,7 +351,7 @@ export default function HlsVideoPlayer({
320351
onUploadFrame={async () => {
321352
const frameTime = getVideoTime();
322353

323-
if (frameTime && onUploadFrame) {
354+
if (frameTime != undefined && onUploadFrame) {
324355
const resp = await onUploadFrame(frameTime);
325356

326357
if (resp && resp.status == 200) {
@@ -334,6 +365,8 @@ export default function HlsVideoPlayer({
334365
}
335366
}
336367
}}
368+
onSnapshot={supportsSnapshot ? handleSnapshot : undefined}
369+
snapshotTitle={t("snapshot.takeSnapshot", { ns: "views/live" })}
337370
fullscreen={fullscreen}
338371
toggleFullscreen={toggleFullscreen}
339372
containerRef={containerRef}
@@ -465,7 +498,7 @@ export default function HlsVideoPlayer({
465498

466499
const frameTime = getVideoTime();
467500

468-
if (frameTime) {
501+
if (frameTime != undefined) {
469502
onTimeUpdate(frameTime);
470503
}
471504
}}

web/src/components/player/VideoControls.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,14 @@ import {
3434
import { cn } from "@/lib/utils";
3535
import { FaCompress, FaExpand } from "react-icons/fa";
3636
import { useTranslation } from "react-i18next";
37+
import { TbCameraDown } from "react-icons/tb";
3738

3839
type VideoControls = {
3940
volume?: boolean;
4041
seek?: boolean;
4142
playbackRate?: boolean;
4243
plusUpload?: boolean;
44+
snapshot?: boolean;
4345
fullscreen?: boolean;
4446
};
4547

@@ -48,6 +50,7 @@ const CONTROLS_DEFAULT: VideoControls = {
4850
seek: true,
4951
playbackRate: true,
5052
plusUpload: false,
53+
snapshot: false,
5154
fullscreen: false,
5255
};
5356
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
@@ -71,6 +74,8 @@ type VideoControlsProps = {
7174
onSeek: (diff: number) => void;
7275
onSetPlaybackRate: (rate: number) => void;
7376
onUploadFrame?: () => void;
77+
onSnapshot?: () => void;
78+
snapshotTitle?: string;
7479
toggleFullscreen?: () => void;
7580
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
7681
};
@@ -92,6 +97,8 @@ export default function VideoControls({
9297
onSeek,
9398
onSetPlaybackRate,
9499
onUploadFrame,
100+
onSnapshot,
101+
snapshotTitle,
95102
toggleFullscreen,
96103
containerRef,
97104
}: VideoControlsProps) {
@@ -292,6 +299,17 @@ export default function VideoControls({
292299
fullscreen={fullscreen}
293300
/>
294301
)}
302+
{features.snapshot && onSnapshot && (
303+
<TbCameraDown
304+
aria-label={snapshotTitle}
305+
className="size-5 cursor-pointer"
306+
title={snapshotTitle}
307+
onClick={(e: React.MouseEvent<SVGElement>) => {
308+
e.stopPropagation();
309+
onSnapshot();
310+
}}
311+
/>
312+
)}
295313
{features.fullscreen && toggleFullscreen && (
296314
<div className="cursor-pointer" onClick={toggleFullscreen}>
297315
{fullscreen ? <FaCompress /> : <FaExpand />}

web/src/components/player/dynamic/DynamicVideoPlayer.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ type DynamicVideoPlayerProps = {
4747
setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
4848
toggleFullscreen: () => void;
4949
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
50+
supportsSnapshot?: boolean;
5051
transformedOverlay?: ReactNode;
5152
};
5253
export default function DynamicVideoPlayer({
@@ -66,6 +67,7 @@ export default function DynamicVideoPlayer({
6667
setFullResolution,
6768
toggleFullscreen,
6869
containerRef,
70+
supportsSnapshot = false,
6971
transformedOverlay,
7072
}: DynamicVideoPlayerProps) {
7173
const { t } = useTranslation(["components/player"]);
@@ -321,6 +323,7 @@ export default function DynamicVideoPlayer({
321323
isDetailMode={isDetailMode}
322324
camera={contextCamera || camera}
323325
currentTimeOverride={currentTime}
326+
supportsSnapshot={supportsSnapshot}
324327
transformedOverlay={transformedOverlay}
325328
/>
326329
)}

web/src/utils/snapshotUtil.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { baseUrl } from "@/api/baseUrl";
2+
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
23

34
type SnapshotResponse = {
45
dataUrl: string;
@@ -97,17 +98,34 @@ export function downloadSnapshot(dataUrl: string, filename: string): void {
9798
}
9899
}
99100

100-
export function generateSnapshotFilename(cameraName: string): string {
101-
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5);
102-
return `${cameraName}_snapshot_${timestamp}.jpg`;
101+
export function generateSnapshotFilename(
102+
cameraName: string,
103+
timestampSeconds?: number,
104+
timezone?: string,
105+
): string {
106+
const seconds = timestampSeconds ?? Date.now() / 1000;
107+
const timestamp = formatUnixTimestampToDateTime(seconds, {
108+
timezone,
109+
date_format: "yyyy-MM-dd'T'HH-mm-ss",
110+
});
111+
112+
const safeTimestamp =
113+
timestamp === "Invalid time"
114+
? new Date(seconds * 1000)
115+
.toISOString()
116+
.replace(/[:.]/g, "-")
117+
.slice(0, -5)
118+
: timestamp;
119+
return `${cameraName}_snapshot_${safeTimestamp}.jpg`;
103120
}
104121

105-
export async function grabVideoSnapshot(): Promise<SnapshotResult> {
122+
export async function grabVideoSnapshot(
123+
targetVideo?: HTMLVideoElement | null,
124+
): Promise<SnapshotResult> {
106125
try {
107-
// Find the video element in the player
108-
const videoElement = document.querySelector(
109-
"#player-container video",
110-
) as HTMLVideoElement;
126+
const videoElement =
127+
targetVideo ??
128+
(document.querySelector("#player-container video") as HTMLVideoElement);
111129

112130
if (!videoElement) {
113131
return {

web/src/views/recording/RecordingView.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,7 @@ export function RecordingView({
839839
setFullResolution={setFullResolution}
840840
toggleFullscreen={toggleFullscreen}
841841
containerRef={mainLayoutRef}
842+
supportsSnapshot={true}
842843
/>
843844
</div>
844845
{isDesktop && effectiveCameras.length > 1 && (

0 commit comments

Comments
 (0)