2024-04-19 14:34:07 +03:00
|
|
|
import Heading from "@/components/ui/heading";
|
|
|
|
|
import { FrigateConfig } from "@/types/frigateConfig";
|
|
|
|
|
import useSWR from "swr";
|
|
|
|
|
import axios from "axios";
|
|
|
|
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|
|
|
|
import AutoUpdatingCameraImage from "@/components/camera/AutoUpdatingCameraImage";
|
2024-04-22 17:20:23 +03:00
|
|
|
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
2024-04-19 14:34:07 +03:00
|
|
|
import { Slider } from "@/components/ui/slider";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import {
|
|
|
|
|
useImproveContrast,
|
|
|
|
|
useMotionContourArea,
|
|
|
|
|
useMotionThreshold,
|
|
|
|
|
} from "@/api/ws";
|
2024-05-29 17:01:39 +03:00
|
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Switch } from "@/components/ui/switch";
|
2024-04-19 14:34:07 +03:00
|
|
|
import { Toaster } from "@/components/ui/sonner";
|
|
|
|
|
import { toast } from "sonner";
|
2024-05-29 17:01:39 +03:00
|
|
|
import { Separator } from "@/components/ui/separator";
|
2024-04-19 14:34:07 +03:00
|
|
|
import { Link } from "react-router-dom";
|
|
|
|
|
import { LuExternalLink } from "react-icons/lu";
|
2024-04-22 17:20:23 +03:00
|
|
|
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
2025-03-16 18:36:20 +03:00
|
|
|
import { Trans, useTranslation } from "react-i18next";
|
2025-05-28 15:10:45 +03:00
|
|
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
2024-04-19 14:34:07 +03:00
|
|
|
|
2024-05-29 17:01:39 +03:00
|
|
|
type MotionTunerViewProps = {
|
2024-04-19 14:34:07 +03:00
|
|
|
selectedCamera: string;
|
|
|
|
|
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type MotionSettings = {
|
|
|
|
|
threshold?: number;
|
|
|
|
|
contour_area?: number;
|
|
|
|
|
improve_contrast?: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
2024-05-29 17:01:39 +03:00
|
|
|
export default function MotionTunerView({
|
2024-04-19 14:34:07 +03:00
|
|
|
selectedCamera,
|
|
|
|
|
setUnsavedChanges,
|
2024-05-29 17:01:39 +03:00
|
|
|
}: MotionTunerViewProps) {
|
2025-03-16 18:36:20 +03:00
|
|
|
const { t } = useTranslation(["views/settings"]);
|
2025-05-28 15:10:45 +03:00
|
|
|
const { getLocaleDocUrl } = useDocDomain();
|
2024-04-19 14:34:07 +03:00
|
|
|
const { data: config, mutate: updateConfig } =
|
|
|
|
|
useSWR<FrigateConfig>("config");
|
|
|
|
|
const [changedValue, setChangedValue] = useState(false);
|
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
|
2024-05-18 21:55:17 +03:00
|
|
|
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
|
2024-04-22 17:20:23 +03:00
|
|
|
|
2024-04-19 14:34:07 +03:00
|
|
|
const { send: sendMotionThreshold } = useMotionThreshold(selectedCamera);
|
|
|
|
|
const { send: sendMotionContourArea } = useMotionContourArea(selectedCamera);
|
|
|
|
|
const { send: sendImproveContrast } = useImproveContrast(selectedCamera);
|
|
|
|
|
|
|
|
|
|
const [motionSettings, setMotionSettings] = useState<MotionSettings>({
|
|
|
|
|
threshold: undefined,
|
|
|
|
|
contour_area: undefined,
|
|
|
|
|
improve_contrast: undefined,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const [origMotionSettings, setOrigMotionSettings] = useState<MotionSettings>({
|
|
|
|
|
threshold: undefined,
|
|
|
|
|
contour_area: undefined,
|
|
|
|
|
improve_contrast: undefined,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const cameraConfig = useMemo(() => {
|
|
|
|
|
if (config && selectedCamera) {
|
|
|
|
|
return config.cameras[selectedCamera];
|
|
|
|
|
}
|
|
|
|
|
}, [config, selectedCamera]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (cameraConfig) {
|
|
|
|
|
setMotionSettings({
|
|
|
|
|
threshold: cameraConfig.motion.threshold,
|
|
|
|
|
contour_area: cameraConfig.motion.contour_area,
|
|
|
|
|
improve_contrast: cameraConfig.motion.improve_contrast,
|
|
|
|
|
});
|
|
|
|
|
setOrigMotionSettings({
|
|
|
|
|
threshold: cameraConfig.motion.threshold,
|
|
|
|
|
contour_area: cameraConfig.motion.contour_area,
|
|
|
|
|
improve_contrast: cameraConfig.motion.improve_contrast,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
// we know that these deps are correct
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [selectedCamera]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!motionSettings.threshold) return;
|
|
|
|
|
|
|
|
|
|
sendMotionThreshold(motionSettings.threshold);
|
|
|
|
|
}, [motionSettings.threshold, sendMotionThreshold]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!motionSettings.contour_area) return;
|
|
|
|
|
|
|
|
|
|
sendMotionContourArea(motionSettings.contour_area);
|
|
|
|
|
}, [motionSettings.contour_area, sendMotionContourArea]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (motionSettings.improve_contrast === undefined) return;
|
|
|
|
|
|
|
|
|
|
sendImproveContrast(motionSettings.improve_contrast ? "ON" : "OFF");
|
|
|
|
|
}, [motionSettings.improve_contrast, sendImproveContrast]);
|
|
|
|
|
|
|
|
|
|
const handleMotionConfigChange = (newConfig: Partial<MotionSettings>) => {
|
|
|
|
|
setMotionSettings((prevConfig) => ({ ...prevConfig, ...newConfig }));
|
|
|
|
|
setUnsavedChanges(true);
|
|
|
|
|
setChangedValue(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const saveToConfig = useCallback(async () => {
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
|
|
|
|
|
axios
|
|
|
|
|
.put(
|
2024-09-04 16:46:49 +03:00
|
|
|
`config/set?cameras.${selectedCamera}.motion.threshold=${motionSettings.threshold}&cameras.${selectedCamera}.motion.contour_area=${motionSettings.contour_area}&cameras.${selectedCamera}.motion.improve_contrast=${motionSettings.improve_contrast ? "True" : "False"}`,
|
2024-05-29 21:05:28 +03:00
|
|
|
{ requires_restart: 0 },
|
2024-04-19 14:34:07 +03:00
|
|
|
)
|
|
|
|
|
.then((res) => {
|
|
|
|
|
if (res.status === 200) {
|
2025-03-16 18:36:20 +03:00
|
|
|
toast.success(t("motionDetectionTuner.toast.success"), {
|
2024-04-19 14:34:07 +03:00
|
|
|
position: "top-center",
|
|
|
|
|
});
|
|
|
|
|
setChangedValue(false);
|
|
|
|
|
updateConfig();
|
|
|
|
|
} else {
|
2025-03-16 18:36:20 +03:00
|
|
|
toast.error(
|
2025-03-17 15:26:01 +03:00
|
|
|
t("toast.save.error.title", {
|
2025-03-16 18:36:20 +03:00
|
|
|
errorMessage: res.statusText,
|
|
|
|
|
ns: "common",
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
position: "top-center",
|
|
|
|
|
},
|
|
|
|
|
);
|
2024-04-19 14:34:07 +03:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch((error) => {
|
|
|
|
|
toast.error(
|
2025-03-17 15:26:01 +03:00
|
|
|
t("toast.save.error.title", {
|
2025-03-16 18:36:20 +03:00
|
|
|
errorMessage: error.response.data.message,
|
|
|
|
|
ns: "common",
|
|
|
|
|
}),
|
2024-04-19 14:34:07 +03:00
|
|
|
{ position: "top-center" },
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
.finally(() => {
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
});
|
|
|
|
|
}, [
|
|
|
|
|
updateConfig,
|
|
|
|
|
motionSettings.threshold,
|
|
|
|
|
motionSettings.contour_area,
|
|
|
|
|
motionSettings.improve_contrast,
|
|
|
|
|
selectedCamera,
|
2025-03-16 18:36:20 +03:00
|
|
|
t,
|
2024-04-19 14:34:07 +03:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const onCancel = useCallback(() => {
|
|
|
|
|
setMotionSettings(origMotionSettings);
|
|
|
|
|
setChangedValue(false);
|
2024-05-18 21:55:17 +03:00
|
|
|
removeMessage("motion_tuner", `motion_tuner_${selectedCamera}`);
|
|
|
|
|
}, [origMotionSettings, removeMessage, selectedCamera]);
|
2024-04-22 17:20:23 +03:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (changedValue) {
|
2024-05-04 22:54:50 +03:00
|
|
|
addMessage(
|
|
|
|
|
"motion_tuner",
|
2025-05-09 16:36:44 +03:00
|
|
|
t("motionDetectionTuner.unsavedChanges", { camera: selectedCamera }),
|
2024-05-04 22:54:50 +03:00
|
|
|
undefined,
|
2024-05-18 21:55:17 +03:00
|
|
|
`motion_tuner_${selectedCamera}`,
|
2024-05-04 22:54:50 +03:00
|
|
|
);
|
2024-04-22 17:20:23 +03:00
|
|
|
} else {
|
2024-05-18 21:55:17 +03:00
|
|
|
removeMessage("motion_tuner", `motion_tuner_${selectedCamera}`);
|
2024-04-22 17:20:23 +03:00
|
|
|
}
|
2024-05-18 21:55:17 +03:00
|
|
|
// we know that these deps are correct
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [changedValue, selectedCamera]);
|
2024-04-19 14:34:07 +03:00
|
|
|
|
2024-04-27 20:02:01 +03:00
|
|
|
useEffect(() => {
|
2025-03-16 18:36:20 +03:00
|
|
|
document.title = t("documentTitle.motionTuner");
|
|
|
|
|
}, [t]);
|
2024-04-27 20:02:01 +03:00
|
|
|
|
2024-04-19 14:34:07 +03:00
|
|
|
if (!cameraConfig && !selectedCamera) {
|
|
|
|
|
return <ActivityIndicator />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2024-05-14 18:06:44 +03:00
|
|
|
<div className="flex size-full flex-col md:flex-row">
|
2024-05-04 22:54:50 +03:00
|
|
|
<Toaster position="top-center" closeButton={true} />
|
2025-10-09 00:02:38 +03:00
|
|
|
<div className="scrollbar-container order-last mb-10 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:mr-3 md:mt-0 md:w-3/12">
|
2025-10-08 22:59:21 +03:00
|
|
|
<Heading as="h4" className="mb-2">
|
2025-03-16 18:36:20 +03:00
|
|
|
{t("motionDetectionTuner.title")}
|
2024-04-19 14:34:07 +03:00
|
|
|
</Heading>
|
2024-05-14 18:06:44 +03:00
|
|
|
<div className="my-3 space-y-3 text-sm text-muted-foreground">
|
2025-03-17 15:26:01 +03:00
|
|
|
<p>{t("motionDetectionTuner.desc.title")}</p>
|
2024-04-19 14:34:07 +03:00
|
|
|
|
|
|
|
|
<div className="flex items-center text-primary">
|
|
|
|
|
<Link
|
2025-05-28 15:10:45 +03:00
|
|
|
to={getLocaleDocUrl("configuration/motion_detection")}
|
2024-04-19 14:34:07 +03:00
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className="inline"
|
|
|
|
|
>
|
2025-08-23 01:19:00 +03:00
|
|
|
{t("readTheDocumentation", { ns: "common" })}
|
2024-05-14 18:06:44 +03:00
|
|
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
2024-04-19 14:34:07 +03:00
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2024-05-14 18:06:44 +03:00
|
|
|
<Separator className="my-2 flex bg-secondary" />
|
|
|
|
|
<div className="flex w-full flex-col space-y-6">
|
2024-04-19 14:34:07 +03:00
|
|
|
<div className="mt-2 space-y-6">
|
|
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
<Label htmlFor="motion-threshold" className="text-md">
|
2025-03-17 15:26:01 +03:00
|
|
|
{t("motionDetectionTuner.Threshold.title")}
|
2024-04-19 14:34:07 +03:00
|
|
|
</Label>
|
2024-05-14 18:06:44 +03:00
|
|
|
<div className="my-2 text-sm text-muted-foreground">
|
2025-03-16 18:36:20 +03:00
|
|
|
<Trans ns="views/settings">
|
|
|
|
|
motionDetectionTuner.Threshold.desc
|
|
|
|
|
</Trans>
|
2024-04-19 14:34:07 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-row justify-between">
|
|
|
|
|
<Slider
|
|
|
|
|
id="motion-threshold"
|
|
|
|
|
className="w-full"
|
|
|
|
|
disabled={motionSettings.threshold === undefined}
|
|
|
|
|
value={[motionSettings.threshold ?? 0]}
|
|
|
|
|
min={5}
|
|
|
|
|
max={80}
|
|
|
|
|
step={1}
|
|
|
|
|
onValueChange={(value) => {
|
|
|
|
|
handleMotionConfigChange({ threshold: value[0] });
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2024-05-14 18:06:44 +03:00
|
|
|
<div className="align-center ml-6 mr-2 flex text-lg">
|
2024-04-19 14:34:07 +03:00
|
|
|
{motionSettings.threshold}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-2 space-y-6">
|
|
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
<Label htmlFor="motion-threshold" className="text-md">
|
2025-03-17 15:26:01 +03:00
|
|
|
{t("motionDetectionTuner.contourArea.title")}
|
2024-04-19 14:34:07 +03:00
|
|
|
</Label>
|
2024-05-14 18:06:44 +03:00
|
|
|
<div className="my-2 text-sm text-muted-foreground">
|
2024-04-19 14:34:07 +03:00
|
|
|
<p>
|
2025-03-16 18:36:20 +03:00
|
|
|
<Trans ns="views/settings">
|
|
|
|
|
motionDetectionTuner.contourArea.desc
|
|
|
|
|
</Trans>
|
2024-04-19 14:34:07 +03:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-row justify-between">
|
|
|
|
|
<Slider
|
|
|
|
|
id="motion-contour-area"
|
|
|
|
|
className="w-full"
|
|
|
|
|
disabled={motionSettings.contour_area === undefined}
|
|
|
|
|
value={[motionSettings.contour_area ?? 0]}
|
|
|
|
|
min={5}
|
|
|
|
|
max={100}
|
|
|
|
|
step={1}
|
|
|
|
|
onValueChange={(value) => {
|
|
|
|
|
handleMotionConfigChange({ contour_area: value[0] });
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2024-05-14 18:06:44 +03:00
|
|
|
<div className="align-center ml-6 mr-2 flex text-lg">
|
2024-04-19 14:34:07 +03:00
|
|
|
{motionSettings.contour_area}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2024-05-14 18:06:44 +03:00
|
|
|
<Separator className="my-2 flex bg-secondary" />
|
2024-04-19 14:34:07 +03:00
|
|
|
<div className="flex flex-row items-center justify-between">
|
|
|
|
|
<div className="space-y-0.5">
|
2025-03-16 18:36:20 +03:00
|
|
|
<Label htmlFor="improve-contrast">
|
2025-03-17 15:26:01 +03:00
|
|
|
{t("motionDetectionTuner.improveContrast.title")}
|
2025-03-16 18:36:20 +03:00
|
|
|
</Label>
|
2024-04-19 14:34:07 +03:00
|
|
|
<div className="text-sm text-muted-foreground">
|
2025-03-16 18:36:20 +03:00
|
|
|
<Trans ns="views/settings">
|
|
|
|
|
motionDetectionTuner.improveContrast.desc
|
|
|
|
|
</Trans>
|
2024-04-19 14:34:07 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Switch
|
|
|
|
|
id="improve-contrast"
|
|
|
|
|
className="ml-3"
|
|
|
|
|
disabled={motionSettings.improve_contrast === undefined}
|
|
|
|
|
checked={motionSettings.improve_contrast === true}
|
|
|
|
|
onCheckedChange={(isChecked) => {
|
|
|
|
|
handleMotionConfigChange({ improve_contrast: isChecked });
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2024-05-14 18:06:44 +03:00
|
|
|
<div className="flex flex-1 flex-col justify-end">
|
2024-04-19 14:34:07 +03:00
|
|
|
<div className="flex flex-row gap-2 pt-5">
|
2024-10-23 01:07:42 +03:00
|
|
|
<Button
|
|
|
|
|
className="flex flex-1"
|
2025-03-16 18:36:20 +03:00
|
|
|
aria-label={t("button.reset", { ns: "common" })}
|
2024-10-23 01:07:42 +03:00
|
|
|
onClick={onCancel}
|
|
|
|
|
>
|
2025-03-16 18:36:20 +03:00
|
|
|
{t("button.reset", { ns: "common" })}
|
2024-04-19 14:34:07 +03:00
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="select"
|
|
|
|
|
disabled={!changedValue || isLoading}
|
|
|
|
|
className="flex flex-1"
|
2025-03-16 18:36:20 +03:00
|
|
|
aria-label={t("button.save", { ns: "common" })}
|
2024-04-19 14:34:07 +03:00
|
|
|
onClick={saveToConfig}
|
|
|
|
|
>
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
<div className="flex flex-row items-center gap-2">
|
|
|
|
|
<ActivityIndicator />
|
2025-03-16 18:36:20 +03:00
|
|
|
<span>{t("button.saving", { ns: "common" })}</span>
|
2024-04-19 14:34:07 +03:00
|
|
|
</div>
|
|
|
|
|
) : (
|
2025-03-16 18:36:20 +03:00
|
|
|
t("button.save", { ns: "common" })
|
2024-04-19 14:34:07 +03:00
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{cameraConfig ? (
|
2025-10-09 00:02:38 +03:00
|
|
|
<div className="flex max-h-[70%] md:mr-3 md:h-dvh md:max-h-full md:w-7/12 md:grow">
|
2024-04-19 14:34:07 +03:00
|
|
|
<div className="size-full min-h-10">
|
|
|
|
|
<AutoUpdatingCameraImage
|
|
|
|
|
camera={cameraConfig.name}
|
|
|
|
|
searchParams={new URLSearchParams([["motion", "1"]])}
|
|
|
|
|
showFps={false}
|
|
|
|
|
className="size-full"
|
|
|
|
|
cameraClasses="relative w-full h-full flex flex-col justify-start"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
2024-04-22 18:12:45 +03:00
|
|
|
<Skeleton className="size-full rounded-lg md:rounded-2xl" />
|
2024-04-19 14:34:07 +03:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|