diff --git a/web/src/components/overlay/ObjectTrackOverlay.tsx b/web/src/components/overlay/ObjectTrackOverlay.tsx index 8fbdcc049..092199dc9 100644 --- a/web/src/components/overlay/ObjectTrackOverlay.tsx +++ b/web/src/components/overlay/ObjectTrackOverlay.tsx @@ -37,8 +37,7 @@ export default function ObjectTrackOverlay({ const { data: config } = useSWR("config"); const { annotationOffset } = useActivityStream(); - // Offset currentTime by annotation offset for rendering - const effectiveCurrentTime = currentTime - annotationOffset; + const effectiveCurrentTime = currentTime - annotationOffset / 1000; // Fetch the full event data to get saved path points const { data: eventData } = useSWR(["event_ids", { ids: selectedObjectId }]); @@ -157,8 +156,9 @@ export default function ObjectTrackOverlay({ }, [savedPathPoints, eventSequencePoints, config, camera, currentTime]); // get absolute positions on the svg canvas for each point - const getAbsolutePositions = useCallback(() => { + const absolutePositions = useMemo(() => { if (!pathPoints) return []; + return pathPoints.map((point) => { // Find the corresponding timeline entry for this point const timelineEntry = objectTimeline?.find( @@ -272,11 +272,6 @@ export default function ObjectTrackOverlay({ : [255, 0, 0]; }, [pathPoints, getObjectColor]); - const absolutePositions = useMemo( - () => getAbsolutePositions(), - [getAbsolutePositions], - ); - // render any zones for object at current time const zonePolygons = useMemo(() => { return zones.map((zone) => { diff --git a/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx b/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx new file mode 100644 index 000000000..3e1ab52c9 --- /dev/null +++ b/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx @@ -0,0 +1,95 @@ +import { useCallback, useState } from "react"; +import { Slider } from "@/components/ui/slider"; +import { Button } from "@/components/ui/button"; +import { useActivityStream } from "@/contexts/ActivityStreamContext"; +import axios from "axios"; +import { useSWRConfig } from "swr"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; + +type Props = { + className?: string; +}; + +export default function AnnotationOffsetSlider({ className }: Props) { + const { annotationOffset, setAnnotationOffset, camera } = useActivityStream(); + const { mutate } = useSWRConfig(); + const { t } = useTranslation(["views/explore"]); + const [isSaving, setIsSaving] = useState(false); + + const handleChange = useCallback( + (values: number[]) => { + if (!values || values.length === 0) return; + const valueMs = values[0]; + setAnnotationOffset(valueMs); + }, + [setAnnotationOffset], + ); + + const reset = useCallback(() => { + setAnnotationOffset(0); + }, [setAnnotationOffset]); + + const save = useCallback(async () => { + setIsSaving(true); + try { + // save value in milliseconds to config + await axios.put( + `config/set?cameras.${camera}.detect.annotation_offset=${annotationOffset}`, + { requires_restart: 0 }, + ); + + toast.success( + t("objectLifecycle.annotationSettings.offset.toast.success", { + camera, + }), + { position: "top-center" }, + ); + + // refresh config + await mutate("config"); + } catch (e: unknown) { + const err = e as { + response?: { data?: { message?: string } }; + message?: string; + }; + const errorMessage = + err?.response?.data?.message || err?.message || "Unknown error"; + toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), { + position: "top-center", + }); + } finally { + setIsSaving(false); + } + }, [annotationOffset, camera, mutate, t]); + + return ( +
+
+ Annotation offset (ms): {annotationOffset} +
+
+ +
+
+ + +
+
+ ); +} diff --git a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx index 9e92bc011..56214b99d 100644 --- a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx +++ b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx @@ -174,7 +174,7 @@ export function AnnotationSettingsPane({ {t("objectLifecycle.annotationSettings.offset.label")}
-
+
diff --git a/web/src/components/timeline/ActivityStream.tsx b/web/src/components/timeline/ActivityStream.tsx index 38d70802e..369ec8eb9 100644 --- a/web/src/components/timeline/ActivityStream.tsx +++ b/web/src/components/timeline/ActivityStream.tsx @@ -7,6 +7,7 @@ import scrollIntoView from "scroll-into-view-if-needed"; import useUserInteraction from "@/hooks/use-user-interaction"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { useTranslation } from "react-i18next"; +import AnnotationOffsetSlider from "@/components/overlay/detail/AnnotationOffsetSlider"; import { FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; import ActivityIndicator from "../indicators/activity-indicator"; @@ -27,7 +28,7 @@ export default function ActivityStream({ const { selectedObjectId, annotationOffset } = useActivityStream(); const scrollRef = useRef(null); - const effectiveTime = currentTime + annotationOffset; + const effectiveTime = currentTime + annotationOffset / 1000; // Track user interaction and adjust scrolling behavior const { userInteracting, setProgrammaticScroll } = useUserInteraction({ @@ -53,7 +54,8 @@ export default function ActivityStream({ ); return { timestamp: sortedActivities[0].timestamp, // Original timestamp for display - effectiveTimestamp: sortedActivities[0].timestamp + annotationOffset, // Adjusted for sorting/comparison + effectiveTimestamp: + sortedActivities[0].timestamp + annotationOffset / 1000, activities: sortedActivities, }; }) @@ -113,27 +115,31 @@ export default function ActivityStream({ } return ( -
-
- {filteredGroups.length === 0 ? ( -
- {t("activity.noActivitiesFound")} -
- ) : ( - filteredGroups.map((group) => ( - - )) - )} +
+
+
+ {filteredGroups.length === 0 ? ( +
+ {t("activity.noActivitiesFound")} +
+ ) : ( + filteredGroups.map((group) => ( + + )) + )} +
+ +
); } diff --git a/web/src/contexts/ActivityStreamContext.tsx b/web/src/contexts/ActivityStreamContext.tsx index b26c52279..ebdd0aef7 100644 --- a/web/src/contexts/ActivityStreamContext.tsx +++ b/web/src/contexts/ActivityStreamContext.tsx @@ -1,4 +1,10 @@ -import React, { createContext, useContext, useState, useMemo } from "react"; +import React, { + createContext, + useContext, + useState, + useMemo, + useEffect, +} from "react"; import { ObjectLifecycleSequence } from "@/types/timeline"; import { FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; @@ -8,7 +14,8 @@ interface ActivityStreamContextType { selectedObjectTimeline: ObjectLifecycleSequence[] | undefined; currentTime: number; camera: string; - annotationOffset: number; + annotationOffset: number; // milliseconds + setAnnotationOffset: (ms: number) => void; setSelectedObjectId: (id: string | undefined) => void; isActivityMode: boolean; } @@ -38,12 +45,15 @@ export function ActivityStreamProvider({ const { data: config } = useSWR("config"); - const annotationOffset = useMemo(() => { - if (!config) { - return 0; - } + const [annotationOffset, setAnnotationOffset] = useState(() => { + if (!config) return 0; + return config.cameras[camera]?.detect?.annotation_offset || 0; + }); - return (config.cameras[camera]?.detect?.annotation_offset || 0) / 1000; // Convert to seconds + useEffect(() => { + if (!config) return; + const cfgOffset = config.cameras[camera]?.detect?.annotation_offset || 0; + setAnnotationOffset(cfgOffset); }, [config, camera]); const selectedObjectTimeline = useMemo(() => { @@ -57,6 +67,7 @@ export function ActivityStreamProvider({ currentTime, camera, annotationOffset, + setAnnotationOffset, setSelectedObjectId, isActivityMode, };