From 19ee01ada414c4a07df54cc0a0179727f4b551bb Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Fri, 6 Mar 2026 19:32:55 -0600
Subject: [PATCH] better ux in tracking details
actually pause the video and seek when annotation offset changes to make it easier to visually line up the bounding box
---
.../overlay/detail/AnnotationOffsetSlider.tsx | 107 ++++--
.../overlay/detail/AnnotationSettingsPane.tsx | 333 ++++++++----------
.../overlay/detail/SearchDetailDialog.tsx | 1 +
.../overlay/detail/TrackingDetails.tsx | 90 ++++-
4 files changed, 306 insertions(+), 225 deletions(-)
diff --git a/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx b/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx
index fbc587413..d74909e86 100644
--- a/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx
+++ b/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx
@@ -7,11 +7,15 @@ import axios from "axios";
import { useSWRConfig } from "swr";
import { toast } from "sonner";
import { Trans, useTranslation } from "react-i18next";
-import { LuInfo } from "react-icons/lu";
+import { LuInfo, LuMinus, LuPlus } from "react-icons/lu";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect";
import { useIsAdmin } from "@/hooks/use-is-admin";
+const OFFSET_MIN = -2500;
+const OFFSET_MAX = 2500;
+const OFFSET_STEP = 50;
+
type Props = {
className?: string;
};
@@ -32,6 +36,16 @@ export default function AnnotationOffsetSlider({ className }: Props) {
[setAnnotationOffset],
);
+ const stepOffset = useCallback(
+ (delta: number) => {
+ setAnnotationOffset((prev) => {
+ const next = prev + delta;
+ return Math.max(OFFSET_MIN, Math.min(OFFSET_MAX, next));
+ });
+ },
+ [setAnnotationOffset],
+ );
+
const reset = useCallback(() => {
setAnnotationOffset(0);
}, [setAnnotationOffset]);
@@ -72,11 +86,18 @@ export default function AnnotationOffsetSlider({ className }: Props) {
return (
+
+ {t("trackingDetails.annotationSettings.offset.label")}:
+
+ {annotationOffset > 0 ? "+" : ""}
+ {annotationOffset}ms
+
+
-
-
- {t("trackingDetails.annotationSettings.offset.label")}:
-
- {annotationOffset}
-
+
+
+
+
+
+
+ trackingDetails.annotationSettings.offset.millisecondsToOffset
+
+
+
+
+
+
+ {t("trackingDetails.annotationSettings.offset.tips")}
+
+
+
-
-
- trackingDetails.annotationSettings.offset.millisecondsToOffset
-
-
-
-
-
-
- {t("trackingDetails.annotationSettings.offset.tips")}
-
-
-
);
}
diff --git a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx
index a08be0cfd..05dc5b360 100644
--- a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx
+++ b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx
@@ -1,31 +1,23 @@
import { Event } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig";
-import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios";
import { useCallback, useState } from "react";
-import { useForm } from "react-hook-form";
-import { LuExternalLink } from "react-icons/lu";
+import { LuExternalLink, LuMinus, LuPlus } from "react-icons/lu";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import useSWR from "swr";
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { z } from "zod";
import { Button } from "@/components/ui/button";
import ActivityIndicator from "@/components/indicators/activity-indicator";
-import { Input } from "@/components/ui/input";
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";
+const OFFSET_MIN = -2500;
+const OFFSET_MAX = 2500;
+const OFFSET_STEP = 50;
+
type AnnotationSettingsPaneProps = {
event: Event;
annotationOffset: number;
@@ -45,93 +37,69 @@ export function AnnotationSettingsPane({
const [isLoading, setIsLoading] = useState(false);
- const formSchema = z.object({
- annotationOffset: z.coerce.number().optional().or(z.literal("")),
- });
-
- const form = useForm>({
- resolver: zodResolver(formSchema),
- mode: "onChange",
- defaultValues: {
- annotationOffset: annotationOffset,
+ const handleSliderChange = useCallback(
+ (values: number[]) => {
+ if (!values || values.length === 0) return;
+ setAnnotationOffset(values[0]);
},
- });
-
- const saveToConfig = useCallback(
- async (annotation_offset: number | string) => {
- if (!config || !event) {
- return;
- }
-
- axios
- .put(
- `config/set?cameras.${event?.camera}.detect.annotation_offset=${annotation_offset}`,
- {
- requires_restart: 0,
- },
- )
- .then((res) => {
- 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) => {
- const errorMessage =
- error.response?.data?.message ||
- error.response?.data?.detail ||
- "Unknown error";
- toast.error(
- t("toast.save.error.title", { errorMessage, ns: "common" }),
- {
- position: "top-center",
- },
- );
- })
- .finally(() => {
- setIsLoading(false);
- });
- },
- [updateConfig, config, event, t],
+ [setAnnotationOffset],
);
- function onSubmit(values: z.infer) {
- if (!values || values.annotationOffset == null || !config) {
- return;
- }
+ const stepOffset = useCallback(
+ (delta: number) => {
+ setAnnotationOffset((prev) => {
+ const next = prev + delta;
+ return Math.max(OFFSET_MIN, Math.min(OFFSET_MAX, next));
+ });
+ },
+ [setAnnotationOffset],
+ );
+
+ const reset = useCallback(() => {
+ setAnnotationOffset(0);
+ }, [setAnnotationOffset]);
+
+ const saveToConfig = useCallback(async () => {
+ if (!config || !event) return;
+
setIsLoading(true);
-
- saveToConfig(values.annotationOffset);
- }
-
- function onApply(values: z.infer) {
- if (
- !values ||
- values.annotationOffset === null ||
- values.annotationOffset === "" ||
- !config
- ) {
- return;
+ 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);
}
- setAnnotationOffset(values.annotationOffset ?? 0);
- }
+ }, [annotationOffset, config, event, updateConfig, t]);
return (
@@ -140,91 +108,98 @@ export function AnnotationSettingsPane({
-