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")}