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
This commit is contained in:
Josh Hawkins 2026-03-06 19:32:55 -06:00
parent 1814420c68
commit 19ee01ada4
4 changed files with 306 additions and 225 deletions

View File

@ -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 (
<div
className={cn(
"flex flex-col gap-0.5",
"flex flex-col gap-1.5",
isMobile && "landscape:gap-3",
className,
)}
>
<div className="flex items-center gap-2 text-sm">
<span>{t("trackingDetails.annotationSettings.offset.label")}:</span>
<span className="font-mono tabular-nums text-primary-variant">
{annotationOffset > 0 ? "+" : ""}
{annotationOffset}ms
</span>
</div>
<div
className={cn(
"flex items-center gap-3",
@ -84,21 +105,62 @@ export default function AnnotationOffsetSlider({ className }: Props) {
"landscape:flex-col landscape:items-start landscape:gap-4",
)}
>
<div className="flex max-w-28 flex-row items-center gap-2 text-sm md:max-w-48">
<span className="max-w-24 md:max-w-44">
{t("trackingDetails.annotationSettings.offset.label")}:
</span>
<span className="text-primary-variant">{annotationOffset}</span>
</div>
<Button
type="button"
variant="outline"
size="icon"
className="size-8 shrink-0"
aria-label="-50ms"
onClick={() => stepOffset(-OFFSET_STEP)}
disabled={annotationOffset <= OFFSET_MIN}
>
<LuMinus className="size-4" />
</Button>
<div className="w-full flex-1 landscape:flex">
<Slider
value={[annotationOffset]}
min={-2500}
max={2500}
step={50}
min={OFFSET_MIN}
max={OFFSET_MAX}
step={OFFSET_STEP}
onValueChange={handleChange}
/>
</div>
<Button
type="button"
variant="outline"
size="icon"
className="size-8 shrink-0"
aria-label="+50ms"
onClick={() => stepOffset(OFFSET_STEP)}
disabled={annotationOffset >= OFFSET_MAX}
>
<LuPlus className="size-4" />
</Button>
</div>
<div className="flex items-center justify-between">
<div
className={cn(
"flex items-center gap-2 text-xs text-muted-foreground",
isMobile && "landscape:flex-col landscape:items-start",
)}
>
<Trans ns="views/explore">
trackingDetails.annotationSettings.offset.millisecondsToOffset
</Trans>
<Popover>
<PopoverTrigger asChild>
<button
className="focus:outline-none"
aria-label={t("trackingDetails.annotationSettings.offset.tips")}
>
<LuInfo className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent className="w-80 text-sm">
{t("trackingDetails.annotationSettings.offset.tips")}
</PopoverContent>
</Popover>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={reset}>
{t("button.reset", { ns: "common" })}
@ -112,29 +174,6 @@ export default function AnnotationOffsetSlider({ className }: Props) {
)}
</div>
</div>
<div
className={cn(
"flex items-center gap-2 text-xs text-muted-foreground",
isMobile && "landscape:flex-col landscape:items-start",
)}
>
<Trans ns="views/explore">
trackingDetails.annotationSettings.offset.millisecondsToOffset
</Trans>
<Popover>
<PopoverTrigger asChild>
<button
className="focus:outline-none"
aria-label={t("trackingDetails.annotationSettings.offset.tips")}
>
<LuInfo className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent className="w-80 text-sm">
{t("trackingDetails.annotationSettings.offset.tips")}
</PopoverContent>
</Popover>
</div>
</div>
);
}

View File

@ -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<z.infer<typeof formSchema>>({
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<typeof formSchema>) {
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<typeof formSchema>) {
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 (
<div className="p-4">
@ -140,91 +108,98 @@ export function AnnotationSettingsPane({
</div>
<Separator className="mb-4 flex bg-secondary" />
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-1 flex-col space-y-3"
>
<FormField
control={form.control}
name="annotationOffset"
render={({ field }) => (
<>
<FormItem className="flex flex-row items-start justify-between space-x-2">
<div className="flex flex-col gap-1">
<FormLabel>
{t("trackingDetails.annotationSettings.offset.label")}
</FormLabel>
<FormDescription>
<Trans ns="views/explore">
trackingDetails.annotationSettings.offset.millisecondsToOffset
</Trans>
<FormMessage />
</FormDescription>
</div>
<div className="flex flex-col gap-3">
<div className="min-w-24">
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 text-center hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="0"
{...field}
/>
</FormControl>
</div>
</div>
</FormItem>
<div className="mt-1 text-sm text-secondary-foreground">
{t("trackingDetails.annotationSettings.offset.tips")}
<div className="mt-2 flex items-center text-primary-variant">
<Link
to={getLocaleDocUrl("configuration/reference")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</>
)}
/>
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
variant="default"
aria-label={t("button.apply", { ns: "common" })}
type="button"
onClick={form.handleSubmit(onApply)}
>
{t("button.apply", { ns: "common" })}
</Button>
{isAdmin && (
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
)}
</div>
<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>
</form>
</Form>
<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(-OFFSET_STEP)}
disabled={annotationOffset <= OFFSET_MIN}
>
<LuMinus className="size-4" />
</Button>
<Slider
value={[annotationOffset]}
min={OFFSET_MIN}
max={OFFSET_MAX}
step={OFFSET_STEP}
onValueChange={handleSliderChange}
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="icon"
className="size-8 shrink-0"
aria-label="+50ms"
onClick={() => stepOffset(OFFSET_STEP)}
disabled={annotationOffset >= 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("configuration/reference")}
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 />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</>
)}
</div>
</div>
);
}

View File

@ -323,6 +323,7 @@ function DialogContentComponent({
<TrackingDetails
className={cn(isDesktop ? "size-full" : "flex flex-col gap-4")}
event={search as unknown as Event}
isAnnotationSettingsOpen={isPopoverOpen}
tabs={
isDesktop ? (
<TabsWithActions

View File

@ -47,12 +47,14 @@ type TrackingDetailsProps = {
event: Event;
fullscreen?: boolean;
tabs?: React.ReactNode;
isAnnotationSettingsOpen?: boolean;
};
export function TrackingDetails({
className,
event,
tabs,
isAnnotationSettingsOpen = false,
}: TrackingDetailsProps) {
const videoRef = useRef<HTMLVideoElement | null>(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<number | null>(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<FrigateConfig>("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<Recording[]>(
@ -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<number | null>(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(() => {