mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 10:33:11 +03:00
improve detail stream ux
This commit is contained in:
parent
19ee01ada4
commit
5f3a51f002
@ -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>
|
||||
);
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user