mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-19 23:08:08 +02:00
Add Camera Wizard (#20461)
* fetch more from ffprobe * add detailed param to ffprobe endpoint * add dots variant to step indicator * add classname * tweak colors for dark mode to match figma * add step 1 form * add helper function for ffmpeg snapshot * add go2rtc stream add and ffprobe snapshot endpoints * add camera image and stream details on successful test * step 1 tweaks * step 2 and i18n * types * step 1 and 2 tweaks * add wizard to camera settings view * add data unit i18n keys * restream tweak * fix type * implement rough idea for step 3 * add api endpoint to delete stream from go2rtc * add main wizard dialog component * extract logic for friendly_name and use in wizard * add i18n and popover for brand url * add camera name to top * consolidate validation logic * prevent dialog from closing when clicking outside * center camera name on mobile * add help/docs link popovers * keep spaces in friendly name * add stream details to overlay like stats in liveplayer * add validation results pane to step 3 * ensure test is invalidated if stream is changed * only display validation results and enable save button if all streams have been tested * tweaks * normalize camera name to lower case and improve hash generation * move wizard to subfolder * tweaks * match look of camera edit form to wizard * move wizard and edit form to its own component * move enabled/disabled switch to management section * clean up * fixes * fix mobile
This commit is contained in:
199
web/src/views/settings/CameraManagementView.tsx
Normal file
199
web/src/views/settings/CameraManagementView.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import CameraEditForm from "@/components/settings/CameraEditForm";
|
||||
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||
import { LuPlus } from "react-icons/lu";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Trans } from "react-i18next";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useEnabledState } from "@/api/ws";
|
||||
|
||||
type CameraManagementViewProps = {
|
||||
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export default function CameraManagementView({
|
||||
setUnsavedChanges,
|
||||
}: CameraManagementViewProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
|
||||
const [viewMode, setViewMode] = useState<"settings" | "add" | "edit">(
|
||||
"settings",
|
||||
); // Control view state
|
||||
const [editCameraName, setEditCameraName] = useState<string | undefined>(
|
||||
undefined,
|
||||
); // Track camera being edited
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
|
||||
// List of cameras for dropdown
|
||||
const cameras = useMemo(() => {
|
||||
if (config) {
|
||||
return Object.keys(config.cameras).sort();
|
||||
}
|
||||
return [];
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("documentTitle.cameraManagement");
|
||||
}, [t]);
|
||||
|
||||
// Handle back navigation from add/edit form
|
||||
const handleBack = useCallback(() => {
|
||||
setViewMode("settings");
|
||||
setEditCameraName(undefined);
|
||||
setUnsavedChanges(false);
|
||||
updateConfig();
|
||||
}, [updateConfig, setUnsavedChanges]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
{viewMode === "settings" ? (
|
||||
<>
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("cameraManagement.title")}
|
||||
</Heading>
|
||||
<div className="my-4 flex flex-col gap-4">
|
||||
<Button
|
||||
variant="select"
|
||||
onClick={() => setShowWizard(true)}
|
||||
className="flex max-w-48 items-center gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("cameraManagement.addCamera")}
|
||||
</Button>
|
||||
{cameras.length > 0 && (
|
||||
<>
|
||||
<div className="my-4 flex flex-col gap-2">
|
||||
<Label>{t("cameraManagement.editCamera")}</Label>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setEditCameraName(value);
|
||||
setViewMode("edit");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue
|
||||
placeholder={t("cameraManagement.selectCamera")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cameras.map((camera) => {
|
||||
return (
|
||||
<SelectItem key={camera} value={camera}>
|
||||
<CameraNameLabel camera={camera} />
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
<div className="max-w-7xl space-y-4">
|
||||
<Heading as="h4" className="my-2">
|
||||
<Trans ns="views/settings">
|
||||
cameraManagement.streams.title
|
||||
</Trans>
|
||||
</Heading>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
<Trans ns="views/settings">
|
||||
cameraManagement.streams.desc
|
||||
</Trans>
|
||||
</div>
|
||||
|
||||
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
|
||||
{cameras.map((camera) => (
|
||||
<div
|
||||
key={camera}
|
||||
className="flex items-center justify-between smart-capitalize"
|
||||
>
|
||||
<CameraNameLabel camera={camera} />
|
||||
<CameraEnableSwitch cameraName={camera} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="mb-2 mt-4 flex bg-secondary" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Button
|
||||
className={`flex items-center gap-2.5 rounded-lg`}
|
||||
aria-label={t("label.back", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="md:max-w-5xl">
|
||||
<CameraEditForm
|
||||
cameraName={viewMode === "edit" ? editCameraName : undefined}
|
||||
onSave={handleBack}
|
||||
onCancel={handleBack}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CameraWizardDialog
|
||||
open={showWizard}
|
||||
onClose={() => setShowWizard(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type CameraEnableSwitchProps = {
|
||||
cameraName: string;
|
||||
};
|
||||
|
||||
function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) {
|
||||
const { payload: enabledState, send: sendEnabled } =
|
||||
useEnabledState(cameraName);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
id={`camera-enabled-${cameraName}`}
|
||||
checked={enabledState === "ON"}
|
||||
onCheckedChange={(isChecked) => {
|
||||
sendEnabled(isChecked ? "ON" : "OFF");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,23 +34,14 @@ import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import {
|
||||
useAlertsState,
|
||||
useDetectionsState,
|
||||
useEnabledState,
|
||||
useObjectDescriptionState,
|
||||
useReviewDescriptionState,
|
||||
} from "@/api/ws";
|
||||
import CameraEditForm from "@/components/settings/CameraEditForm";
|
||||
import { LuPlus } from "react-icons/lu";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||
|
||||
type CameraSettingsViewProps = {
|
||||
selectedCamera: string;
|
||||
@@ -87,17 +78,10 @@ export default function CameraSettingsView({
|
||||
const [editCameraName, setEditCameraName] = useState<string | undefined>(
|
||||
undefined,
|
||||
); // Track camera being edited
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
|
||||
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
|
||||
|
||||
// List of cameras for dropdown
|
||||
const cameras = useMemo(() => {
|
||||
if (config) {
|
||||
return Object.keys(config.cameras).sort();
|
||||
}
|
||||
return [];
|
||||
}, [config]);
|
||||
|
||||
const selectCameraName = useCameraFriendlyName(selectedCamera);
|
||||
|
||||
// zones and labels
|
||||
@@ -148,8 +132,6 @@ export default function CameraSettingsView({
|
||||
const watchedAlertsZones = form.watch("alerts_zones");
|
||||
const watchedDetectionsZones = form.watch("detections_zones");
|
||||
|
||||
const { payload: enabledState, send: sendEnabled } =
|
||||
useEnabledState(selectedCamera);
|
||||
const { payload: alertsState, send: sendAlerts } =
|
||||
useAlertsState(selectedCamera);
|
||||
const { payload: detectionsState, send: sendDetections } =
|
||||
@@ -202,9 +184,12 @@ export default function CameraSettingsView({
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(t("camera.reviewClassification.toast.success"), {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.success(
|
||||
t("cameraReview.reviewClassification.toast.success"),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
updateConfig();
|
||||
} else {
|
||||
toast.error(
|
||||
@@ -272,7 +257,7 @@ export default function CameraSettingsView({
|
||||
if (changedValue) {
|
||||
addMessage(
|
||||
"camera_settings",
|
||||
t("camera.reviewClassification.unsavedChanges", {
|
||||
t("cameraReview.reviewClassification.unsavedChanges", {
|
||||
camera: selectedCamera,
|
||||
}),
|
||||
undefined,
|
||||
@@ -295,7 +280,7 @@ export default function CameraSettingsView({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("documentTitle.camera");
|
||||
document.title = t("documentTitle.cameraReview");
|
||||
}, [t]);
|
||||
|
||||
// Handle back navigation from add/edit form
|
||||
@@ -317,70 +302,11 @@ export default function CameraSettingsView({
|
||||
{viewMode === "settings" ? (
|
||||
<>
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("camera.title")}
|
||||
</Heading>
|
||||
<div className="mb-4 flex flex-col gap-4">
|
||||
<Button
|
||||
variant="select"
|
||||
onClick={() => setViewMode("add")}
|
||||
className="flex max-w-48 items-center gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("camera.addCamera")}
|
||||
</Button>
|
||||
{cameras.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>{t("camera.editCamera")}</Label>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setEditCameraName(value);
|
||||
setViewMode("edit");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder={t("camera.selectCamera")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cameras.map((camera) => {
|
||||
return (
|
||||
<SelectItem key={camera} value={camera}>
|
||||
<CameraNameLabel camera={camera} />
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
<Trans ns="views/settings">camera.streams.title</Trans>
|
||||
{t("cameraReview.title")}
|
||||
</Heading>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
id="camera-enabled"
|
||||
className="mr-3"
|
||||
checked={enabledState === "ON"}
|
||||
onCheckedChange={(isChecked) => {
|
||||
sendEnabled(isChecked ? "ON" : "OFF");
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="camera-enabled">
|
||||
<Trans>button.enabled</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
<Trans ns="views/settings">camera.streams.desc</Trans>
|
||||
</div>
|
||||
<Separator className="mb-2 mt-4 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
<Trans ns="views/settings">camera.review.title</Trans>
|
||||
<Trans ns="views/settings">cameraReview.review.title</Trans>
|
||||
</Heading>
|
||||
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
||||
@@ -395,7 +321,9 @@ export default function CameraSettingsView({
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="alerts-enabled">
|
||||
<Trans ns="views/settings">camera.review.alerts</Trans>
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.review.alerts
|
||||
</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -418,7 +346,7 @@ export default function CameraSettingsView({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
<Trans ns="views/settings">camera.review.desc</Trans>
|
||||
<Trans ns="views/settings">cameraReview.review.desc</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -428,7 +356,7 @@ export default function CameraSettingsView({
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
<Trans ns="views/settings">
|
||||
camera.object_descriptions.title
|
||||
cameraReview.object_descriptions.title
|
||||
</Trans>
|
||||
</Heading>
|
||||
|
||||
@@ -450,7 +378,7 @@ export default function CameraSettingsView({
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
<Trans ns="views/settings">
|
||||
camera.object_descriptions.desc
|
||||
cameraReview.object_descriptions.desc
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
@@ -463,7 +391,7 @@ export default function CameraSettingsView({
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
<Trans ns="views/settings">
|
||||
camera.review_descriptions.title
|
||||
cameraReview.review_descriptions.title
|
||||
</Trans>
|
||||
</Heading>
|
||||
|
||||
@@ -485,7 +413,7 @@ export default function CameraSettingsView({
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
<Trans ns="views/settings">
|
||||
camera.review_descriptions.desc
|
||||
cameraReview.review_descriptions.desc
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
@@ -496,7 +424,7 @@ export default function CameraSettingsView({
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
<Trans ns="views/settings">
|
||||
camera.reviewClassification.title
|
||||
cameraReview.reviewClassification.title
|
||||
</Trans>
|
||||
</Heading>
|
||||
|
||||
@@ -504,7 +432,7 @@ export default function CameraSettingsView({
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
<Trans ns="views/settings">
|
||||
camera.reviewClassification.desc
|
||||
cameraReview.reviewClassification.desc
|
||||
</Trans>
|
||||
</p>
|
||||
<div className="flex items-center text-primary">
|
||||
@@ -550,7 +478,7 @@ export default function CameraSettingsView({
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
<Trans ns="views/settings">
|
||||
camera.reviewClassification.selectAlertsZones
|
||||
cameraReview.reviewClassification.selectAlertsZones
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</div>
|
||||
@@ -599,7 +527,7 @@ export default function CameraSettingsView({
|
||||
) : (
|
||||
<div className="font-normal text-destructive">
|
||||
<Trans ns="views/settings">
|
||||
camera.reviewClassification.noDefinedZones
|
||||
cameraReview.reviewClassification.noDefinedZones
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
@@ -607,7 +535,7 @@ export default function CameraSettingsView({
|
||||
<div className="text-sm">
|
||||
{watchedAlertsZones && watchedAlertsZones.length > 0
|
||||
? t(
|
||||
"camera.reviewClassification.zoneObjectAlertsTips",
|
||||
"cameraReview.reviewClassification.zoneObjectAlertsTips",
|
||||
{
|
||||
alertsLabels,
|
||||
zone: watchedAlertsZones
|
||||
@@ -622,7 +550,7 @@ export default function CameraSettingsView({
|
||||
},
|
||||
)
|
||||
: t(
|
||||
"camera.reviewClassification.objectAlertsTips",
|
||||
"cameraReview.reviewClassification.objectAlertsTips",
|
||||
{
|
||||
alertsLabels,
|
||||
cameraName: selectCameraName,
|
||||
@@ -650,7 +578,7 @@ export default function CameraSettingsView({
|
||||
{selectDetections && (
|
||||
<FormDescription>
|
||||
<Trans ns="views/settings">
|
||||
camera.reviewClassification.selectDetectionsZones
|
||||
cameraReview.reviewClassification.selectDetectionsZones
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
)}
|
||||
@@ -713,7 +641,7 @@ export default function CameraSettingsView({
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
<Trans ns="views/settings">
|
||||
camera.reviewClassification.limitDetections
|
||||
cameraReview.reviewClassification.limitDetections
|
||||
</Trans>
|
||||
</label>
|
||||
</div>
|
||||
@@ -726,7 +654,7 @@ export default function CameraSettingsView({
|
||||
watchedDetectionsZones.length > 0 ? (
|
||||
!selectDetections ? (
|
||||
<Trans
|
||||
i18nKey="camera.reviewClassification.zoneObjectDetectionsTips.text"
|
||||
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
zone: watchedDetectionsZones
|
||||
@@ -743,7 +671,7 @@ export default function CameraSettingsView({
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="camera.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
|
||||
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
zone: watchedDetectionsZones
|
||||
@@ -761,7 +689,7 @@ export default function CameraSettingsView({
|
||||
)
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="camera.reviewClassification.objectDetectionsTips"
|
||||
i18nKey="cameraReview.reviewClassification.objectDetectionsTips"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
cameraName: selectCameraName,
|
||||
@@ -835,6 +763,11 @@ export default function CameraSettingsView({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CameraWizardDialog
|
||||
open={showWizard}
|
||||
onClose={() => setShowWizard(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user