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({
-
- - ( - <> - -
- - {t("trackingDetails.annotationSettings.offset.label")} - - - - trackingDetails.annotationSettings.offset.millisecondsToOffset - - - -
-
-
- - - -
-
-
-
- {t("trackingDetails.annotationSettings.offset.tips")} -
- - {t("readTheDocumentation", { ns: "common" })} - - -
-
- - )} - /> -
-
- - {isAdmin && ( - - )} -
+
+
+
+ {t("trackingDetails.annotationSettings.offset.label")}
- - +
+ + trackingDetails.annotationSettings.offset.millisecondsToOffset + +
+
+ +
+ + + +
+ +
+ + {annotationOffset > 0 ? "+" : ""} + {annotationOffset}ms + + +
+ +
+ {t("trackingDetails.annotationSettings.offset.tips")} +
+ + {t("readTheDocumentation", { ns: "common" })} + + +
+
+ + {isAdmin && ( + <> + + + + )} +
); } diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index 85f237f66..1c58add7c 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -323,6 +323,7 @@ function DialogContentComponent({ (null); const { t } = useTranslation(["views/explore"]); @@ -69,6 +71,14 @@ export function TrackingDetails({ // user (eg, clicking a lifecycle row). When null we display `currentTime`. const [manualOverride, setManualOverride] = useState(null); + // Capture the annotation offset used for building the video source URL. + // This only updates when the event changes, NOT on every slider drag, + // so the HLS player doesn't reload while the user is adjusting the offset. + const sourceOffsetRef = useRef(annotationOffset); + useEffect(() => { + sourceOffsetRef.current = annotationOffset; + }, [event.id]); // eslint-disable-line react-hooks/exhaustive-deps + // event.start_time is detect time, convert to record, then subtract padding const [currentTime, setCurrentTime] = useState( (event.start_time ?? 0) + annotationOffset / 1000 - REVIEW_PADDING, @@ -90,14 +100,19 @@ export function TrackingDetails({ const { data: config } = useSWR("config"); - // Fetch recording segments for the event's time range to handle motion-only gaps + // Fetch recording segments for the event's time range to handle motion-only gaps. + // Use the source offset (stable per event) so recordings don't refetch on every + // slider drag while adjusting annotation offset. const eventStartRecord = useMemo( - () => (event.start_time ?? 0) + annotationOffset / 1000, - [event.start_time, annotationOffset], + () => (event.start_time ?? 0) + sourceOffsetRef.current / 1000, + // eslint-disable-next-line react-hooks/exhaustive-deps + [event.start_time, event.id], ); const eventEndRecord = useMemo( - () => (event.end_time ?? Date.now() / 1000) + annotationOffset / 1000, - [event.end_time, annotationOffset], + () => + (event.end_time ?? Date.now() / 1000) + sourceOffsetRef.current / 1000, + // eslint-disable-next-line react-hooks/exhaustive-deps + [event.end_time, event.id], ); const { data: recordings } = useSWR( @@ -298,6 +313,53 @@ export function TrackingDetails({ setSelectedObjectIds([event.id]); }, [event.id, setSelectedObjectIds]); + // When the annotation settings popover is open, pin the video to a specific + // lifecycle event (detect-stream timestamp). As the user drags the offset + // slider, the video re-seeks to show the recording frame at + // pinnedTimestamp + newOffset, while the bounding box stays fixed at the + // pinned detect timestamp. This lets the user visually align the box to + // the car in the video. + const pinnedDetectTimestampRef = useRef(null); + const wasAnnotationOpenRef = useRef(false); + + // On popover open: pause, pin first lifecycle item, and seek. + useEffect(() => { + if (isAnnotationSettingsOpen && !wasAnnotationOpenRef.current) { + if (videoRef.current && displaySource === "video") { + videoRef.current.pause(); + } + if (eventSequence && eventSequence.length > 0) { + pinnedDetectTimestampRef.current = eventSequence[0].timestamp; + } + } + if (!isAnnotationSettingsOpen) { + pinnedDetectTimestampRef.current = null; + } + wasAnnotationOpenRef.current = isAnnotationSettingsOpen; + }, [isAnnotationSettingsOpen, displaySource, eventSequence]); + + // When the pinned timestamp or offset changes, re-seek the video and + // explicitly update currentTime so the overlay shows the pinned event's box. + useEffect(() => { + const pinned = pinnedDetectTimestampRef.current; + if (!isAnnotationSettingsOpen || pinned == null) return; + if (!videoRef.current || displaySource !== "video") return; + + const targetTimeRecord = pinned + annotationOffset / 1000; + const relativeTime = timestampToVideoTime(targetTimeRecord); + videoRef.current.currentTime = relativeTime; + + // Explicitly update currentTime state so the overlay's effectiveCurrentTime + // resolves back to the pinned detect timestamp: + // effectiveCurrentTime = targetTimeRecord - annotationOffset/1000 = pinned + setCurrentTime(targetTimeRecord); + }, [ + isAnnotationSettingsOpen, + annotationOffset, + displaySource, + timestampToVideoTime, + ]); + const handleLifecycleClick = useCallback( (item: TrackingDetailsSequence) => { if (!videoRef.current && !imgRef.current) return; @@ -453,19 +515,23 @@ export function TrackingDetails({ const videoSource = useMemo(() => { // event.start_time and event.end_time are in DETECT stream time - // Convert to record stream time, then create video clip with padding - const eventStartRecord = event.start_time + annotationOffset / 1000; - const eventEndRecord = - (event.end_time ?? Date.now() / 1000) + annotationOffset / 1000; - const startTime = eventStartRecord - REVIEW_PADDING; - const endTime = eventEndRecord + REVIEW_PADDING; + // Convert to record stream time, then create video clip with padding. + // Use sourceOffsetRef (stable per event) so the HLS player doesn't + // reload while the user is dragging the annotation offset slider. + const sourceOffset = sourceOffsetRef.current; + const eventStartRec = event.start_time + sourceOffset / 1000; + const eventEndRec = + (event.end_time ?? Date.now() / 1000) + sourceOffset / 1000; + const startTime = eventStartRec - REVIEW_PADDING; + const endTime = eventEndRec + REVIEW_PADDING; const playlist = `${baseUrl}vod/clip/${event.camera}/start/${startTime}/end/${endTime}/index.m3u8`; return { playlist, startPosition: 0, }; - }, [event, annotationOffset]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [event]); // Determine camera aspect ratio category const cameraAspect = useMemo(() => {