2025-10-16 16:55:10 +03:00
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
2025-10-16 16:24:14 +03:00
|
|
|
import { ObjectLifecycleSequence } from "@/types/timeline";
|
|
|
|
|
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
|
|
|
|
import { useDetailStream } from "@/context/detail-stream-context";
|
|
|
|
|
import scrollIntoView from "scroll-into-view-if-needed";
|
|
|
|
|
import useUserInteraction from "@/hooks/use-user-interaction";
|
2025-10-16 23:15:23 +03:00
|
|
|
import {
|
|
|
|
|
formatUnixTimestampToDateTime,
|
2025-10-24 15:50:06 +03:00
|
|
|
getDurationFromTimestamps,
|
2025-10-16 23:15:23 +03:00
|
|
|
} from "@/utils/dateUtil";
|
2025-10-16 16:24:14 +03:00
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
|
import AnnotationOffsetSlider from "@/components/overlay/detail/AnnotationOffsetSlider";
|
|
|
|
|
import { FrigateConfig } from "@/types/frigateConfig";
|
|
|
|
|
import useSWR from "swr";
|
|
|
|
|
import ActivityIndicator from "../indicators/activity-indicator";
|
|
|
|
|
import { Event } from "@/types/event";
|
|
|
|
|
import { getIconForLabel } from "@/utils/iconUtil";
|
2025-10-16 23:15:23 +03:00
|
|
|
import { ReviewSegment } from "@/types/review";
|
2025-10-24 15:50:06 +03:00
|
|
|
import { LuChevronDown, LuCircle, LuChevronRight } from "react-icons/lu";
|
2025-10-16 16:24:14 +03:00
|
|
|
import { getTranslatedLabel } from "@/utils/i18n";
|
|
|
|
|
import EventMenu from "@/components/timeline/EventMenu";
|
|
|
|
|
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
2025-10-24 15:50:06 +03:00
|
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
2025-10-26 15:27:07 +03:00
|
|
|
import { Link } from "react-router-dom";
|
2025-10-16 16:24:14 +03:00
|
|
|
|
|
|
|
|
type DetailStreamProps = {
|
|
|
|
|
reviewItems?: ReviewSegment[];
|
|
|
|
|
currentTime: number;
|
2025-10-24 15:50:06 +03:00
|
|
|
isPlaying?: boolean;
|
2025-10-16 23:15:23 +03:00
|
|
|
onSeek: (timestamp: number, play?: boolean) => void;
|
2025-10-16 16:24:14 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default function DetailStream({
|
|
|
|
|
reviewItems,
|
|
|
|
|
currentTime,
|
2025-10-24 15:50:06 +03:00
|
|
|
isPlaying = false,
|
2025-10-16 16:24:14 +03:00
|
|
|
onSeek,
|
|
|
|
|
}: DetailStreamProps) {
|
|
|
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
|
|
|
const { t } = useTranslation("views/events");
|
|
|
|
|
const { annotationOffset } = useDetailStream();
|
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
const [activeReviewId, setActiveReviewId] = useState<string | undefined>(
|
|
|
|
|
undefined,
|
|
|
|
|
);
|
|
|
|
|
const { userInteracting, setProgrammaticScroll } = useUserInteraction({
|
|
|
|
|
elementRef: scrollRef,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const effectiveTime = currentTime + annotationOffset / 1000;
|
|
|
|
|
const [upload, setUpload] = useState<Event | undefined>(undefined);
|
|
|
|
|
|
2025-10-24 15:50:06 +03:00
|
|
|
const onSeekCheckPlaying = (timestamp: number) => {
|
|
|
|
|
onSeek(timestamp, isPlaying);
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-16 16:24:14 +03:00
|
|
|
// Ensure we initialize the active review when reviewItems first arrive.
|
|
|
|
|
// This helps when the component mounts while the video is already
|
|
|
|
|
// playing — it guarantees the matching review is highlighted right
|
|
|
|
|
// away instead of waiting for a future effectiveTime change.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!reviewItems || reviewItems.length === 0) return;
|
|
|
|
|
if (activeReviewId) return;
|
|
|
|
|
|
|
|
|
|
let target: ReviewSegment | undefined;
|
|
|
|
|
let closest: { r: ReviewSegment; diff: number } | undefined;
|
|
|
|
|
|
|
|
|
|
for (const r of reviewItems) {
|
2025-10-16 23:15:23 +03:00
|
|
|
const start = r.start_time ?? 0;
|
|
|
|
|
const end = r.end_time ?? r.start_time ?? start;
|
2025-10-16 16:24:14 +03:00
|
|
|
if (effectiveTime >= start && effectiveTime <= end) {
|
|
|
|
|
target = r;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
const mid = (start + end) / 2;
|
|
|
|
|
const diff = Math.abs(effectiveTime - mid);
|
|
|
|
|
if (!closest || diff < closest.diff) closest = { r, diff };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!target && closest) target = closest.r;
|
|
|
|
|
|
|
|
|
|
if (target) {
|
2025-10-16 23:15:23 +03:00
|
|
|
const start = target.start_time ?? 0;
|
2025-10-16 16:24:14 +03:00
|
|
|
setActiveReviewId(
|
|
|
|
|
`review-${target.id ?? target.start_time ?? Math.floor(start)}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-10-16 23:15:23 +03:00
|
|
|
}, [reviewItems, activeReviewId, effectiveTime]);
|
2025-10-16 16:24:14 +03:00
|
|
|
|
|
|
|
|
// Auto-scroll to current time
|
|
|
|
|
useEffect(() => {
|
2025-10-24 15:50:06 +03:00
|
|
|
if (!scrollRef.current || userInteracting || !isPlaying) return;
|
2025-10-16 16:24:14 +03:00
|
|
|
// Prefer the review whose range contains the effectiveTime. If none
|
|
|
|
|
// contains it, pick the nearest review (by mid-point distance). This is
|
|
|
|
|
// robust to unordered reviewItems and avoids always picking the last
|
|
|
|
|
// element.
|
|
|
|
|
const items = reviewItems ?? [];
|
|
|
|
|
if (items.length === 0) return;
|
|
|
|
|
|
|
|
|
|
let target: ReviewSegment | undefined;
|
|
|
|
|
let closest: { r: ReviewSegment; diff: number } | undefined;
|
|
|
|
|
|
|
|
|
|
for (const r of items) {
|
2025-10-16 23:15:23 +03:00
|
|
|
const start = r.start_time ?? 0;
|
|
|
|
|
const end = r.end_time ?? r.start_time ?? start;
|
2025-10-16 16:24:14 +03:00
|
|
|
if (effectiveTime >= start && effectiveTime <= end) {
|
|
|
|
|
target = r;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
const mid = (start + end) / 2;
|
|
|
|
|
const diff = Math.abs(effectiveTime - mid);
|
|
|
|
|
if (!closest || diff < closest.diff) closest = { r, diff };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!target && closest) target = closest.r;
|
|
|
|
|
|
|
|
|
|
if (target) {
|
2025-10-16 23:15:23 +03:00
|
|
|
const start = target.start_time ?? 0;
|
2025-10-16 16:24:14 +03:00
|
|
|
const id = `review-${target.id ?? target.start_time ?? Math.floor(start)}`;
|
|
|
|
|
const element = scrollRef.current.querySelector(
|
|
|
|
|
`[data-review-id="${id}"]`,
|
|
|
|
|
) as HTMLElement;
|
|
|
|
|
if (element) {
|
2025-10-24 15:50:06 +03:00
|
|
|
// Only scroll if element is completely out of view
|
|
|
|
|
const containerRect = scrollRef.current.getBoundingClientRect();
|
|
|
|
|
const elementRect = element.getBoundingClientRect();
|
|
|
|
|
const isFullyInvisible =
|
|
|
|
|
elementRect.bottom < containerRect.top ||
|
|
|
|
|
elementRect.top > containerRect.bottom;
|
|
|
|
|
|
|
|
|
|
if (isFullyInvisible) {
|
|
|
|
|
setProgrammaticScroll();
|
|
|
|
|
scrollIntoView(element, {
|
|
|
|
|
scrollMode: "if-needed",
|
|
|
|
|
behavior: "smooth",
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-10-16 16:24:14 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [
|
|
|
|
|
reviewItems,
|
|
|
|
|
effectiveTime,
|
|
|
|
|
annotationOffset,
|
|
|
|
|
userInteracting,
|
|
|
|
|
setProgrammaticScroll,
|
2025-10-24 15:50:06 +03:00
|
|
|
isPlaying,
|
2025-10-16 16:24:14 +03:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Auto-select active review based on effectiveTime (if inside a review range)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!reviewItems || reviewItems.length === 0) return;
|
|
|
|
|
for (const r of reviewItems) {
|
2025-10-16 23:15:23 +03:00
|
|
|
const start = r.start_time ?? 0;
|
|
|
|
|
const end = r.end_time ?? r.start_time ?? start;
|
2025-10-16 16:24:14 +03:00
|
|
|
if (effectiveTime >= start && effectiveTime <= end) {
|
|
|
|
|
setActiveReviewId(
|
|
|
|
|
`review-${r.id ?? r.start_time ?? Math.floor(start)}`,
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-16 23:15:23 +03:00
|
|
|
}, [effectiveTime, reviewItems]);
|
2025-10-16 16:24:14 +03:00
|
|
|
|
|
|
|
|
if (!config) {
|
|
|
|
|
return <ActivityIndicator />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<FrigatePlusDialog
|
|
|
|
|
upload={upload}
|
|
|
|
|
onClose={() => setUpload(undefined)}
|
2025-10-26 01:15:36 +03:00
|
|
|
onEventUploaded={() => {
|
|
|
|
|
if (upload) {
|
|
|
|
|
upload.plus_id = "new_upload";
|
|
|
|
|
}
|
|
|
|
|
}}
|
2025-10-16 16:24:14 +03:00
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
ref={scrollRef}
|
2025-10-24 15:50:06 +03:00
|
|
|
className="scrollbar-container h-[calc(100vh-70px)] overflow-y-auto"
|
2025-10-16 16:24:14 +03:00
|
|
|
>
|
2025-10-24 15:50:06 +03:00
|
|
|
<div className="space-y-4 py-2">
|
2025-10-16 16:24:14 +03:00
|
|
|
{reviewItems?.length === 0 ? (
|
|
|
|
|
<div className="py-8 text-center text-muted-foreground">
|
|
|
|
|
{t("detail.noDataFound")}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
reviewItems?.map((review: ReviewSegment) => {
|
2025-10-16 23:15:23 +03:00
|
|
|
const id = `review-${review.id ?? review.start_time ?? Math.floor(review.start_time ?? 0)}`;
|
2025-10-16 16:24:14 +03:00
|
|
|
return (
|
|
|
|
|
<ReviewGroup
|
|
|
|
|
key={id}
|
|
|
|
|
id={id}
|
|
|
|
|
review={review}
|
|
|
|
|
config={config}
|
2025-10-24 15:50:06 +03:00
|
|
|
onSeek={onSeekCheckPlaying}
|
2025-10-16 16:24:14 +03:00
|
|
|
effectiveTime={effectiveTime}
|
|
|
|
|
isActive={activeReviewId == id}
|
|
|
|
|
onActivate={() => setActiveReviewId(id)}
|
|
|
|
|
onOpenUpload={(e) => setUpload(e)}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<AnnotationOffsetSlider />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ReviewGroupProps = {
|
|
|
|
|
review: ReviewSegment;
|
|
|
|
|
id: string;
|
|
|
|
|
config: FrigateConfig;
|
2025-10-16 23:15:23 +03:00
|
|
|
onSeek: (timestamp: number, play?: boolean) => void;
|
2025-10-16 16:24:14 +03:00
|
|
|
isActive?: boolean;
|
|
|
|
|
onActivate?: () => void;
|
|
|
|
|
onOpenUpload?: (e: Event) => void;
|
|
|
|
|
effectiveTime?: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function ReviewGroup({
|
|
|
|
|
review,
|
|
|
|
|
id,
|
|
|
|
|
config,
|
|
|
|
|
onSeek,
|
|
|
|
|
isActive = false,
|
|
|
|
|
onActivate,
|
|
|
|
|
onOpenUpload,
|
|
|
|
|
effectiveTime,
|
|
|
|
|
}: ReviewGroupProps) {
|
|
|
|
|
const { t } = useTranslation("views/events");
|
2025-10-24 15:50:06 +03:00
|
|
|
const [open, setOpen] = useState(false);
|
2025-10-16 23:15:23 +03:00
|
|
|
const start = review.start_time ?? 0;
|
2025-10-16 16:24:14 +03:00
|
|
|
|
|
|
|
|
const displayTime = formatUnixTimestampToDateTime(start, {
|
|
|
|
|
timezone: config.ui.timezone,
|
|
|
|
|
date_format:
|
|
|
|
|
config.ui.time_format == "24hour"
|
2025-10-16 23:15:23 +03:00
|
|
|
? t("time.formattedTimestampHourMinuteSecond.24hour", { ns: "common" })
|
|
|
|
|
: t("time.formattedTimestampHourMinuteSecond.12hour", { ns: "common" }),
|
2025-10-16 16:24:14 +03:00
|
|
|
time_style: "medium",
|
|
|
|
|
date_style: "medium",
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-18 21:19:21 +03:00
|
|
|
const shouldFetchEvents = review?.data?.detections?.length > 0;
|
|
|
|
|
|
2025-10-24 15:50:06 +03:00
|
|
|
const { data: fetchedEvents, isValidating } = useSWR<Event[]>(
|
2025-10-18 21:19:21 +03:00
|
|
|
shouldFetchEvents
|
2025-10-16 16:24:14 +03:00
|
|
|
? ["event_ids", { ids: review.data.detections.join(",") }]
|
|
|
|
|
: null,
|
|
|
|
|
);
|
|
|
|
|
|
2025-10-18 21:19:21 +03:00
|
|
|
const rawIconLabels: string[] = [
|
|
|
|
|
...(fetchedEvents
|
2025-10-26 01:15:36 +03:00
|
|
|
? fetchedEvents.map((e) =>
|
|
|
|
|
e.sub_label ? e.label + "-verified" : e.label,
|
|
|
|
|
)
|
2025-10-18 21:19:21 +03:00
|
|
|
: (review.data?.objects ?? [])),
|
|
|
|
|
...(review.data?.audio ?? []),
|
|
|
|
|
];
|
2025-10-16 16:24:14 +03:00
|
|
|
|
|
|
|
|
// limit to 5 icons
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
const iconLabels: string[] = [];
|
|
|
|
|
for (const lbl of rawIconLabels) {
|
|
|
|
|
if (!seen.has(lbl)) {
|
|
|
|
|
seen.add(lbl);
|
|
|
|
|
iconLabels.push(lbl);
|
|
|
|
|
if (iconLabels.length >= 5) break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 16:55:10 +03:00
|
|
|
const reviewInfo = useMemo(() => {
|
2025-10-24 15:50:06 +03:00
|
|
|
const objectCount = fetchedEvents
|
|
|
|
|
? fetchedEvents.length
|
|
|
|
|
: (review.data.objects ?? []).length;
|
2025-10-16 16:55:10 +03:00
|
|
|
|
2025-10-24 15:50:06 +03:00
|
|
|
return `${objectCount} ${t("detail.trackedObject", { count: objectCount })}`;
|
2025-10-16 16:55:10 +03:00
|
|
|
}, [review, t, fetchedEvents]);
|
|
|
|
|
|
2025-10-24 15:50:06 +03:00
|
|
|
const reviewDuration = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
getDurationFromTimestamps(
|
|
|
|
|
review.start_time,
|
|
|
|
|
review.end_time ?? null,
|
|
|
|
|
true,
|
|
|
|
|
),
|
|
|
|
|
[review.start_time, review.end_time],
|
|
|
|
|
);
|
2025-10-16 23:15:23 +03:00
|
|
|
|
2025-10-16 16:24:14 +03:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
data-review-id={id}
|
2025-10-25 01:30:12 +03:00
|
|
|
className="cursor-pointer rounded-lg bg-secondary py-3"
|
2025-10-16 16:24:14 +03:00
|
|
|
>
|
|
|
|
|
<div
|
2025-10-25 01:30:12 +03:00
|
|
|
className={cn(
|
|
|
|
|
"flex items-start",
|
|
|
|
|
open && "border-b border-secondary-highlight pb-4",
|
|
|
|
|
)}
|
2025-10-16 16:24:14 +03:00
|
|
|
onClick={() => {
|
|
|
|
|
onActivate?.();
|
|
|
|
|
onSeek(start);
|
|
|
|
|
}}
|
|
|
|
|
>
|
2025-10-25 01:30:12 +03:00
|
|
|
<div className="ml-4 mr-2 mt-1.5 flex flex-row items-start">
|
|
|
|
|
<LuCircle
|
|
|
|
|
className={cn(
|
|
|
|
|
"size-3",
|
|
|
|
|
isActive
|
|
|
|
|
? "fill-selected text-selected"
|
|
|
|
|
: "fill-muted duration-500 dark:fill-secondary-highlight dark:text-secondary-highlight",
|
2025-10-16 23:15:23 +03:00
|
|
|
)}
|
2025-10-25 01:30:12 +03:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mr-3 flex w-full justify-between">
|
|
|
|
|
<div className="ml-1 flex flex-col items-start gap-1.5">
|
|
|
|
|
<div className="flex flex-row gap-3">
|
|
|
|
|
<div className="text-sm font-medium">{displayTime}</div>
|
2025-10-26 01:15:36 +03:00
|
|
|
<div className="relative flex items-center gap-2 text-white">
|
2025-10-25 01:30:12 +03:00
|
|
|
{iconLabels.slice(0, 5).map((lbl, idx) => (
|
|
|
|
|
<div
|
|
|
|
|
key={`${lbl}-${idx}`}
|
|
|
|
|
className="rounded-full bg-muted-foreground p-1"
|
|
|
|
|
>
|
|
|
|
|
{getIconForLabel(lbl, "size-3 text-white")}
|
2025-10-24 15:50:06 +03:00
|
|
|
</div>
|
2025-10-25 01:30:12 +03:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col gap-0.5">
|
|
|
|
|
{review.data.metadata?.title && (
|
|
|
|
|
<div className="mb-1 text-sm text-primary-variant">
|
|
|
|
|
{review.data.metadata.title}
|
|
|
|
|
</div>
|
2025-10-24 15:50:06 +03:00
|
|
|
)}
|
2025-10-25 01:30:12 +03:00
|
|
|
<div className="flex flex-row items-center gap-1.5">
|
|
|
|
|
<div className="text-xs text-primary-variant">{reviewInfo}</div>
|
|
|
|
|
|
|
|
|
|
{reviewDuration && (
|
|
|
|
|
<>
|
|
|
|
|
<span className="text-[5px] text-primary-variant">•</span>
|
|
|
|
|
<div className="text-xs text-primary-variant">
|
|
|
|
|
{reviewDuration}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-10-24 15:50:06 +03:00
|
|
|
</div>
|
2025-10-16 16:24:14 +03:00
|
|
|
</div>
|
2025-10-25 01:30:12 +03:00
|
|
|
<div
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setOpen((v) => !v);
|
|
|
|
|
}}
|
|
|
|
|
className="ml-2 inline-flex items-center justify-center rounded p-1 hover:bg-secondary/10"
|
|
|
|
|
>
|
|
|
|
|
{open ? (
|
|
|
|
|
<LuChevronDown className="size-4 text-primary-variant" />
|
|
|
|
|
) : (
|
|
|
|
|
<LuChevronRight className="size-4 text-primary-variant" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-10-16 16:24:14 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-10-24 15:50:06 +03:00
|
|
|
{open && (
|
2025-10-25 01:30:12 +03:00
|
|
|
<div className="space-y-0.5">
|
2025-10-24 15:50:06 +03:00
|
|
|
{shouldFetchEvents && isValidating && !fetchedEvents ? (
|
2025-10-16 16:24:14 +03:00
|
|
|
<ActivityIndicator />
|
|
|
|
|
) : (
|
2025-10-24 15:50:06 +03:00
|
|
|
(fetchedEvents || []).map((event, index) => {
|
2025-10-16 16:24:14 +03:00
|
|
|
return (
|
2025-10-24 15:50:06 +03:00
|
|
|
<div
|
|
|
|
|
key={`event-${event.id}-${index}`}
|
|
|
|
|
className="border-b border-secondary-highlight pb-0.5 last:border-0 last:pb-0"
|
|
|
|
|
>
|
|
|
|
|
<EventList
|
|
|
|
|
key={event.id}
|
|
|
|
|
event={event}
|
|
|
|
|
effectiveTime={effectiveTime}
|
|
|
|
|
onSeek={onSeek}
|
|
|
|
|
onOpenUpload={onOpenUpload}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2025-10-16 16:24:14 +03:00
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
)}
|
2025-10-18 21:19:21 +03:00
|
|
|
{review.data.audio && review.data.audio.length > 0 && (
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
{review.data.audio.map((audioLabel) => (
|
|
|
|
|
<div
|
|
|
|
|
key={audioLabel}
|
|
|
|
|
className="rounded-md bg-secondary p-2 outline outline-[3px] -outline-offset-[2.8px] outline-transparent duration-500"
|
|
|
|
|
>
|
2025-10-24 15:50:06 +03:00
|
|
|
<div className="ml-1.5 flex items-center gap-2 text-sm font-medium">
|
|
|
|
|
<div className="rounded-full bg-muted-foreground p-1">
|
2025-10-25 01:30:12 +03:00
|
|
|
{getIconForLabel(audioLabel, "size-3 text-white")}
|
2025-10-24 15:50:06 +03:00
|
|
|
</div>
|
2025-10-18 21:19:21 +03:00
|
|
|
<span>{getTranslatedLabel(audioLabel)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-10-16 16:24:14 +03:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-24 15:50:06 +03:00
|
|
|
type EventListProps = {
|
2025-10-16 16:24:14 +03:00
|
|
|
event: Event;
|
|
|
|
|
effectiveTime?: number;
|
2025-10-16 23:15:23 +03:00
|
|
|
onSeek: (ts: number, play?: boolean) => void;
|
2025-10-16 16:24:14 +03:00
|
|
|
onOpenUpload?: (e: Event) => void;
|
|
|
|
|
};
|
2025-10-24 15:50:06 +03:00
|
|
|
function EventList({
|
2025-10-16 16:24:14 +03:00
|
|
|
event,
|
|
|
|
|
effectiveTime,
|
|
|
|
|
onSeek,
|
|
|
|
|
onOpenUpload,
|
2025-10-24 15:50:06 +03:00
|
|
|
}: EventListProps) {
|
2025-10-16 16:24:14 +03:00
|
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
|
|
|
|
2025-10-26 01:15:36 +03:00
|
|
|
const { selectedObjectIds, toggleObjectSelection } = useDetailStream();
|
|
|
|
|
|
|
|
|
|
const isSelected = selectedObjectIds.includes(event.id);
|
|
|
|
|
|
|
|
|
|
const label = event.sub_label || getTranslatedLabel(event.label);
|
2025-10-16 16:24:14 +03:00
|
|
|
|
2025-10-24 15:50:06 +03:00
|
|
|
const handleObjectSelect = (event: Event | undefined) => {
|
|
|
|
|
if (event) {
|
2025-10-26 01:15:36 +03:00
|
|
|
// onSeek(event.start_time ?? 0);
|
|
|
|
|
toggleObjectSelection(event.id);
|
2025-10-24 15:50:06 +03:00
|
|
|
} else {
|
2025-10-26 01:15:36 +03:00
|
|
|
toggleObjectSelection(undefined);
|
2025-10-24 15:50:06 +03:00
|
|
|
}
|
|
|
|
|
};
|
2025-10-16 16:24:14 +03:00
|
|
|
|
2025-10-26 01:15:36 +03:00
|
|
|
// Clear selection when effectiveTime has passed this event's end_time
|
2025-10-16 16:24:14 +03:00
|
|
|
useEffect(() => {
|
2025-10-26 01:15:36 +03:00
|
|
|
if (isSelected && effectiveTime && event.end_time) {
|
2025-10-18 21:19:21 +03:00
|
|
|
if (effectiveTime >= event.end_time) {
|
2025-10-26 01:15:36 +03:00
|
|
|
toggleObjectSelection(event.id);
|
2025-10-16 16:24:14 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [
|
2025-10-26 01:15:36 +03:00
|
|
|
isSelected,
|
2025-10-16 16:24:14 +03:00
|
|
|
event.id,
|
|
|
|
|
event.end_time,
|
|
|
|
|
effectiveTime,
|
2025-10-26 01:15:36 +03:00
|
|
|
toggleObjectSelection,
|
2025-10-16 16:24:14 +03:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return (
|
2025-10-24 15:50:06 +03:00
|
|
|
<>
|
2025-10-16 16:24:14 +03:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
2025-10-25 01:30:12 +03:00
|
|
|
"rounded-md bg-secondary p-2",
|
2025-10-26 01:15:36 +03:00
|
|
|
isSelected
|
2025-10-25 01:30:12 +03:00
|
|
|
? "bg-secondary-highlight"
|
2025-10-16 16:24:14 +03:00
|
|
|
: "outline-transparent duration-500",
|
2025-10-26 01:15:36 +03:00
|
|
|
!isSelected &&
|
2025-10-18 21:19:21 +03:00
|
|
|
(effectiveTime ?? 0) >= (event.start_time ?? 0) - 0.5 &&
|
|
|
|
|
(effectiveTime ?? 0) <=
|
|
|
|
|
(event.end_time ?? event.start_time ?? 0) + 0.5 &&
|
2025-10-24 15:50:06 +03:00
|
|
|
"bg-secondary-highlight",
|
2025-10-16 16:24:14 +03:00
|
|
|
)}
|
|
|
|
|
>
|
2025-10-26 01:15:36 +03:00
|
|
|
<div className="ml-1.5 flex w-full items-end justify-between">
|
|
|
|
|
<div className="flex flex-1 items-center gap-2 text-sm font-medium">
|
2025-10-24 15:50:06 +03:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
2025-10-26 01:15:36 +03:00
|
|
|
"relative rounded-full p-1 text-white",
|
|
|
|
|
isSelected ? "bg-selected" : "bg-muted-foreground",
|
2025-10-24 15:50:06 +03:00
|
|
|
)}
|
2025-10-26 01:15:36 +03:00
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
handleObjectSelect(isSelected ? undefined : event);
|
|
|
|
|
}}
|
2025-10-24 15:50:06 +03:00
|
|
|
>
|
2025-10-26 01:15:36 +03:00
|
|
|
{getIconForLabel(
|
|
|
|
|
event.sub_label ? event.label + "-verified" : event.label,
|
|
|
|
|
"size-3 text-white",
|
|
|
|
|
)}
|
2025-10-24 15:50:06 +03:00
|
|
|
</div>
|
2025-10-26 01:15:36 +03:00
|
|
|
<div
|
|
|
|
|
className="flex flex-1 items-center gap-2"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onSeek(event.start_time ?? 0);
|
|
|
|
|
}}
|
|
|
|
|
role="button"
|
|
|
|
|
>
|
2025-10-26 15:27:07 +03:00
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<span className="capitalize">{label}</span>
|
|
|
|
|
{event.data?.recognized_license_plate && (
|
|
|
|
|
<>
|
|
|
|
|
<span className="text-secondary-foreground">·</span>
|
|
|
|
|
<div className="text-sm text-secondary-foreground">
|
|
|
|
|
<Link
|
|
|
|
|
to={`/explore?recognized_license_plate=${event.data.recognized_license_plate}`}
|
|
|
|
|
className="text-sm"
|
|
|
|
|
>
|
|
|
|
|
{event.data.recognized_license_plate}
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-10-16 16:24:14 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-10-26 01:15:36 +03:00
|
|
|
<div className="mr-2 flex flex-row justify-end">
|
2025-10-16 16:24:14 +03:00
|
|
|
<EventMenu
|
|
|
|
|
event={event}
|
|
|
|
|
config={config}
|
|
|
|
|
onOpenUpload={(e) => onOpenUpload?.(e)}
|
2025-10-26 01:15:36 +03:00
|
|
|
isSelected={isSelected}
|
|
|
|
|
onToggleSelection={handleObjectSelect}
|
2025-10-16 16:24:14 +03:00
|
|
|
/>
|
|
|
|
|
</div>
|
2025-10-24 15:50:06 +03:00
|
|
|
</div>
|
2025-10-16 16:24:14 +03:00
|
|
|
|
2025-10-24 15:50:06 +03:00
|
|
|
<div className="mt-2">
|
|
|
|
|
<ObjectTimeline
|
|
|
|
|
eventId={event.id}
|
|
|
|
|
onSeek={onSeek}
|
|
|
|
|
effectiveTime={effectiveTime}
|
|
|
|
|
/>
|
2025-10-16 16:24:14 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-10-24 15:50:06 +03:00
|
|
|
</>
|
2025-10-16 16:24:14 +03:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type LifecycleItemProps = {
|
2025-10-24 15:50:06 +03:00
|
|
|
item: ObjectLifecycleSequence;
|
2025-10-16 16:24:14 +03:00
|
|
|
isActive?: boolean;
|
2025-10-16 23:15:23 +03:00
|
|
|
onSeek?: (timestamp: number, play?: boolean) => void;
|
2025-10-24 15:50:06 +03:00
|
|
|
effectiveTime?: number;
|
2025-10-16 16:24:14 +03:00
|
|
|
};
|
|
|
|
|
|
2025-10-24 15:50:06 +03:00
|
|
|
function LifecycleItem({
|
|
|
|
|
item,
|
|
|
|
|
isActive,
|
|
|
|
|
onSeek,
|
|
|
|
|
effectiveTime,
|
|
|
|
|
}: LifecycleItemProps) {
|
2025-10-16 16:24:14 +03:00
|
|
|
const { t } = useTranslation("views/events");
|
|
|
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
|
|
|
|
2025-10-24 15:50:06 +03:00
|
|
|
const aspectRatio = useMemo(() => {
|
|
|
|
|
if (!config || !item?.camera) {
|
|
|
|
|
return 16 / 9;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
config.cameras[item.camera].detect.width /
|
|
|
|
|
config.cameras[item.camera].detect.height
|
|
|
|
|
);
|
|
|
|
|
}, [config, item]);
|
|
|
|
|
|
2025-10-16 16:24:14 +03:00
|
|
|
const formattedEventTimestamp = config
|
2025-10-24 15:50:06 +03:00
|
|
|
? formatUnixTimestampToDateTime(item?.timestamp ?? 0, {
|
2025-10-16 16:24:14 +03:00
|
|
|
timezone: config.ui.timezone,
|
|
|
|
|
date_format:
|
|
|
|
|
config.ui.time_format == "24hour"
|
|
|
|
|
? t("time.formattedTimestampHourMinuteSecond.24hour", {
|
|
|
|
|
ns: "common",
|
|
|
|
|
})
|
|
|
|
|
: t("time.formattedTimestampHourMinuteSecond.12hour", {
|
|
|
|
|
ns: "common",
|
|
|
|
|
}),
|
|
|
|
|
time_style: "medium",
|
|
|
|
|
date_style: "medium",
|
|
|
|
|
})
|
|
|
|
|
: "";
|
|
|
|
|
|
2025-10-24 15:50:06 +03:00
|
|
|
const ratio =
|
|
|
|
|
Array.isArray(item?.data.box) && item?.data.box.length >= 4
|
|
|
|
|
? (aspectRatio * (item?.data.box[2] / item?.data.box[3])).toFixed(2)
|
|
|
|
|
: "N/A";
|
|
|
|
|
const areaPx =
|
|
|
|
|
Array.isArray(item?.data.box) && item?.data.box.length >= 4
|
|
|
|
|
? Math.round(
|
|
|
|
|
(config?.cameras[item?.camera]?.detect?.width ?? 0) *
|
|
|
|
|
(config?.cameras[item?.camera]?.detect?.height ?? 0) *
|
|
|
|
|
(item?.data.box[2] * item?.data.box[3]),
|
|
|
|
|
)
|
|
|
|
|
: undefined;
|
|
|
|
|
const areaPct =
|
|
|
|
|
Array.isArray(item?.data.box) && item?.data.box.length >= 4
|
|
|
|
|
? (item?.data.box[2] * item?.data.box[3]).toFixed(4)
|
|
|
|
|
: undefined;
|
|
|
|
|
|
2025-10-16 16:24:14 +03:00
|
|
|
return (
|
|
|
|
|
<div
|
2025-10-16 23:15:23 +03:00
|
|
|
role="button"
|
|
|
|
|
onClick={() => {
|
2025-10-24 15:50:06 +03:00
|
|
|
onSeek?.(item.timestamp ?? 0, false);
|
2025-10-16 23:15:23 +03:00
|
|
|
}}
|
2025-10-16 16:24:14 +03:00
|
|
|
className={cn(
|
2025-10-16 23:15:23 +03:00
|
|
|
"flex cursor-pointer items-center gap-2 text-sm text-primary-variant",
|
|
|
|
|
isActive
|
|
|
|
|
? "font-semibold text-primary dark:font-normal"
|
|
|
|
|
: "duration-500",
|
2025-10-16 16:24:14 +03:00
|
|
|
)}
|
|
|
|
|
>
|
2025-10-24 15:50:06 +03:00
|
|
|
<div className="relative flex size-4 items-center justify-center">
|
|
|
|
|
<LuCircle
|
|
|
|
|
className={cn(
|
|
|
|
|
"relative z-10 ml-[1px] size-2.5 fill-secondary-foreground stroke-none",
|
|
|
|
|
(isActive || (effectiveTime ?? 0) >= (item?.timestamp ?? 0)) &&
|
|
|
|
|
"fill-selected duration-300",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
2025-10-16 16:24:14 +03:00
|
|
|
</div>
|
2025-10-26 15:27:07 +03:00
|
|
|
|
|
|
|
|
<div className="ml-0.5 flex min-w-0 flex-1">
|
2025-10-24 15:50:06 +03:00
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger>
|
2025-10-26 15:27:07 +03:00
|
|
|
<div className="flex items-start break-words text-left">
|
2025-10-25 01:30:12 +03:00
|
|
|
{getLifecycleItemDescription(item)}
|
|
|
|
|
</div>
|
2025-10-24 15:50:06 +03:00
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent>
|
|
|
|
|
<div className="mt-1 flex flex-wrap items-start gap-3 text-sm text-secondary-foreground">
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
<div className="flex items-start gap-1">
|
|
|
|
|
<span className="text-muted-foreground">
|
|
|
|
|
{t("objectLifecycle.lifecycleItemDesc.header.ratio")}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="font-medium text-foreground">{ratio}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-start gap-1">
|
|
|
|
|
<span className="text-muted-foreground">
|
|
|
|
|
{t("objectLifecycle.lifecycleItemDesc.header.area")}
|
|
|
|
|
</span>
|
|
|
|
|
{areaPx !== undefined && areaPct !== undefined ? (
|
|
|
|
|
<span className="font-medium text-foreground">
|
2025-10-26 15:27:07 +03:00
|
|
|
{areaPx} {t("pixels", { ns: "common" })}{" "}
|
|
|
|
|
<span className="text-secondary-foreground">·</span>{" "}
|
|
|
|
|
{areaPct}%
|
2025-10-24 15:50:06 +03:00
|
|
|
</span>
|
|
|
|
|
) : (
|
|
|
|
|
<span>N/A</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
2025-10-26 15:27:07 +03:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="ml-3 flex-shrink-0 px-1 text-right text-xs text-primary-variant">
|
|
|
|
|
<div className="whitespace-nowrap">{formattedEventTimestamp}</div>
|
2025-10-16 16:24:14 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch and render timeline entries for a single event id on demand.
|
|
|
|
|
function ObjectTimeline({
|
|
|
|
|
eventId,
|
|
|
|
|
onSeek,
|
|
|
|
|
effectiveTime,
|
|
|
|
|
}: {
|
|
|
|
|
eventId: string;
|
2025-10-16 23:15:23 +03:00
|
|
|
onSeek: (ts: number, play?: boolean) => void;
|
2025-10-16 16:24:14 +03:00
|
|
|
effectiveTime?: number;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation("views/events");
|
|
|
|
|
const { data: timeline, isValidating } = useSWR<ObjectLifecycleSequence[]>([
|
|
|
|
|
"timeline",
|
|
|
|
|
{
|
|
|
|
|
source_id: eventId,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
2025-10-24 15:50:06 +03:00
|
|
|
if (isValidating && (!timeline || timeline.length === 0)) {
|
|
|
|
|
return <ActivityIndicator className="ml-2 size-3" />;
|
2025-10-16 16:24:14 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!timeline || timeline.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="py-2 text-sm text-muted-foreground">
|
|
|
|
|
{t("detail.noObjectDetailData")}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-24 15:50:06 +03:00
|
|
|
// Calculate how far down the blue line should extend based on effectiveTime
|
|
|
|
|
const calculateLineHeight = () => {
|
|
|
|
|
if (!timeline || timeline.length === 0) return 0;
|
|
|
|
|
|
|
|
|
|
const currentTime = effectiveTime ?? 0;
|
|
|
|
|
|
|
|
|
|
// Find which events have been passed
|
|
|
|
|
let lastPassedIndex = -1;
|
|
|
|
|
for (let i = 0; i < timeline.length; i++) {
|
|
|
|
|
if (currentTime >= (timeline[i].timestamp ?? 0)) {
|
|
|
|
|
lastPassedIndex = i;
|
|
|
|
|
} else {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// No events passed yet
|
|
|
|
|
if (lastPassedIndex < 0) return 0;
|
|
|
|
|
|
|
|
|
|
// All events passed
|
|
|
|
|
if (lastPassedIndex >= timeline.length - 1) return 100;
|
|
|
|
|
|
|
|
|
|
// Calculate percentage based on item position, not time
|
|
|
|
|
// Each item occupies an equal visual space regardless of time gaps
|
|
|
|
|
const itemPercentage = 100 / (timeline.length - 1);
|
|
|
|
|
|
|
|
|
|
// Find progress between current and next event for smooth transition
|
|
|
|
|
const currentEvent = timeline[lastPassedIndex];
|
|
|
|
|
const nextEvent = timeline[lastPassedIndex + 1];
|
|
|
|
|
const currentTimestamp = currentEvent.timestamp ?? 0;
|
|
|
|
|
const nextTimestamp = nextEvent.timestamp ?? 0;
|
|
|
|
|
|
|
|
|
|
// Calculate interpolation between the two events
|
|
|
|
|
const timeBetween = nextTimestamp - currentTimestamp;
|
|
|
|
|
const timeElapsed = currentTime - currentTimestamp;
|
|
|
|
|
const interpolation = timeBetween > 0 ? timeElapsed / timeBetween : 0;
|
|
|
|
|
|
|
|
|
|
// Base position plus interpolated progress to next item
|
|
|
|
|
return Math.min(
|
|
|
|
|
100,
|
|
|
|
|
lastPassedIndex * itemPercentage + interpolation * itemPercentage,
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const blueLineHeight = calculateLineHeight();
|
|
|
|
|
|
2025-10-16 16:24:14 +03:00
|
|
|
return (
|
2025-10-24 15:50:06 +03:00
|
|
|
<div className="-pb-2 relative mx-2">
|
|
|
|
|
<div className="absolute -top-2 bottom-2 left-2 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" />
|
|
|
|
|
<div
|
|
|
|
|
className="absolute left-2 top-2 z-[5] max-h-[calc(100%-1rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
|
|
|
|
|
style={{ height: `${blueLineHeight}%` }}
|
|
|
|
|
/>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{timeline.map((event, idx) => {
|
|
|
|
|
const isActive =
|
|
|
|
|
Math.abs((effectiveTime ?? 0) - (event.timestamp ?? 0)) <= 0.5;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<LifecycleItem
|
|
|
|
|
key={`${event.timestamp}-${event.source_id ?? ""}-${idx}`}
|
|
|
|
|
item={event}
|
|
|
|
|
onSeek={onSeek}
|
|
|
|
|
isActive={isActive}
|
|
|
|
|
effectiveTime={effectiveTime}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
2025-10-16 16:24:14 +03:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|