mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-26 14:21:53 +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
745 lines
28 KiB
TypeScript
745 lines
28 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
import { Trans, useTranslation } from "react-i18next";
|
|
import useSWR from "swr";
|
|
import axios from "axios";
|
|
import { toast } from "sonner";
|
|
import AutoUpdatingCameraImage from "@/components/camera/AutoUpdatingCameraImage";
|
|
import { Button, buttonVariants } from "@/components/ui/button";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { DebugReplayConfigSheet } from "@/components/overlay/DebugReplayConfigSheet";
|
|
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
|
import { cn } from "@/lib/utils";
|
|
import Heading from "@/components/ui/heading";
|
|
import { Toaster } from "@/components/ui/sonner";
|
|
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
|
import { getIconForLabel } from "@/utils/iconUtil";
|
|
import { getTranslatedLabel } from "@/utils/i18n";
|
|
import { Card } from "@/components/ui/card";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { ObjectType } from "@/types/ws";
|
|
import { useJobStatus } from "@/api/ws";
|
|
import WsMessageFeed from "@/components/ws/WsMessageFeed";
|
|
|
|
import { LuExternalLink, LuInfo } from "react-icons/lu";
|
|
import { LuSquare } from "react-icons/lu";
|
|
import { MdReplay } from "react-icons/md";
|
|
import { isDesktop, isMobile } from "react-device-detect";
|
|
import Logo from "@/components/Logo";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
|
import DebugDrawingLayer from "@/components/overlay/DebugDrawingLayer";
|
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
|
|
|
type DebugReplayStatus = {
|
|
active: boolean;
|
|
replay_camera: string | null;
|
|
source_camera: string | null;
|
|
start_time: number | null;
|
|
end_time: number | null;
|
|
live_ready: boolean;
|
|
};
|
|
|
|
type DebugReplayJobResults = {
|
|
current_step: "preparing_clip" | "starting_camera" | null;
|
|
progress_percent: number | null;
|
|
source_camera: string | null;
|
|
replay_camera_name: string | null;
|
|
start_ts: number | null;
|
|
end_ts: number | null;
|
|
};
|
|
|
|
type DebugOptions = {
|
|
bbox: boolean;
|
|
timestamp: boolean;
|
|
zones: boolean;
|
|
mask: boolean;
|
|
motion: boolean;
|
|
regions: boolean;
|
|
paths: boolean;
|
|
};
|
|
|
|
const DEFAULT_OPTIONS: DebugOptions = {
|
|
bbox: true,
|
|
timestamp: false,
|
|
zones: false,
|
|
mask: false,
|
|
motion: true,
|
|
regions: false,
|
|
paths: false,
|
|
};
|
|
|
|
const DEBUG_OPTION_KEYS: (keyof DebugOptions)[] = [
|
|
"bbox",
|
|
"timestamp",
|
|
"zones",
|
|
"mask",
|
|
"motion",
|
|
"regions",
|
|
"paths",
|
|
];
|
|
|
|
const DEBUG_OPTION_I18N_KEY: Record<keyof DebugOptions, string> = {
|
|
bbox: "boundingBoxes",
|
|
timestamp: "timestamp",
|
|
zones: "zones",
|
|
mask: "mask",
|
|
motion: "motion",
|
|
regions: "regions",
|
|
paths: "paths",
|
|
};
|
|
|
|
export default function Replay() {
|
|
const { t } = useTranslation(["views/replay", "views/settings", "common"]);
|
|
const navigate = useNavigate();
|
|
const { getLocaleDocUrl } = useDocDomain();
|
|
|
|
const {
|
|
data: status,
|
|
mutate: refreshStatus,
|
|
isLoading,
|
|
} = useSWR<DebugReplayStatus>("debug_replay/status", {
|
|
refreshInterval: (latestData) => (latestData?.live_ready ? 0 : 1000),
|
|
});
|
|
const { payload: replayJob } =
|
|
useJobStatus<DebugReplayJobResults>("debug_replay");
|
|
const [isInitializing, setIsInitializing] = useState(true);
|
|
|
|
// Refresh status immediately on mount to avoid showing "no session" briefly
|
|
useEffect(() => {
|
|
const initializeStatus = async () => {
|
|
await refreshStatus();
|
|
setIsInitializing(false);
|
|
};
|
|
initializeStatus();
|
|
}, [refreshStatus]);
|
|
|
|
const [options, setOptions] = useState<DebugOptions>(DEFAULT_OPTIONS);
|
|
const [isStopping, setIsStopping] = useState(false);
|
|
|
|
const searchParams = useMemo(() => {
|
|
const params = new URLSearchParams();
|
|
for (const key of DEBUG_OPTION_KEYS) {
|
|
params.set(key, options[key] ? "1" : "0");
|
|
}
|
|
return params;
|
|
}, [options]);
|
|
|
|
const handleSetOption = useCallback(
|
|
(key: keyof DebugOptions, value: boolean) => {
|
|
setOptions((prev) => ({ ...prev, [key]: value }));
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleStop = useCallback(() => {
|
|
setIsStopping(true);
|
|
axios
|
|
.post("debug_replay/stop")
|
|
.then(() => {
|
|
refreshStatus();
|
|
})
|
|
.catch((error) => {
|
|
const errorMessage =
|
|
error.response?.data?.message ||
|
|
error.response?.data?.detail ||
|
|
"Unknown error";
|
|
toast.error(t("dialog.toast.stopError", { error: errorMessage }), {
|
|
position: "top-center",
|
|
});
|
|
})
|
|
.finally(() => {
|
|
setIsStopping(false);
|
|
});
|
|
}, [refreshStatus, t]);
|
|
|
|
// Camera activity for the replay camera
|
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
|
revalidateOnFocus: false,
|
|
});
|
|
const replayCameraName = status?.replay_camera ?? "";
|
|
const replayCameraConfig = replayCameraName
|
|
? config?.cameras?.[replayCameraName]
|
|
: undefined;
|
|
|
|
const { objects } = useCameraActivity(replayCameraConfig);
|
|
|
|
// debug draw
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [debugDraw, setDebugDraw] = useState(false);
|
|
|
|
// Format time range for display
|
|
const timeRangeDisplay = useMemo(() => {
|
|
if (!status?.start_time || !status?.end_time) return "";
|
|
const start = new Date(status.start_time * 1000).toLocaleString();
|
|
const end = new Date(status.end_time * 1000).toLocaleString();
|
|
return `${start} — ${end}`;
|
|
}, [status]);
|
|
|
|
// Show loading state
|
|
if (isInitializing || (isLoading && !status?.active)) {
|
|
return (
|
|
<div className="flex size-full items-center justify-center">
|
|
<ActivityIndicator />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Startup error (job failed). Only show when status.active is also true so
|
|
// we don't surface stale failed jobs after a session ended cleanly.
|
|
if (replayJob?.status === "failed" && status?.active) {
|
|
return (
|
|
<div className="flex size-full flex-col items-center justify-center gap-4 p-8">
|
|
<Heading as="h2" className="text-center">
|
|
{t("page.startError.title")}
|
|
</Heading>
|
|
{replayJob.error_message && (
|
|
<p className="max-w-xl text-center text-sm text-muted-foreground">
|
|
{replayJob.error_message}
|
|
</p>
|
|
)}
|
|
<Button
|
|
variant="default"
|
|
onClick={() => {
|
|
axios
|
|
.post("debug_replay/stop")
|
|
.catch(() => {})
|
|
.finally(() => navigate("/review"));
|
|
}}
|
|
>
|
|
{t("page.startError.back")}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// No active session. Also covers the brief window between the runner
|
|
// pushing job.status = "cancelled" via WS and the next SWR refresh
|
|
// flipping status.active to false — without this, render falls through
|
|
// to the full replay UI and you see a flash of it before stop completes.
|
|
if (!status?.active || replayJob?.status === "cancelled") {
|
|
return (
|
|
<div className="flex size-full flex-col items-center justify-center gap-4 p-8">
|
|
<MdReplay className="size-12" />
|
|
<Heading as="h2" className="text-center">
|
|
{t("page.noSession")}
|
|
</Heading>
|
|
<p className="max-w-md text-center text-muted-foreground">
|
|
{t("page.noSessionDesc")}
|
|
</p>
|
|
<Button variant="default" onClick={() => navigate("/review")}>
|
|
{t("page.goToRecordings")}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Startup in progress (job is running). The session is active but the
|
|
// replay camera isn't ready yet; show progress / phase from the job.
|
|
const startupStep =
|
|
replayJob?.status === "running"
|
|
? (replayJob.results?.current_step ?? null)
|
|
: null;
|
|
if (startupStep === "preparing_clip" || startupStep === "starting_camera") {
|
|
const phaseTitle =
|
|
startupStep === "preparing_clip"
|
|
? t("page.preparingClip")
|
|
: t("page.startingCamera");
|
|
const progressPercent = replayJob?.results?.progress_percent ?? null;
|
|
const showProgressBar =
|
|
startupStep === "preparing_clip" && progressPercent != null;
|
|
return (
|
|
<div className="flex size-full flex-col items-center justify-center gap-4 p-8">
|
|
{showProgressBar ? (
|
|
<div className="flex w-64 flex-col items-center gap-2">
|
|
<Progress value={progressPercent ?? 0} />
|
|
<div className="text-xs text-muted-foreground">
|
|
{Math.round(progressPercent ?? 0)}%
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<ActivityIndicator className="size-8" />
|
|
)}
|
|
<Heading as="h3" className="text-center">
|
|
{phaseTitle}
|
|
</Heading>
|
|
{startupStep === "preparing_clip" && (
|
|
<p className="max-w-md text-center text-sm text-muted-foreground">
|
|
{t("page.preparingClipDesc")}
|
|
</p>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={isStopping}
|
|
onClick={handleStop}
|
|
>
|
|
{t("button.cancel", { ns: "common" })}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex size-full flex-col overflow-hidden">
|
|
<Toaster position="top-center" closeButton={true} />
|
|
|
|
{/* Top bar */}
|
|
<div className="flex min-h-12 items-center justify-between border-b border-secondary px-2 py-2 md:min-h-16 md:px-3 md:py-3">
|
|
{isMobile && (
|
|
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
|
)}
|
|
<Button
|
|
className="flex items-center gap-2.5 rounded-lg"
|
|
aria-label={t("label.back", { ns: "common" })}
|
|
size="sm"
|
|
onClick={() => navigate(-1)}
|
|
>
|
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
|
{isDesktop && (
|
|
<div className="text-primary">
|
|
{t("button.back", { ns: "common" })}
|
|
</div>
|
|
)}
|
|
</Button>
|
|
<div className="flex items-center gap-2">
|
|
<DebugReplayConfigSheet
|
|
replayCamera={status.replay_camera ?? undefined}
|
|
/>
|
|
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
className="flex items-center gap-2"
|
|
disabled={isStopping}
|
|
>
|
|
{isStopping && <ActivityIndicator className="size-4" />}
|
|
<span className="hidden md:inline">{t("page.stopReplay")}</span>
|
|
<LuSquare className="size-4 md:hidden" />
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
{t("page.confirmStop.title")}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("page.confirmStop.description")}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>
|
|
{t("page.confirmStop.cancel")}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleStop}
|
|
className={cn(buttonVariants({ variant: "destructive" }))}
|
|
>
|
|
{t("page.confirmStop.confirm")}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main content */}
|
|
<div className="flex flex-1 flex-col overflow-hidden pb-2 md:flex-row">
|
|
{/* Camera feed */}
|
|
<div className="flex max-h-[40%] px-2 pt-2 md:h-dvh md:max-h-full md:w-7/12 md:grow md:px-4 md:pt-2">
|
|
{isStopping ? (
|
|
<div className="flex size-full items-center justify-center rounded-lg bg-background_alt">
|
|
<div className="flex flex-col items-center justify-center gap-2">
|
|
<ActivityIndicator className="size-8" />
|
|
<div className="text-secondary-foreground">
|
|
{t("page.stoppingReplay")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
status.replay_camera && (
|
|
<div className="relative size-full min-h-10" ref={containerRef}>
|
|
{status.live_ready ? (
|
|
<>
|
|
<AutoUpdatingCameraImage
|
|
className="size-full"
|
|
cameraClasses="relative w-full h-full flex flex-col justify-start"
|
|
searchParams={searchParams}
|
|
camera={status.replay_camera}
|
|
showFps={false}
|
|
/>
|
|
{debugDraw && (
|
|
<DebugDrawingLayer
|
|
containerRef={containerRef}
|
|
cameraWidth={
|
|
config?.cameras?.[status.source_camera ?? ""]?.detect
|
|
.width ?? 1280
|
|
}
|
|
cameraHeight={
|
|
config?.cameras?.[status.source_camera ?? ""]?.detect
|
|
.height ?? 720
|
|
}
|
|
/>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="pointer-events-none absolute inset-0 z-10 size-full rounded-lg bg-background">
|
|
<Skeleton className="size-full rounded-lg" />
|
|
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center gap-2">
|
|
<ActivityIndicator className="size-8" />
|
|
<div className="text-secondary-foreground">
|
|
{t("page.initializingReplay")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
|
|
{/* Side panel */}
|
|
<div className="order-last mb-2 mt-2 flex h-full w-full flex-col overflow-hidden 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-4/12">
|
|
<div className="mb-5 flex flex-col space-y-2">
|
|
<Heading as="h3" className="mb-0">
|
|
{t("title")}
|
|
</Heading>
|
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
<span className="smart-capitalize">{status.source_camera}</span>
|
|
{timeRangeDisplay && (
|
|
<>
|
|
<span className="hidden md:inline">•</span>
|
|
<span className="hidden md:inline">{timeRangeDisplay}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="mb-5 space-y-3 text-sm text-muted-foreground">
|
|
<p>{t("description")}</p>
|
|
</div>
|
|
</div>
|
|
<Tabs
|
|
defaultValue="debug"
|
|
className="flex min-h-0 w-full flex-1 flex-col"
|
|
>
|
|
<TabsList className="grid w-full grid-cols-3">
|
|
<TabsTrigger value="debug">
|
|
{t("debug.debugging", { ns: "views/settings" })}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="objects">{t("page.objects")}</TabsTrigger>
|
|
<TabsTrigger value="messages">
|
|
{t("websocket_messages")}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent
|
|
value="debug"
|
|
className="scrollbar-container mt-2 overflow-y-auto"
|
|
>
|
|
<div className="mt-2 space-y-6">
|
|
<div className="my-2.5 flex flex-col gap-2.5">
|
|
{DEBUG_OPTION_KEYS.map((key) => {
|
|
const i18nKey = DEBUG_OPTION_I18N_KEY[key];
|
|
return (
|
|
<div
|
|
key={key}
|
|
className="flex w-full flex-row items-center justify-between"
|
|
>
|
|
<div className="mb-1 flex flex-col">
|
|
<div className="flex items-center gap-2">
|
|
<Label
|
|
className="mb-0 cursor-pointer text-primary smart-capitalize"
|
|
htmlFor={`debug-${key}`}
|
|
>
|
|
{t(`debug.${i18nKey}.title`, {
|
|
ns: "views/settings",
|
|
})}
|
|
</Label>
|
|
{(key === "bbox" ||
|
|
key === "motion" ||
|
|
key === "regions" ||
|
|
key === "paths") && (
|
|
<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">
|
|
{key === "bbox" ? (
|
|
<>
|
|
<p className="mb-2">
|
|
<strong>
|
|
{t(
|
|
"debug.boundingBoxes.colors.label",
|
|
{
|
|
ns: "views/settings",
|
|
},
|
|
)}
|
|
</strong>
|
|
</p>
|
|
<ul className="list-disc space-y-1 pl-5">
|
|
<Trans ns="views/settings">
|
|
debug.boundingBoxes.colors.info
|
|
</Trans>
|
|
</ul>
|
|
</>
|
|
) : (
|
|
<Trans ns="views/settings">
|
|
{`debug.${i18nKey}.tips`}
|
|
</Trans>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
</div>
|
|
<div className="mt-1 text-xs text-muted-foreground">
|
|
{t(`debug.${i18nKey}.desc`, {
|
|
ns: "views/settings",
|
|
})}
|
|
</div>
|
|
</div>
|
|
<Switch
|
|
id={`debug-${key}`}
|
|
className="ml-1"
|
|
checked={options[key]}
|
|
onCheckedChange={(checked) =>
|
|
handleSetOption(key, checked)
|
|
}
|
|
/>
|
|
</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", {
|
|
ns: "views/settings",
|
|
})}
|
|
</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", {
|
|
ns: "views/settings",
|
|
})}
|
|
<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", {
|
|
ns: "views/settings",
|
|
})}
|
|
</div>
|
|
</div>
|
|
<Switch
|
|
key={"draw"}
|
|
className="ml-1"
|
|
id="debug_draw"
|
|
checked={debugDraw}
|
|
onCheckedChange={(isChecked) => {
|
|
setDebugDraw(isChecked);
|
|
}}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
<TabsContent
|
|
value="objects"
|
|
className="scrollbar-container mt-2 overflow-y-auto"
|
|
>
|
|
<ObjectList
|
|
cameraConfig={replayCameraConfig}
|
|
objects={objects}
|
|
config={config}
|
|
/>
|
|
</TabsContent>
|
|
<TabsContent
|
|
value="messages"
|
|
className="mt-2 flex min-h-0 flex-1 flex-col"
|
|
>
|
|
<div className="flex h-full flex-col overflow-hidden rounded-md border border-secondary">
|
|
<WsMessageFeed
|
|
maxSize={2000}
|
|
lockedCamera={status.replay_camera ?? undefined}
|
|
showCameraBadge={false}
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type ObjectListProps = {
|
|
cameraConfig?: CameraConfig;
|
|
objects?: ObjectType[];
|
|
config?: FrigateConfig;
|
|
};
|
|
|
|
function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
|
|
const { t } = useTranslation(["views/settings", "common"]);
|
|
|
|
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],
|
|
);
|
|
|
|
if (!objects || objects.length === 0) {
|
|
return (
|
|
<div className="p-3 text-center text-sm text-muted-foreground">
|
|
{t("debug.noObjects", { ns: "views/settings" })}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="scrollbar-container relative flex w-full flex-col overflow-y-auto">
|
|
{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", {
|
|
ns: "views/settings",
|
|
})}
|
|
</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", {
|
|
ns: "views/settings",
|
|
})}
|
|
</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", {
|
|
ns: "views/settings",
|
|
})}
|
|
</p>
|
|
{obj.area && cameraConfig ? (
|
|
<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)}
|
|
%
|
|
</div>
|
|
</div>
|
|
) : (
|
|
"-"
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|