improve detail stream ux

This commit is contained in:
Josh Hawkins 2026-03-06 21:07:51 -06:00
parent 19ee01ada4
commit 5f3a51f002
2 changed files with 103 additions and 41 deletions

View File

@ -137,42 +137,35 @@ export default function AnnotationOffsetSlider({ className }: Props) {
<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" })}
<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="mt-px shrink-0 focus:outline-none"
aria-label={t("trackingDetails.annotationSettings.offset.tips")}
>
<LuInfo className="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-80 text-sm">
{t("trackingDetails.annotationSettings.offset.tips")}
</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>
{isAdmin && (
<Button size="sm" onClick={save} disabled={isSaving}>
{isSaving
? t("button.saving", { ns: "common" })
: t("button.save", { ns: "common" })}
</Button>
)}
</div>
)}
</div>
</div>
);

View File

@ -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">