mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-19 23:08:08 +02:00
Improve annotation offset UX (#22310)
* keep nav buttons visible nav buttons would be hidden when closing and reopening dialog after selecting the tracking details pane * 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 * improve detail stream ux * update dummy camera docs * fix docs link
This commit is contained in:
@@ -7,10 +7,16 @@ 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 { LuExternalLink, 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";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const OFFSET_MIN = -2500;
|
||||
const OFFSET_MAX = 2500;
|
||||
const OFFSET_STEP = 50;
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
@@ -19,6 +25,7 @@ type Props = {
|
||||
export default function AnnotationOffsetSlider({ className }: Props) {
|
||||
const { annotationOffset, setAnnotationOffset, camera } = useDetailStream();
|
||||
const isAdmin = useIsAdmin();
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const { mutate } = useSWRConfig();
|
||||
const { t } = useTranslation(["views/explore"]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -32,6 +39,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 +89,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,57 +108,81 @@ 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>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={reset}>
|
||||
{t("button.reset", { ns: "common" })}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button size="sm" onClick={save} disabled={isSaving}>
|
||||
{isSaving
|
||||
? t("button.saving", { ns: "common" })
|
||||
: t("button.save", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
</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={cn(
|
||||
"flex items-center gap-2 text-xs text-muted-foreground",
|
||||
isMobile && "landscape:flex-col landscape:items-start",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-1.5 text-xs text-muted-foreground">
|
||||
<Trans ns="views/explore">
|
||||
trackingDetails.annotationSettings.offset.millisecondsToOffset
|
||||
</Trans>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="focus:outline-none"
|
||||
className="mt-px shrink-0 focus:outline-none"
|
||||
aria-label={t("trackingDetails.annotationSettings.offset.tips")}
|
||||
>
|
||||
<LuInfo className="size-4" />
|
||||
<LuInfo className="size-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-sm">
|
||||
{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>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={reset}>
|
||||
{t("button.reset", { ns: "common" })}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button size="sm" onClick={save} disabled={isSaving}>
|
||||
{isSaving
|
||||
? t("button.saving", { ns: "common" })
|
||||
: t("button.save", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,100 @@ 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(
|
||||
"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 />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -495,6 +496,15 @@ export default function SearchDetailDialog({
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop || !onPrevious || !onNext) {
|
||||
setShowNavigationButtons(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowNavigationButtons(isOpen);
|
||||
}, [isOpen, onNext, onPrevious]);
|
||||
|
||||
// show/hide annotation settings is handled inside TabsWithActions
|
||||
|
||||
const searchTabs = useMemo(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { TrackingDetailsSequence } from "@/types/timeline";
|
||||
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||
import { useDetailStream } from "@/context/detail-stream-context";
|
||||
@@ -33,6 +33,7 @@ import { MdAutoAwesome } from "react-icons/md";
|
||||
import { isPWA } from "@/utils/isPWA";
|
||||
import { isInIframe } from "@/utils/isIFrame";
|
||||
import { GenAISummaryDialog } from "../overlay/chip/GenAISummaryChip";
|
||||
import { Separator } from "../ui/separator";
|
||||
|
||||
type DetailStreamProps = {
|
||||
reviewItems?: ReviewSegment[];
|
||||
@@ -49,7 +50,8 @@ export default function DetailStream({
|
||||
}: DetailStreamProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { t } = useTranslation("views/events");
|
||||
const { annotationOffset } = useDetailStream();
|
||||
const { annotationOffset, selectedObjectIds, setSelectedObjectIds } =
|
||||
useDetailStream();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [activeReviewId, setActiveReviewId] = useState<string | undefined>(
|
||||
@@ -67,9 +69,69 @@ export default function DetailStream({
|
||||
true,
|
||||
);
|
||||
|
||||
const onSeekCheckPlaying = (timestamp: number) => {
|
||||
onSeek(timestamp, isPlaying);
|
||||
};
|
||||
// When the settings panel opens, pin to the nearest review with detections
|
||||
// so the user can visually align the bounding box using the offset slider
|
||||
const pinnedDetectTimestampRef = useRef<number | null>(null);
|
||||
const wasControlsExpandedRef = useRef(false);
|
||||
const selectedBeforeExpandRef = useRef<string[]>([]);
|
||||
|
||||
const onSeekCheckPlaying = useCallback(
|
||||
(timestamp: number) => {
|
||||
onSeek(timestamp, isPlaying);
|
||||
},
|
||||
[onSeek, isPlaying],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (controlsExpanded && !wasControlsExpandedRef.current) {
|
||||
selectedBeforeExpandRef.current = selectedObjectIds;
|
||||
|
||||
const items = (reviewItems ?? []).filter(
|
||||
(r) => r.data?.detections?.length > 0,
|
||||
);
|
||||
if (items.length > 0) {
|
||||
// Pick the nearest review to current effective time
|
||||
let nearest = items[0];
|
||||
let minDiff = Math.abs(effectiveTime - nearest.start_time);
|
||||
for (const r of items) {
|
||||
const diff = Math.abs(effectiveTime - r.start_time);
|
||||
if (diff < minDiff) {
|
||||
nearest = r;
|
||||
minDiff = diff;
|
||||
}
|
||||
}
|
||||
|
||||
const nearestId = `review-${nearest.id ?? nearest.start_time ?? Math.floor(nearest.start_time ?? 0)}`;
|
||||
setActiveReviewId(nearestId);
|
||||
|
||||
const detectionId = nearest.data.detections[0];
|
||||
setSelectedObjectIds([detectionId]);
|
||||
|
||||
// Use the detection's actual start timestamp (parsed from its ID)
|
||||
// rather than review.start_time, which can be >10ms away from any
|
||||
// lifecycle event and would fail the bounding-box TOLERANCE check.
|
||||
const detectTimestamp = parseFloat(detectionId);
|
||||
pinnedDetectTimestampRef.current = detectTimestamp;
|
||||
const recordTime = detectTimestamp + annotationOffset / 1000;
|
||||
onSeek(recordTime, false);
|
||||
}
|
||||
}
|
||||
if (!controlsExpanded && wasControlsExpandedRef.current) {
|
||||
pinnedDetectTimestampRef.current = null;
|
||||
setSelectedObjectIds(selectedBeforeExpandRef.current);
|
||||
}
|
||||
wasControlsExpandedRef.current = controlsExpanded;
|
||||
// Only trigger on expand/collapse transition
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controlsExpanded]);
|
||||
|
||||
// Re-seek on annotation offset change while settings panel is open
|
||||
useEffect(() => {
|
||||
const pinned = pinnedDetectTimestampRef.current;
|
||||
if (!controlsExpanded || pinned == null) return;
|
||||
const recordTime = pinned + annotationOffset / 1000;
|
||||
onSeek(recordTime, false);
|
||||
}, [controlsExpanded, annotationOffset, onSeek]);
|
||||
|
||||
// Ensure we initialize the active review when reviewItems first arrive.
|
||||
// This helps when the component mounts while the video is already
|
||||
@@ -214,6 +276,12 @@ export default function DetailStream({
|
||||
/>
|
||||
|
||||
<div className="relative flex h-full flex-col">
|
||||
{controlsExpanded && (
|
||||
<div
|
||||
className="absolute inset-0 z-20 cursor-pointer bg-black/50"
|
||||
onClick={() => setControlsExpanded(false)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="scrollbar-container flex-1 overflow-y-auto overflow-x-hidden pb-14"
|
||||
@@ -267,8 +335,9 @@ export default function DetailStream({
|
||||
)}
|
||||
</button>
|
||||
{controlsExpanded && (
|
||||
<div className="space-y-3 px-3 pb-3">
|
||||
<div className="space-y-4 px-3 pb-5 pt-2">
|
||||
<AnnotationOffsetSlider />
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">
|
||||
|
||||
Reference in New Issue
Block a user