mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-07 05:55:27 +03:00
* lpr fixes - remove duplicate code - fix min_area check for non frigate+ code path - move log outside of non frigate+ code path * only show chat link when a genai provider is configured with the chat role * respect ui.timezone when generating fallback export names * reapply radix pointer events fix to call sites that use navigate() * formatting * fall back to prior preview frame for short export thumbnails * fix typing * fix e2e test for chat navigation * batch annotation offset to seek atomically and throttle slider drag * add debug replay loading toast for explore actions * Improve handling of webpush missing shortSummary --------- Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
251 lines
7.8 KiB
TypeScript
251 lines
7.8 KiB
TypeScript
import { Event } from "@/types/event";
|
|
import { FrigateConfig } from "@/types/frigateConfig";
|
|
import axios from "axios";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { flushSync } from "react-dom";
|
|
import { throttle } from "lodash";
|
|
import { LuExternalLink, LuMinus, LuPlus } from "react-icons/lu";
|
|
import { Link } from "react-router-dom";
|
|
import { toast } from "sonner";
|
|
import useSWR from "swr";
|
|
import { Button } from "@/components/ui/button";
|
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Slider } from "@/components/ui/slider";
|
|
import { Trans, useTranslation } from "react-i18next";
|
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
|
import {
|
|
ANNOTATION_OFFSET_MAX,
|
|
ANNOTATION_OFFSET_MIN,
|
|
ANNOTATION_OFFSET_STEP,
|
|
} from "@/lib/const";
|
|
|
|
const SLIDER_DRAG_THROTTLE_MS = 80;
|
|
|
|
type AnnotationSettingsPaneProps = {
|
|
event: Event;
|
|
annotationOffset: number;
|
|
setAnnotationOffset: React.Dispatch<React.SetStateAction<number>>;
|
|
};
|
|
export function AnnotationSettingsPane({
|
|
event,
|
|
annotationOffset,
|
|
setAnnotationOffset,
|
|
}: AnnotationSettingsPaneProps) {
|
|
const { t } = useTranslation(["views/explore"]);
|
|
const isAdmin = useIsAdmin();
|
|
const { getLocaleDocUrl } = useDocDomain();
|
|
|
|
const { data: config, mutate: updateConfig } =
|
|
useSWR<FrigateConfig>("config");
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
// flushSync ensures setAnnotationOffset commits synchronously so the
|
|
// useLayoutEffect in TrackingDetails (which seeks the video and sets
|
|
// currentTime in response) runs before the browser paints — preventing a
|
|
// one-frame overlay mismatch where annotationOffset has changed but
|
|
// currentTime has not.
|
|
const applyOffset = useCallback(
|
|
(newOffset: number) => {
|
|
flushSync(() => {
|
|
setAnnotationOffset(newOffset);
|
|
});
|
|
},
|
|
[setAnnotationOffset],
|
|
);
|
|
|
|
const throttledApplyOffset = useMemo(
|
|
() =>
|
|
throttle(applyOffset, SLIDER_DRAG_THROTTLE_MS, {
|
|
leading: true,
|
|
trailing: true,
|
|
}),
|
|
[applyOffset],
|
|
);
|
|
|
|
useEffect(() => () => throttledApplyOffset.cancel(), [throttledApplyOffset]);
|
|
|
|
const handleSliderChange = useCallback(
|
|
(values: number[]) => {
|
|
if (!values || values.length === 0) return;
|
|
throttledApplyOffset(values[0]);
|
|
},
|
|
[throttledApplyOffset],
|
|
);
|
|
|
|
const handleSliderCommit = useCallback(
|
|
(values: number[]) => {
|
|
if (!values || values.length === 0) return;
|
|
throttledApplyOffset.cancel();
|
|
applyOffset(values[0]);
|
|
},
|
|
[throttledApplyOffset, applyOffset],
|
|
);
|
|
|
|
const stepOffset = useCallback(
|
|
(delta: number) => {
|
|
const next = Math.max(
|
|
ANNOTATION_OFFSET_MIN,
|
|
Math.min(ANNOTATION_OFFSET_MAX, annotationOffset + delta),
|
|
);
|
|
throttledApplyOffset.cancel();
|
|
applyOffset(next);
|
|
},
|
|
[annotationOffset, applyOffset, throttledApplyOffset],
|
|
);
|
|
|
|
const reset = useCallback(() => {
|
|
throttledApplyOffset.cancel();
|
|
applyOffset(0);
|
|
}, [applyOffset, throttledApplyOffset]);
|
|
|
|
const saveToConfig = useCallback(async () => {
|
|
if (!config || !event) return;
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const res = await axios.put(
|
|
`config/set?cameras.${event.camera}.detect.annotation_offset=${annotationOffset}`,
|
|
{ requires_restart: 0 },
|
|
);
|
|
if (res.status === 200) {
|
|
toast.success(
|
|
t("trackingDetails.annotationSettings.offset.toast.success", {
|
|
camera: event.camera,
|
|
}),
|
|
{ position: "top-center" },
|
|
);
|
|
updateConfig();
|
|
} else {
|
|
toast.error(
|
|
t("toast.save.error.title", {
|
|
errorMessage: res.statusText,
|
|
ns: "common",
|
|
}),
|
|
{ position: "top-center" },
|
|
);
|
|
}
|
|
} catch (error: unknown) {
|
|
const err = error as {
|
|
response?: { data?: { message?: string; detail?: string } };
|
|
};
|
|
const errorMessage =
|
|
err?.response?.data?.message ||
|
|
err?.response?.data?.detail ||
|
|
"Unknown error";
|
|
toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), {
|
|
position: "top-center",
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [annotationOffset, config, event, updateConfig, t]);
|
|
|
|
return (
|
|
<div className="p-4">
|
|
<div className="text-md mb-2">
|
|
{t("trackingDetails.annotationSettings.title")}
|
|
</div>
|
|
|
|
<Separator className="mb-4 flex bg-secondary" />
|
|
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-sm font-medium">
|
|
{t("trackingDetails.annotationSettings.offset.label")}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
<Trans ns="views/explore">
|
|
trackingDetails.annotationSettings.offset.millisecondsToOffset
|
|
</Trans>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
className="size-8 shrink-0"
|
|
aria-label="-50ms"
|
|
onClick={() => stepOffset(-ANNOTATION_OFFSET_STEP)}
|
|
disabled={annotationOffset <= ANNOTATION_OFFSET_MIN}
|
|
>
|
|
<LuMinus className="size-4" />
|
|
</Button>
|
|
<Slider
|
|
value={[annotationOffset]}
|
|
min={ANNOTATION_OFFSET_MIN}
|
|
max={ANNOTATION_OFFSET_MAX}
|
|
step={ANNOTATION_OFFSET_STEP}
|
|
onValueChange={handleSliderChange}
|
|
onValueCommit={handleSliderCommit}
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
className="size-8 shrink-0"
|
|
aria-label="+50ms"
|
|
onClick={() => stepOffset(ANNOTATION_OFFSET_STEP)}
|
|
disabled={annotationOffset >= ANNOTATION_OFFSET_MAX}
|
|
>
|
|
<LuPlus className="size-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-mono text-sm tabular-nums text-primary-variant">
|
|
{annotationOffset > 0 ? "+" : ""}
|
|
{annotationOffset}ms
|
|
</span>
|
|
<Button type="button" variant="ghost" size="sm" onClick={reset}>
|
|
{t("button.reset", { ns: "common" })}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="text-sm text-secondary-foreground">
|
|
{t("trackingDetails.annotationSettings.offset.tips")}
|
|
<div className="mt-2 flex items-center text-primary-variant">
|
|
<Link
|
|
to={getLocaleDocUrl(
|
|
"troubleshooting/dummy-camera#annotation-offset",
|
|
)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline"
|
|
>
|
|
{t("readTheDocumentation", { ns: "common" })}
|
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{isAdmin && (
|
|
<>
|
|
<Separator className="bg-secondary" />
|
|
<Button
|
|
variant="select"
|
|
aria-label={t("button.save", { ns: "common" })}
|
|
disabled={isLoading}
|
|
onClick={saveToConfig}
|
|
>
|
|
{isLoading ? (
|
|
<div className="flex flex-row items-center gap-2">
|
|
<ActivityIndicator className="size-4" />
|
|
<span>{t("button.saving", { ns: "common" })}</span>
|
|
</div>
|
|
) : (
|
|
t("button.save", { ns: "common" })
|
|
)}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|