mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-22 20:31:53 +03:00
* improve scroll handling for non-modal DropdownMenu in classification and face selection dialogs * clean up * fix incorrect key capitalization * fix profile array overrides not replacing base arrays don't use lodash merge(), it does positional merging and an empty source array doesn't override the destination, and shorter arrays leak destination elements through. backend is unaffected, so the saved config and actual backend functionality was right * only show audio debug tab when audio is enabled in config * move apple_compatibility out of advanced * remove retry_interval from UI 99% of users should never be changing this * hide switch in optionalfieldwidget if editing a profile * add override badges for cameras and profiles collect shared functions into the config util and separate hooks * Use new models endpoint info to determine modalities * clarify language * fix linter --------- Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
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="text-md 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="text-md 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="text-md 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="text-md 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>
|
|
);
|
|
}
|