diff --git a/web/public/locales/en/views/live.json b/web/public/locales/en/views/live.json
index 92c4cfbd8..3b47ed731 100644
--- a/web/public/locales/en/views/live.json
+++ b/web/public/locales/en/views/live.json
@@ -73,6 +73,12 @@
"enable": "Enable Snapshots",
"disable": "Disable Snapshots"
},
+ "snapshot": {
+ "takeSnapshot": "Download instant snapshot",
+ "noVideoSource": "No video source available for snapshot.",
+ "captureFailed": "Failed to capture snapshot.",
+ "downloadStarted": "Snapshot download started."
+ },
"audioDetect": {
"enable": "Enable Audio Detect",
"disable": "Disable Audio Detect"
@@ -90,8 +96,8 @@
"disable": "Hide Stream Stats"
},
"manualRecording": {
- "title": "On-Demand Recording",
- "tips": "Start a manual event based on this camera's recording retention settings.",
+ "title": "On-Demand",
+ "tips": "Download an instant snapshot or start a manual event based on this camera's recording retention settings.",
"playInBackground": {
"label": "Play in background",
"desc": "Enable this option to continue streaming when the player is hidden."
diff --git a/web/src/components/dynamic/CameraFeatureToggle.tsx b/web/src/components/dynamic/CameraFeatureToggle.tsx
index 122178edb..5479e4297 100644
--- a/web/src/components/dynamic/CameraFeatureToggle.tsx
+++ b/web/src/components/dynamic/CameraFeatureToggle.tsx
@@ -6,6 +6,7 @@ import {
} from "@/components/ui/tooltip";
import { isDesktop } from "react-device-detect";
import { cn } from "@/lib/utils";
+import ActivityIndicator from "../indicators/activity-indicator";
const variants = {
primary: {
@@ -30,7 +31,8 @@ type CameraFeatureToggleProps = {
Icon: IconType;
title: string;
onClick?: () => void;
- disabled?: boolean; // New prop for disabling
+ disabled?: boolean;
+ loading?: boolean;
};
export default function CameraFeatureToggle({
@@ -40,7 +42,8 @@ export default function CameraFeatureToggle({
Icon,
title,
onClick,
- disabled = false, // Default to false
+ disabled = false,
+ loading = false,
}: CameraFeatureToggleProps) {
const content = (
-
+ {loading ? (
+
+ ) : (
+
+ )}
);
diff --git a/web/src/utils/snapshotUtil.ts b/web/src/utils/snapshotUtil.ts
new file mode 100644
index 000000000..c88433d45
--- /dev/null
+++ b/web/src/utils/snapshotUtil.ts
@@ -0,0 +1,163 @@
+import { baseUrl } from "@/api/baseUrl";
+
+type SnapshotResponse = {
+ dataUrl: string;
+ blob: Blob;
+ contentType: string;
+};
+
+export type SnapshotResult =
+ | {
+ success: true;
+ data: SnapshotResponse;
+ }
+ | {
+ success: false;
+ error: string;
+ };
+
+export async function fetchCameraSnapshot(
+ name: string,
+): Promise {
+ try {
+ const url = `${baseUrl}api/${encodeURIComponent(name)}/latest.jpg`;
+
+ const response = await fetch(url, {
+ method: "GET",
+ cache: "no-store",
+ credentials: "same-origin",
+ });
+
+ if (!response.ok) {
+ return {
+ success: false,
+ error: `Snapshot request failed with status ${response.status}`,
+ };
+ }
+
+ const blob = await response.blob();
+
+ if (!blob || blob.size === 0) {
+ return {
+ success: false,
+ error: "Snapshot response was empty",
+ };
+ }
+
+ const dataUrl = await blobToDataUrl(blob);
+ const contentType = response.headers.get("content-type") ?? `image/jpeg`;
+
+ return {
+ success: true,
+ data: {
+ dataUrl,
+ blob,
+ contentType,
+ },
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error occurred",
+ };
+ }
+}
+
+function blobToDataUrl(blob: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+
+ reader.onloadend = () => {
+ if (typeof reader.result === "string") {
+ resolve(reader.result);
+ } else {
+ reject(new Error("Failed to convert blob to data URL"));
+ }
+ };
+
+ reader.onerror = () => {
+ reject(reader.error ?? new Error("Failed to read snapshot blob"));
+ };
+
+ reader.readAsDataURL(blob);
+ });
+}
+
+export function downloadSnapshot(dataUrl: string, filename: string): void {
+ try {
+ const link = document.createElement("a");
+ link.href = dataUrl;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error(`Failed to download snapshot for ${filename}:`, error);
+ }
+}
+
+export function generateSnapshotFilename(cameraName: string): string {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5);
+ return `${cameraName}_snapshot_${timestamp}.jpg`;
+}
+
+export async function grabVideoSnapshot(): Promise {
+ try {
+ // Find the video element in the player
+ const videoElement = document.querySelector(
+ "#player-container video",
+ ) as HTMLVideoElement;
+
+ if (!videoElement) {
+ return {
+ success: false,
+ error: "Video element not found",
+ };
+ }
+
+ const width = videoElement.videoWidth;
+ const height = videoElement.videoHeight;
+
+ if (width === 0 || height === 0) {
+ return {
+ success: false,
+ error: "Video element has no dimensions",
+ };
+ }
+
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+
+ const context = canvas.getContext("2d");
+
+ if (!context) {
+ return {
+ success: false,
+ error: "Failed to get canvas context",
+ };
+ }
+
+ context.drawImage(videoElement, 0, 0, width, height);
+ const dataUrl = canvas.toDataURL("image/jpeg", 0.9);
+
+ // Convert data URL to blob
+ const response = await fetch(dataUrl);
+ const blob = await response.blob();
+
+ return {
+ success: true,
+ data: {
+ dataUrl,
+ blob,
+ contentType: "image/jpeg",
+ },
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error occurred",
+ };
+ }
+}
diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx
index 5fd514bc4..5e038ef9e 100644
--- a/web/src/views/live/LiveCameraView.tsx
+++ b/web/src/views/live/LiveCameraView.tsx
@@ -57,6 +57,7 @@ import {
} from "react-icons/fa";
import { GiSpeaker, GiSpeakerOff } from "react-icons/gi";
import {
+ TbCameraDown,
TbRecordMail,
TbRecordMailOff,
TbViewfinder,
@@ -112,6 +113,14 @@ import { useDocDomain } from "@/hooks/use-doc-domain";
import PtzControlPanel from "@/components/overlay/PtzControlPanel";
import ObjectSettingsView from "../settings/ObjectSettingsView";
import { useSearchEffect } from "@/hooks/use-overlay-state";
+import {
+ downloadSnapshot,
+ fetchCameraSnapshot,
+ generateSnapshotFilename,
+ grabVideoSnapshot,
+ SnapshotResult,
+} from "@/utils/snapshotUtil";
+import ActivityIndicator from "@/components/indicators/activity-indicator";
type LiveCameraViewProps = {
config?: FrigateConfig;
@@ -882,6 +891,34 @@ function FrigateCameraFeatures({
}
}, [createEvent, endEvent, isRecording]);
+ const [isSnapshotLoading, setIsSnapshotLoading] = useState(false);
+
+ const handleSnapshotClick = useCallback(async () => {
+ setIsSnapshotLoading(true);
+ try {
+ let result: SnapshotResult;
+
+ if (isRestreamed && preferredLiveMode !== "jsmpeg") {
+ // For restreamed streams with video elements (MSE/WebRTC), grab directly from video element
+ result = await grabVideoSnapshot();
+ } else {
+ // For detect stream or JSMpeg players, use the API endpoint
+ result = await fetchCameraSnapshot(camera.name);
+ }
+
+ if (result.success) {
+ const { dataUrl } = result.data;
+ const filename = generateSnapshotFilename(camera.name);
+ downloadSnapshot(dataUrl, filename);
+ toast.success(t("snapshot.downloadStarted"));
+ } else {
+ toast.error(t("snapshot.captureFailed"));
+ }
+ } finally {
+ setIsSnapshotLoading(false);
+ }
+ }, [camera.name, isRestreamed, preferredLiveMode, t]);
+
useEffect(() => {
// ensure manual event is stopped when component unmounts
return () => {
@@ -1016,6 +1053,16 @@ function FrigateCameraFeatures({
onClick={handleEventButtonClick}
disabled={!cameraEnabled || debug}
/>
+
{t("manualRecording.title")}
-
+
+
+
+
{t("manualRecording.tips")}