Add ability to download on demand snapshots (#20488)

* on demand snapshot utils

* add optional loading state to feature toggle buttons

* add on demand snapshot button to single camera live view

* i18n
This commit is contained in:
Josh Hawkins
2025-10-14 14:05:35 -05:00
committed by GitHub
parent b05ac7430a
commit dad5b72145
4 changed files with 259 additions and 24 deletions

View File

@@ -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}
/>
<CameraFeatureToggle
className="p-2 md:p-0"
variant={fullscreen ? "overlay" : "primary"}
Icon={TbCameraDown}
isActive={false}
title={t("snapshot.takeSnapshot")}
onClick={handleSnapshotClick}
disabled={!cameraEnabled || debug || isSnapshotLoading}
loading={isSnapshotLoading}
/>
<DropdownMenu modal={false}>
<DropdownMenuTrigger>
<div
@@ -1584,16 +1631,28 @@ function FrigateCameraFeatures({
<div className="mb-1 text-sm font-medium leading-none">
{t("manualRecording.title")}
</div>
<Button
onClick={handleEventButtonClick}
className={cn(
"w-full",
isRecording && "animate-pulse bg-red-500 hover:bg-red-600",
)}
disabled={debug}
>
{t("manualRecording." + (isRecording ? "end" : "start"))}
</Button>
<div className="flex flex-row items-stretch gap-2">
<Button
onClick={handleSnapshotClick}
disabled={!cameraEnabled || debug || isSnapshotLoading}
className="h-auto w-full whitespace-normal"
>
{isSnapshotLoading && (
<ActivityIndicator className="mr-2 size-4" />
)}
{t("snapshot.takeSnapshot")}
</Button>
<Button
onClick={handleEventButtonClick}
className={cn(
"h-auto w-full whitespace-normal",
isRecording && "animate-pulse bg-red-500 hover:bg-red-600",
)}
disabled={debug}
>
{t("manualRecording." + (isRecording ? "end" : "start"))}
</Button>
</div>
<p className="text-sm text-muted-foreground">
{t("manualRecording.tips")}
</p>