Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion web/src/components/player/HlsVideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record";
import { useTranslation } from "react-i18next";
import ObjectTrackOverlay from "@/components/overlay/ObjectTrackOverlay";
import { useIsAdmin } from "@/hooks/use-is-admin";
import {
downloadSnapshot,
generateSnapshotFilename,
grabVideoSnapshot,
} from "@/utils/snapshotUtil";

// Android native hls does not seek correctly
const USE_NATIVE_HLS = false;
Expand Down Expand Up @@ -85,7 +90,7 @@ export default function HlsVideoPlayer({
currentTimeOverride,
transformedOverlay,
}: HlsVideoPlayerProps) {
const { t } = useTranslation("components/player");
const { t } = useTranslation(["components/player", "views/live"]);
const { data: config } = useSWR<FrigateConfig>("config");
const isAdmin = useIsAdmin();

Expand Down Expand Up @@ -271,6 +276,30 @@ export default function HlsVideoPlayer({
return currentTime + inpointOffset;
}, [videoRef, inpointOffset]);

const handleSnapshot = useCallback(async () => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll need some kind of loading guard that disables the button while the snapshot is being downloaded (like is done in LiveCameraView's snapshot handler), because a user could spam click the snapshot button, which in most cases will cause the browser to just download a bunch of black frames.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in the guarding features!

const frameTime = getVideoTime();
const result = await grabVideoSnapshot(videoRef.current);

if (result.success) {
downloadSnapshot(
result.data.dataUrl,
generateSnapshotFilename(
camera ?? "recording",
currentTime ?? frameTime,
config?.ui?.timezone,
),
);
toast.success(t("snapshot.downloadStarted", { ns: "views/live" }), {
position: "top-center",
});
} else {
toast.error(t("snapshot.captureFailed", { ns: "views/live" }), {
position: "top-center",
});
}
}, [camera, config?.ui?.timezone, currentTime, getVideoTime, t, videoRef]);
const onSnapshot = camera ? handleSnapshot : undefined;

return (
<TransformWrapper
minScale={1.0}
Expand All @@ -294,6 +323,7 @@ export default function HlsVideoPlayer({
seek: true,
playbackRate: true,
plusUpload: isAdmin && config?.plus?.enabled == true,
snapshot: !!onSnapshot,
fullscreen: supportsFullscreen,
}}
setControlsOpen={setControlsOpen}
Expand Down Expand Up @@ -334,6 +364,8 @@ export default function HlsVideoPlayer({
}
}
}}
onSnapshot={onSnapshot}
snapshotTitle={t("snapshot.takeSnapshot", { ns: "views/live" })}
fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen}
containerRef={containerRef}
Expand Down
18 changes: 18 additions & 0 deletions web/src/components/player/VideoControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,14 @@ import {
import { cn } from "@/lib/utils";
import { FaCompress, FaExpand } from "react-icons/fa";
import { useTranslation } from "react-i18next";
import { TbCameraDown } from "react-icons/tb";

type VideoControls = {
volume?: boolean;
seek?: boolean;
playbackRate?: boolean;
plusUpload?: boolean;
snapshot?: boolean;
fullscreen?: boolean;
};

Expand All @@ -48,6 +50,7 @@ const CONTROLS_DEFAULT: VideoControls = {
seek: true,
playbackRate: true,
plusUpload: false,
snapshot: false,
fullscreen: false,
};
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
Expand All @@ -71,6 +74,8 @@ type VideoControlsProps = {
onSeek: (diff: number) => void;
onSetPlaybackRate: (rate: number) => void;
onUploadFrame?: () => void;
onSnapshot?: () => void;
snapshotTitle?: string;
toggleFullscreen?: () => void;
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
};
Expand All @@ -92,6 +97,8 @@ export default function VideoControls({
onSeek,
onSetPlaybackRate,
onUploadFrame,
onSnapshot,
snapshotTitle,
toggleFullscreen,
containerRef,
}: VideoControlsProps) {
Expand Down Expand Up @@ -292,6 +299,17 @@ export default function VideoControls({
fullscreen={fullscreen}
/>
)}
{features.snapshot && onSnapshot && (
<TbCameraDown
aria-label={snapshotTitle}
className="size-5 cursor-pointer"
title={snapshotTitle}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd remove this. None of the other buttons here have a title.

onClick={(e: React.MouseEvent<SVGElement>) => {
e.stopPropagation();
onSnapshot();
}}
/>
)}
{features.fullscreen && toggleFullscreen && (
<div className="cursor-pointer" onClick={toggleFullscreen}>
{fullscreen ? <FaCompress /> : <FaExpand />}
Expand Down
27 changes: 20 additions & 7 deletions web/src/utils/snapshotUtil.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { baseUrl } from "@/api/baseUrl";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";

type SnapshotResponse = {
dataUrl: string;
Expand Down Expand Up @@ -97,17 +98,29 @@ export function downloadSnapshot(dataUrl: string, filename: string): void {
}
}

export function generateSnapshotFilename(cameraName: string): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5);
export function generateSnapshotFilename(
cameraName: string,
timestampSeconds?: number,
timezone?: string,
): string {
const seconds =
typeof timestampSeconds === "number" && Number.isFinite(timestampSeconds)
? timestampSeconds
: Date.now() / 1000;
const timestamp = formatUnixTimestampToDateTime(seconds, {
timezone,
date_format: "yyyy-MM-dd'T'HH-mm-ss",
});
return `${cameraName}_snapshot_${timestamp}.jpg`;
}

export async function grabVideoSnapshot(): Promise<SnapshotResult> {
export async function grabVideoSnapshot(
targetVideo?: HTMLVideoElement | null,
): Promise<SnapshotResult> {
try {
// Find the video element in the player
const videoElement = document.querySelector(
"#player-container video",
) as HTMLVideoElement;
const videoElement =
targetVideo ??
(document.querySelector("#player-container video") as HTMLVideoElement);

if (!videoElement) {
return {
Expand Down