mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-03 18:41:14 +03:00
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* remove redundant per-view toasters in settings * add variants to standardize dialog footer button layouts * remove text-md this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html * make wizard footers consistent with dialog footers * consistent destructive button style remove text-white from individual buttons and add it to the variant
536 lines
20 KiB
TypeScript
536 lines
20 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|
import AutoUpdatingCameraImage from "@/components/camera/AutoUpdatingCameraImage";
|
|
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
|
import { Toaster } from "@/components/ui/sonner";
|
|
import { Label } from "@/components/ui/label";
|
|
import useSWR from "swr";
|
|
import Heading from "@/components/ui/heading";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { AudioDetection, ObjectType } from "@/types/ws";
|
|
import useDeepMemo from "@/hooks/use-deep-memo";
|
|
import { Card } from "@/components/ui/card";
|
|
import { getIconForLabel } from "@/utils/iconUtil";
|
|
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
|
import { LuExternalLink, LuInfo } from "react-icons/lu";
|
|
import { Link } from "react-router-dom";
|
|
|
|
import DebugDrawingLayer from "@/components/overlay/DebugDrawingLayer";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { isDesktop } from "react-device-detect";
|
|
import { Trans, useTranslation } from "react-i18next";
|
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
|
import { getTranslatedLabel } from "@/utils/i18n";
|
|
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
|
import { AudioLevelGraph } from "@/components/audio/AudioLevelGraph";
|
|
import { useWs } from "@/api/ws";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type ObjectSettingsViewProps = {
|
|
selectedCamera?: string;
|
|
};
|
|
|
|
type Options = { [key: string]: boolean };
|
|
|
|
const emptyObject = Object.freeze({});
|
|
|
|
export default function ObjectSettingsView({
|
|
selectedCamera,
|
|
}: ObjectSettingsViewProps) {
|
|
const { t } = useTranslation(["views/settings"]);
|
|
|
|
const { getLocaleDocUrl } = useDocDomain();
|
|
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const DEBUG_OPTIONS = [
|
|
{
|
|
param: "bbox",
|
|
title: t("debug.boundingBoxes.title"),
|
|
description: t("debug.boundingBoxes.desc"),
|
|
info: (
|
|
<>
|
|
<p className="mb-2">
|
|
<strong>{t("debug.boundingBoxes.colors.label")}</strong>
|
|
</p>
|
|
<ul className="list-disc space-y-1 pl-5">
|
|
<Trans ns="views/settings">debug.boundingBoxes.colors.info</Trans>
|
|
</ul>
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
param: "timestamp",
|
|
title: t("debug.timestamp.title"),
|
|
description: t("debug.timestamp.desc"),
|
|
},
|
|
{
|
|
param: "zones",
|
|
title: t("debug.zones.title"),
|
|
description: t("debug.zones.desc"),
|
|
},
|
|
{
|
|
param: "mask",
|
|
title: t("debug.mask.title"),
|
|
description: t("debug.mask.desc"),
|
|
},
|
|
{
|
|
param: "motion",
|
|
title: t("debug.motion.title"),
|
|
description: t("debug.motion.desc"),
|
|
info: <Trans ns="views/settings">debug.motion.tips</Trans>,
|
|
},
|
|
{
|
|
param: "regions",
|
|
title: t("debug.regions.title"),
|
|
description: t("debug.regions.desc"),
|
|
info: <Trans ns="views/settings">debug.regions.tips</Trans>,
|
|
},
|
|
{
|
|
param: "paths",
|
|
title: t("debug.paths.title"),
|
|
description: t("debug.paths.desc"),
|
|
info: <Trans ns="views/settings">debug.paths.tips</Trans>,
|
|
},
|
|
];
|
|
|
|
const [options, setOptions, optionsLoaded] = useUserPersistence<Options>(
|
|
`${selectedCamera}-feed`,
|
|
emptyObject,
|
|
);
|
|
|
|
const handleSetOption = useCallback(
|
|
(id: string, value: boolean) => {
|
|
const newOptions = { ...options, [id]: value };
|
|
setOptions(newOptions);
|
|
},
|
|
[options, setOptions],
|
|
);
|
|
|
|
const [debugDraw, setDebugDraw] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setDebugDraw(false);
|
|
}, [selectedCamera]);
|
|
|
|
const cameraConfig = useMemo(() => {
|
|
if (config && selectedCamera) {
|
|
return config.cameras[selectedCamera];
|
|
}
|
|
}, [config, selectedCamera]);
|
|
|
|
const cameraName = useCameraFriendlyName(cameraConfig);
|
|
|
|
const { objects, audio_detections } = useCameraActivity(
|
|
cameraConfig ?? ({} as CameraConfig),
|
|
);
|
|
|
|
const memoizedObjects = useDeepMemo(objects);
|
|
const memoizedAudio = useDeepMemo(audio_detections);
|
|
|
|
const searchParams = useMemo(() => {
|
|
if (!optionsLoaded) {
|
|
return new URLSearchParams();
|
|
}
|
|
|
|
const params = new URLSearchParams(
|
|
Object.keys(options || {}).reduce((memo, key) => {
|
|
//@ts-expect-error we know this is correct
|
|
memo.push([key, options[key] === true ? "1" : "0"]);
|
|
return memo;
|
|
}, []),
|
|
);
|
|
return params;
|
|
}, [options, optionsLoaded]);
|
|
|
|
useEffect(() => {
|
|
document.title = t("documentTitle.object");
|
|
}, [t]);
|
|
|
|
if (!cameraConfig) {
|
|
return <ActivityIndicator />;
|
|
}
|
|
|
|
return (
|
|
<div className="mt-1 flex size-full flex-col pb-2 md:flex-row">
|
|
<Toaster position="top-center" closeButton={true} />
|
|
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-3/12">
|
|
<Heading as="h4" className="mb-2">
|
|
{t("debug.title")}
|
|
</Heading>
|
|
<div className="mb-5 space-y-3 text-sm text-muted-foreground">
|
|
<p>
|
|
{t("debug.detectorDesc", {
|
|
detectors: config
|
|
? Object.keys(config?.detectors)
|
|
.map((detector) => capitalizeFirstLetter(detector))
|
|
.join(",")
|
|
: "",
|
|
})}
|
|
</p>
|
|
<p>{t("debug.desc")}</p>
|
|
</div>
|
|
{config?.cameras[cameraConfig.name]?.webui_url && (
|
|
<div className="mb-5 text-sm text-muted-foreground">
|
|
<div className="mt-2 flex flex-row items-center text-primary">
|
|
<Link
|
|
to={config?.cameras[cameraConfig.name]?.webui_url ?? ""}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline"
|
|
>
|
|
{t("debug.openCameraWebUI", {
|
|
camera: cameraName,
|
|
})}
|
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Tabs defaultValue="debug" className="w-full">
|
|
<TabsList
|
|
className={cn(
|
|
"grid w-full",
|
|
cameraConfig.audio.enabled_in_config
|
|
? "grid-cols-3"
|
|
: "grid-cols-2",
|
|
)}
|
|
>
|
|
<TabsTrigger value="debug">{t("debug.debugging")}</TabsTrigger>
|
|
<TabsTrigger value="objectlist">
|
|
{t("debug.objectList")}
|
|
</TabsTrigger>
|
|
{cameraConfig.audio.enabled_in_config && (
|
|
<TabsTrigger value="audio">{t("debug.audio.title")}</TabsTrigger>
|
|
)}
|
|
</TabsList>
|
|
<TabsContent value="debug">
|
|
<div className="flex w-full flex-col space-y-6">
|
|
<div className="mt-2 space-y-6">
|
|
<div className="my-2.5 flex flex-col gap-2.5">
|
|
{DEBUG_OPTIONS.map(({ param, title, description, info }) => (
|
|
<div
|
|
key={param}
|
|
className="flex w-full flex-row items-center justify-between"
|
|
>
|
|
<div className="mb-2 flex flex-col">
|
|
<div className="flex items-center gap-2">
|
|
<Label
|
|
className="mb-0 cursor-pointer text-primary smart-capitalize"
|
|
htmlFor={param}
|
|
>
|
|
{title}
|
|
</Label>
|
|
{info && (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<div className="cursor-pointer p-0">
|
|
<LuInfo className="size-4" />
|
|
<span className="sr-only">Info</span>
|
|
</div>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-80 text-sm">
|
|
{info}
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
</div>
|
|
<div className="mt-1 text-xs text-muted-foreground">
|
|
{description}
|
|
</div>
|
|
</div>
|
|
<Switch
|
|
key={`${param}-${selectedCamera}`}
|
|
className="ml-1"
|
|
id={param}
|
|
checked={options && options[param]}
|
|
disabled={
|
|
param === "paths" &&
|
|
cameraConfig?.onvif?.autotracking?.enabled_in_config
|
|
}
|
|
onCheckedChange={(isChecked) => {
|
|
handleSetOption(param, isChecked);
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{isDesktop && (
|
|
<>
|
|
<Separator className="my-2" />
|
|
<div className="flex w-full flex-row items-center justify-between">
|
|
<div className="mb-2 flex flex-col">
|
|
<div className="flex items-center gap-2">
|
|
<Label
|
|
className="mb-0 cursor-pointer text-primary smart-capitalize"
|
|
htmlFor="debugdraw"
|
|
>
|
|
{t("debug.objectShapeFilterDrawing.title")}
|
|
</Label>
|
|
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<div className="cursor-pointer p-0">
|
|
<LuInfo className="size-4" />
|
|
<span className="sr-only">
|
|
{t("button.info", { ns: "common" })}
|
|
</span>
|
|
</div>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-80 text-sm">
|
|
{t("debug.objectShapeFilterDrawing.tips")}
|
|
<div className="mt-2 flex items-center text-primary">
|
|
<Link
|
|
to={getLocaleDocUrl(
|
|
"configuration/object_filters#object-shape",
|
|
)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline"
|
|
>
|
|
{t("readTheDocumentation", { ns: "common" })}
|
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
|
</Link>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
<div className="mt-1 text-xs text-muted-foreground">
|
|
{t("debug.objectShapeFilterDrawing.desc")}
|
|
</div>
|
|
</div>
|
|
<Switch
|
|
key={`$draw-${selectedCamera}`}
|
|
className="ml-1"
|
|
id="debug_draw"
|
|
checked={debugDraw}
|
|
onCheckedChange={(isChecked) => {
|
|
setDebugDraw(isChecked);
|
|
}}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
<TabsContent value="objectlist">
|
|
<ObjectList cameraConfig={cameraConfig} objects={memoizedObjects} />
|
|
</TabsContent>
|
|
{cameraConfig.audio.enabled_in_config && (
|
|
<TabsContent value="audio">
|
|
<AudioList
|
|
cameraConfig={cameraConfig}
|
|
audioDetections={memoizedAudio}
|
|
/>
|
|
</TabsContent>
|
|
)}
|
|
</Tabs>
|
|
</div>
|
|
|
|
{cameraConfig ? (
|
|
<div className="flex max-h-[70%] md:h-dvh md:max-h-full md:w-7/12 md:grow">
|
|
<div ref={containerRef} className="relative size-full min-h-10">
|
|
<AutoUpdatingCameraImage
|
|
camera={cameraConfig.name}
|
|
searchParams={searchParams}
|
|
showFps={false}
|
|
className="size-full"
|
|
cameraClasses="relative w-full h-full flex flex-col justify-start"
|
|
/>
|
|
{debugDraw && (
|
|
<DebugDrawingLayer
|
|
containerRef={containerRef}
|
|
cameraWidth={cameraConfig.detect.width}
|
|
cameraHeight={cameraConfig.detect.height}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<Skeleton className="size-full rounded-lg md:rounded-2xl" />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type ObjectListProps = {
|
|
cameraConfig: CameraConfig;
|
|
objects?: ObjectType[];
|
|
};
|
|
|
|
function ObjectList({ cameraConfig, objects }: ObjectListProps) {
|
|
const { t } = useTranslation(["views/settings", "common"]);
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
|
|
const colormap = useMemo(() => {
|
|
if (!config) {
|
|
return;
|
|
}
|
|
|
|
return config.model?.colormap;
|
|
}, [config]);
|
|
|
|
const getColorForObjectName = useCallback(
|
|
(objectName: string) => {
|
|
return colormap && colormap[objectName]
|
|
? `rgb(${colormap[objectName][2]}, ${colormap[objectName][1]}, ${colormap[objectName][0]})`
|
|
: "rgb(128, 128, 128)";
|
|
},
|
|
[colormap],
|
|
);
|
|
|
|
return (
|
|
<div className="scrollbar-container relative flex w-full flex-col overflow-y-auto">
|
|
{objects && objects.length > 0 ? (
|
|
objects.map((obj: ObjectType) => {
|
|
return (
|
|
<Card className="mb-1 p-2 text-sm" key={obj.id}>
|
|
<div className="flex flex-row items-center gap-3 pb-1">
|
|
<div className="flex flex-1 flex-row items-center justify-start p-3 pl-1">
|
|
<div
|
|
className="rounded-lg p-2"
|
|
style={{
|
|
backgroundColor: obj.stationary
|
|
? "rgb(110,110,110)"
|
|
: getColorForObjectName(obj.label),
|
|
}}
|
|
>
|
|
{getIconForLabel(obj.label, "object", "size-5 text-white")}
|
|
</div>
|
|
<div className="ml-3 text-lg">
|
|
{getTranslatedLabel(obj.label)}
|
|
</div>
|
|
</div>
|
|
<div className="flex w-8/12 flex-row items-center justify-end">
|
|
<div className="mr-2 w-1/3">
|
|
<div className="flex flex-col items-end justify-end">
|
|
<p className="mb-1.5 text-sm text-primary-variant">
|
|
{t("debug.objectShapeFilterDrawing.score")}
|
|
</p>
|
|
{obj.score
|
|
? (obj.score * 100).toFixed(1).toString()
|
|
: "-"}
|
|
%
|
|
</div>
|
|
</div>
|
|
<div className="mr-2 w-1/3">
|
|
<div className="flex flex-col items-end justify-end">
|
|
<p className="mb-1.5 text-sm text-primary-variant">
|
|
{t("debug.objectShapeFilterDrawing.ratio")}
|
|
</p>
|
|
{obj.ratio ? obj.ratio.toFixed(2).toString() : "-"}
|
|
</div>
|
|
</div>
|
|
<div className="mr-2 w-1/3">
|
|
<div className="flex flex-col items-end justify-end">
|
|
<p className="mb-1.5 text-sm text-primary-variant">
|
|
{t("debug.objectShapeFilterDrawing.area")}
|
|
</p>
|
|
{obj.area ? (
|
|
<div className="text-end">
|
|
<div className="text-xs">
|
|
{t("information.pixels", {
|
|
ns: "common",
|
|
area: obj.area,
|
|
})}
|
|
</div>
|
|
<div className="text-xs">
|
|
{(
|
|
(obj.area /
|
|
(cameraConfig.detect.width *
|
|
cameraConfig.detect.height)) *
|
|
100
|
|
)
|
|
.toFixed(2)
|
|
.toString()}
|
|
%
|
|
</div>
|
|
</div>
|
|
) : (
|
|
"-"
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
})
|
|
) : (
|
|
<div className="p-3 text-center">{t("debug.noObjects")}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type AudioListProps = {
|
|
cameraConfig: CameraConfig;
|
|
audioDetections?: AudioDetection[];
|
|
};
|
|
|
|
function AudioList({ cameraConfig, audioDetections }: AudioListProps) {
|
|
const { t } = useTranslation(["views/settings"]);
|
|
|
|
// Get audio levels directly from ws hooks
|
|
const {
|
|
value: { payload: audioRms },
|
|
} = useWs(`${cameraConfig.name}/audio/rms`, "");
|
|
const {
|
|
value: { payload: audioDBFS },
|
|
} = useWs(`${cameraConfig.name}/audio/dBFS`, "");
|
|
|
|
return (
|
|
<div className="scrollbar-container flex w-full flex-col overflow-y-auto">
|
|
{audioDetections && Object.keys(audioDetections).length > 0 ? (
|
|
Object.entries(audioDetections).map(([key, obj]) => (
|
|
<Card className="mb-1 p-2 text-sm" key={obj.id ?? key}>
|
|
<div className="flex flex-row items-center gap-3 pb-1">
|
|
<div className="flex flex-1 flex-row items-center justify-start p-3 pl-1">
|
|
<div className="rounded-lg bg-selected p-2">
|
|
{getIconForLabel(key, "audio", "size-5 text-white")}
|
|
</div>
|
|
<div className="ml-3 text-lg">{getTranslatedLabel(key)}</div>
|
|
</div>
|
|
<div className="flex w-8/12 flex-row items-center justify-end">
|
|
<div className="mr-2 w-1/3">
|
|
<div className="flex flex-col items-end justify-end">
|
|
<p className="mb-1.5 text-sm text-primary-variant">
|
|
{t("debug.audio.score")}
|
|
</p>
|
|
{obj.score ? (obj.score * 100).toFixed(1).toString() : "-"}%
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))
|
|
) : (
|
|
<div className="p-3 text-center">
|
|
<p className="mb-2">{t("debug.audio.noAudioDetections")}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("debug.audio.currentRMS")}{" "}
|
|
{(typeof audioRms === "number" ? audioRms : 0).toFixed(1)} |{" "}
|
|
{t("debug.audio.currentdbFS")}{" "}
|
|
{(typeof audioDBFS === "number" ? audioDBFS : 0).toFixed(1)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<AudioLevelGraph cameraName={cameraConfig.name} />
|
|
</div>
|
|
);
|
|
}
|