mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-01 21:04:52 +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" />
|
<LuPlus className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-start gap-1.5 text-xs text-muted-foreground">
|
||||||
<div
|
<Trans ns="views/explore">
|
||||||
className={cn(
|
trackingDetails.annotationSettings.offset.millisecondsToOffset
|
||||||
"flex items-center gap-2 text-xs text-muted-foreground",
|
</Trans>
|
||||||
isMobile && "landscape:flex-col landscape:items-start",
|
<Popover>
|
||||||
)}
|
<PopoverTrigger asChild>
|
||||||
>
|
<button
|
||||||
<Trans ns="views/explore">
|
className="mt-px shrink-0 focus:outline-none"
|
||||||
trackingDetails.annotationSettings.offset.millisecondsToOffset
|
aria-label={t("trackingDetails.annotationSettings.offset.tips")}
|
||||||
</Trans>
|
>
|
||||||
<Popover>
|
<LuInfo className="size-3.5" />
|
||||||
<PopoverTrigger asChild>
|
</button>
|
||||||
<button
|
</PopoverTrigger>
|
||||||
className="focus:outline-none"
|
<PopoverContent className="w-80 text-sm">
|
||||||
aria-label={t("trackingDetails.annotationSettings.offset.tips")}
|
{t("trackingDetails.annotationSettings.offset.tips")}
|
||||||
>
|
</PopoverContent>
|
||||||
<LuInfo className="size-4" />
|
</Popover>
|
||||||
</button>
|
</div>
|
||||||
</PopoverTrigger>
|
<div className="flex items-center justify-end gap-2">
|
||||||
<PopoverContent className="w-80 text-sm">
|
<Button size="sm" variant="ghost" onClick={reset}>
|
||||||
{t("trackingDetails.annotationSettings.offset.tips")}
|
{t("button.reset", { ns: "common" })}
|
||||||
</PopoverContent>
|
</Button>
|
||||||
</Popover>
|
{isAdmin && (
|
||||||
</div>
|
<Button size="sm" onClick={save} disabled={isSaving}>
|
||||||
<div className="flex items-center gap-2">
|
{isSaving
|
||||||
<Button size="sm" variant="ghost" onClick={reset}>
|
? t("button.saving", { ns: "common" })
|
||||||
{t("button.reset", { ns: "common" })}
|
: t("button.save", { ns: "common" })}
|
||||||
</Button>
|
</Button>
|
||||||
{isAdmin && (
|
)}
|
||||||
<Button size="sm" onClick={save} disabled={isSaving}>
|
|
||||||
{isSaving
|
|
||||||
? t("button.saving", { ns: "common" })
|
|
||||||
: t("button.save", { ns: "common" })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</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 { TrackingDetailsSequence } from "@/types/timeline";
|
||||||
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||||
import { useDetailStream } from "@/context/detail-stream-context";
|
import { useDetailStream } from "@/context/detail-stream-context";
|
||||||
@ -33,6 +33,7 @@ import { MdAutoAwesome } from "react-icons/md";
|
|||||||
import { isPWA } from "@/utils/isPWA";
|
import { isPWA } from "@/utils/isPWA";
|
||||||
import { isInIframe } from "@/utils/isIFrame";
|
import { isInIframe } from "@/utils/isIFrame";
|
||||||
import { GenAISummaryDialog } from "../overlay/chip/GenAISummaryChip";
|
import { GenAISummaryDialog } from "../overlay/chip/GenAISummaryChip";
|
||||||
|
import { Separator } from "../ui/separator";
|
||||||
|
|
||||||
type DetailStreamProps = {
|
type DetailStreamProps = {
|
||||||
reviewItems?: ReviewSegment[];
|
reviewItems?: ReviewSegment[];
|
||||||
@ -49,7 +50,8 @@ export default function DetailStream({
|
|||||||
}: DetailStreamProps) {
|
}: DetailStreamProps) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const { t } = useTranslation("views/events");
|
const { t } = useTranslation("views/events");
|
||||||
const { annotationOffset } = useDetailStream();
|
const { annotationOffset, selectedObjectIds, setSelectedObjectIds } =
|
||||||
|
useDetailStream();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [activeReviewId, setActiveReviewId] = useState<string | undefined>(
|
const [activeReviewId, setActiveReviewId] = useState<string | undefined>(
|
||||||
@ -67,9 +69,69 @@ export default function DetailStream({
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSeekCheckPlaying = (timestamp: number) => {
|
// When the settings panel opens, pin to the nearest review with detections
|
||||||
onSeek(timestamp, isPlaying);
|
// 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.
|
// Ensure we initialize the active review when reviewItems first arrive.
|
||||||
// This helps when the component mounts while the video is already
|
// 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">
|
<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
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="scrollbar-container flex-1 overflow-y-auto overflow-x-hidden pb-14"
|
className="scrollbar-container flex-1 overflow-y-auto overflow-x-hidden pb-14"
|
||||||
@ -267,8 +335,9 @@ export default function DetailStream({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{controlsExpanded && (
|
{controlsExpanded && (
|
||||||
<div className="space-y-3 px-3 pb-3">
|
<div className="space-y-4 px-3 pb-5 pt-2">
|
||||||
<AnnotationOffsetSlider />
|
<AnnotationOffsetSlider />
|
||||||
|
<Separator />
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-sm font-medium">
|
<label className="text-sm font-medium">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user