2025-10-16 16:55:10 +03:00
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
2025-10-26 21:12:20 +03:00
|
|
|
import { TrackingDetailsSequence } from "@/types/timeline";
|
2025-10-16 16:24:14 +03:00
|
|
|
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-11-08 15:44:30 +03:00
|
|
|
import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
|
2025-11-06 19:22:52 +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-30 01:03:38 +03:00
|
|
|
import { Switch } from "@/components/ui/switch";
|
|
|
|
|
import { usePersistence } from "@/hooks/use-persistence";
|
|
|
|
|
import { isDesktop } from "react-device-detect";
|
2025-11-07 17:02:06 +03:00
|
|
|
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
2025-11-06 19:22:52 +03:00
|
|
|
import { PiSlidersHorizontalBold } from "react-icons/pi";
|
|
|
|
|
import { MdAutoAwesome } from "react-icons/md";
|
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,
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-01 17:19:30 +03:00
|
|
|
const effectiveTime = currentTime - annotationOffset / 1000;
|
2025-10-16 16:24:14 +03:00
|
|
|
const [upload, setUpload] = useState<Event | undefined>(undefined);
|
2025-10-30 01:03:38 +03:00
|
|
|
const [controlsExpanded, setControlsExpanded] = useState(false);
|
|
|
|
|
const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence(
|
|
|
|
|
"detailStreamActiveExpanded",
|
|
|
|
|
true,
|
|
|
|
|
);
|
2025-10-16 16:24:14 +03:00
|
|
|
|
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 (
|
2025-10-30 01:03:38 +03:00
|
|
|
<>
|
2025-10-16 16:24:14 +03:00
|
|
|
<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
|
|
|
/>
|
|
|
|
|
|
2025-10-30 01:03:38 +03:00
|
|
|
<div className="relative flex h-full flex-col">
|
|
|
|
|
<div
|
|
|
|
|
ref={scrollRef}
|
2025-11-07 16:53:27 +03:00
|
|
|
className="scrollbar-container flex-1 overflow-y-auto overflow-x-hidden pb-14"
|
2025-10-30 01:03:38 +03:00
|
|
|
>
|
|
|
|
|
<div className="space-y-4 py-2">
|
|
|
|
|
{reviewItems?.length === 0 ? (
|
|
|
|
|
<div className="py-8 text-center text-muted-foreground">
|
|
|
|
|
{t("detail.noDataFound")}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
reviewItems?.map((review: ReviewSegment) => {
|
|
|
|
|
const id = `review-${review.id ?? review.start_time ?? Math.floor(review.start_time ?? 0)}`;
|
|
|
|
|
return (
|
|
|
|
|
<ReviewGroup
|
|
|
|
|
key={id}
|
|
|
|
|
id={id}
|
|
|
|
|
review={review}
|
|
|
|
|
config={config}
|
|
|
|
|
onSeek={onSeekCheckPlaying}
|
|
|
|
|
effectiveTime={effectiveTime}
|
2025-11-01 17:19:30 +03:00
|
|
|
annotationOffset={annotationOffset}
|
2025-10-30 01:03:38 +03:00
|
|
|
isActive={activeReviewId == id}
|
|
|
|
|
onActivate={() => setActiveReviewId(id)}
|
|
|
|
|
onOpenUpload={(e) => setUpload(e)}
|
|
|
|
|
alwaysExpandActive={alwaysExpandActive}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"absolute bottom-0 left-0 right-0 z-30 rounded-t-md border border-secondary-highlight bg-background_alt shadow-md",
|
|
|
|
|
isDesktop && "border-b-0",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setControlsExpanded(!controlsExpanded)}
|
|
|
|
|
className="flex w-full items-center justify-between p-3"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-2 text-sm font-medium">
|
2025-11-06 19:22:52 +03:00
|
|
|
<PiSlidersHorizontalBold className="size-4" />
|
2025-10-30 01:03:38 +03:00
|
|
|
<span>{t("detail.settings")}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{controlsExpanded ? (
|
|
|
|
|
<LuChevronDown className="size-4 text-primary-variant" />
|
|
|
|
|
) : (
|
|
|
|
|
<LuChevronRight className="size-4 text-primary-variant" />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
{controlsExpanded && (
|
|
|
|
|
<div className="space-y-3 px-3 pb-3">
|
|
|
|
|
<AnnotationOffsetSlider />
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<label className="text-sm font-medium">
|
|
|
|
|
{t("detail.alwaysExpandActive.title")}
|
|
|
|
|
</label>
|
|
|
|
|
<Switch
|
|
|
|
|
checked={alwaysExpandActive}
|
|
|
|
|
onCheckedChange={setAlwaysExpandActive}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-muted-foreground">
|
|
|
|
|
{t("detail.alwaysExpandActive.desc")}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-10-16 16:24:14 +03:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-10-30 01:03:38 +03:00
|
|
|
</>
|
2025-10-16 16:24:14 +03:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2025-11-01 17:19:30 +03:00
|
|
|
annotationOffset: number;
|
2025-10-30 01:03:38 +03:00
|
|
|
alwaysExpandActive?: boolean;
|
2025-10-16 16:24:14 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function ReviewGroup({
|
|
|
|
|
review,
|
|
|
|
|
id,
|
|
|
|
|
config,
|
|
|
|
|
onSeek,
|
|
|
|
|
isActive = false,
|
|
|
|
|
onActivate,
|
|
|
|
|
onOpenUpload,
|
|
|
|
|
effectiveTime,
|
2025-11-01 17:19:30 +03:00
|
|
|
annotationOffset,
|
2025-10-30 01:03:38 +03:00
|
|
|
alwaysExpandActive = false,
|
2025-10-16 16:24:14 +03:00
|
|
|
}: 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-11-01 17:19:30 +03:00
|
|
|
// review.start_time is in detect time, convert to record for seeking
|
|
|
|
|
const startRecord = start + annotationOffset / 1000;
|
2025-10-16 16:24:14 +03:00
|
|
|
|
2025-10-30 01:03:38 +03:00
|
|
|
// Auto-expand when this review becomes active and alwaysExpandActive is enabled
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isActive && alwaysExpandActive) {
|
|
|
|
|
setOpen(true);
|
|
|
|
|
}
|
|
|
|
|
}, [isActive, alwaysExpandActive]);
|
|
|
|
|
|
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-11-12 02:23:30 +03:00
|
|
|
return `${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-11-04 17:45:45 +03:00
|
|
|
className={`mx-1 cursor-pointer rounded-lg bg-secondary px-0 py-3 outline outline-[2px] -outline-offset-[1.8px] ${
|
|
|
|
|
isActive
|
|
|
|
|
? "shadow-selected outline-selected"
|
|
|
|
|
: "outline-transparent duration-500"
|
|
|
|
|
}`}
|
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?.();
|
2025-11-01 17:19:30 +03:00
|
|
|
onSeek(startRecord);
|
2025-10-16 16:24:14 +03:00
|
|
|
}}
|
|
|
|
|
>
|
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(
|
2025-11-04 17:45:45 +03:00
|
|
|
"size-3 duration-500",
|
|
|
|
|
review.severity == "alert"
|
|
|
|
|
? "fill-severity_alert text-severity_alert"
|
|
|
|
|
: "fill-severity_detection text-severity_detection",
|
2025-10-16 23:15:23 +03:00
|
|
|
)}
|
2025-10-25 01:30:12 +03:00
|
|
|
/>
|
|
|
|
|
</div>
|
2025-11-08 15:44:30 +03:00
|
|
|
<div className="mr-3 grid w-full grid-cols-[1fr_auto] gap-2">
|
|
|
|
|
<div className="ml-1 flex min-w-0 flex-col gap-1.5">
|
2025-10-25 01:30:12 +03:00
|
|
|
<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 && (
|
2025-11-08 15:44:30 +03:00
|
|
|
<div className="mb-1 flex min-w-0 items-center gap-1 text-sm text-primary-variant">
|
2025-11-05 18:48:47 +03:00
|
|
|
<MdAutoAwesome className="size-3 shrink-0" />
|
|
|
|
|
<span className="truncate">{review.data.metadata.title}</span>
|
2025-10-25 01:30:12 +03:00
|
|
|
</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);
|
|
|
|
|
}}
|
2025-11-08 15:44:30 +03:00
|
|
|
className="inline-flex items-center justify-center self-center rounded p-1 hover:bg-secondary/10"
|
2025-10-25 01:30:12 +03:00
|
|
|
>
|
|
|
|
|
{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}
|
2025-11-05 17:49:31 +03:00
|
|
|
review={review}
|
2025-10-24 15:50:06 +03:00
|
|
|
effectiveTime={effectiveTime}
|
2025-11-01 17:19:30 +03:00
|
|
|
annotationOffset={annotationOffset}
|
2025-10-24 15:50:06 +03:00
|
|
|
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-11-12 02:23:30 +03:00
|
|
|
<span>{getTranslatedLabel(audioLabel, "audio")}</span>
|
2025-10-18 21:19:21 +03:00
|
|
|
</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;
|
2025-11-05 17:49:31 +03:00
|
|
|
review: ReviewSegment;
|
2025-10-16 16:24:14 +03:00
|
|
|
effectiveTime?: number;
|
2025-11-01 17:19:30 +03:00
|
|
|
annotationOffset: 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,
|
2025-11-05 17:49:31 +03:00
|
|
|
review,
|
2025-10-16 16:24:14 +03:00
|
|
|
effectiveTime,
|
2025-11-01 17:19:30 +03:00
|
|
|
annotationOffset,
|
2025-10-16 16:24:14 +03:00
|
|
|
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 21:12:20 +03:00
|
|
|
const { selectedObjectIds, setSelectedObjectIds, toggleObjectSelection } =
|
|
|
|
|
useDetailStream();
|
2025-10-26 01:15:36 +03:00
|
|
|
|
|
|
|
|
const isSelected = selectedObjectIds.includes(event.id);
|
|
|
|
|
|
2025-11-12 02:23:30 +03:00
|
|
|
const label =
|
|
|
|
|
event.sub_label || getTranslatedLabel(event.label, event.data.type);
|
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 21:12:20 +03:00
|
|
|
setSelectedObjectIds([]);
|
|
|
|
|
setSelectedObjectIds([event.id]);
|
2025-11-01 17:19:30 +03:00
|
|
|
// event.start_time is detect time, convert to record
|
|
|
|
|
const recordTime = event.start_time + annotationOffset / 1000;
|
|
|
|
|
onSeek(recordTime);
|
2025-10-24 15:50:06 +03:00
|
|
|
} else {
|
2025-10-26 21:12:20 +03:00
|
|
|
setSelectedObjectIds([]);
|
2025-10-24 15:50:06 +03:00
|
|
|
}
|
|
|
|
|
};
|
2025-10-16 16:24:14 +03:00
|
|
|
|
2025-10-26 21:12:20 +03:00
|
|
|
const handleTimelineClick = (ts: number, play?: boolean) => {
|
2025-11-01 17:19:30 +03:00
|
|
|
setSelectedObjectIds([]);
|
|
|
|
|
setSelectedObjectIds([event.id]);
|
2025-10-26 21:12:20 +03:00
|
|
|
onSeek(ts, play);
|
|
|
|
|
};
|
|
|
|
|
|
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
|
|
|
<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",
|
2025-10-26 21:12:20 +03:00
|
|
|
(effectiveTime ?? 0) >= (event.start_time ?? 0) - 0.5 &&
|
|
|
|
|
(effectiveTime ?? 0) <=
|
|
|
|
|
(event.end_time ?? event.start_time ?? 0) + 0.5
|
|
|
|
|
? "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();
|
2025-10-26 21:12:20 +03:00
|
|
|
handleObjectSelect(event);
|
2025-10-26 01:15:36 +03:00
|
|
|
}}
|
2025-10-26 21:12:20 +03:00
|
|
|
role="button"
|
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();
|
2025-10-26 21:12:20 +03:00
|
|
|
handleObjectSelect(event);
|
2025-10-26 01:15:36 +03:00
|
|
|
}}
|
|
|
|
|
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
|
2025-11-05 17:49:31 +03:00
|
|
|
review={review}
|
2025-10-24 15:50:06 +03:00
|
|
|
eventId={event.id}
|
2025-10-26 21:12:20 +03:00
|
|
|
onSeek={handleTimelineClick}
|
2025-10-24 15:50:06 +03:00
|
|
|
effectiveTime={effectiveTime}
|
2025-11-01 17:19:30 +03:00
|
|
|
annotationOffset={annotationOffset}
|
2025-10-26 21:12:20 +03:00
|
|
|
startTime={event.start_time}
|
|
|
|
|
endTime={event.end_time}
|
2025-10-24 15:50:06 +03:00
|
|
|
/>
|
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-26 21:12:20 +03:00
|
|
|
item: TrackingDetailsSequence;
|
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-11-01 17:19:30 +03:00
|
|
|
annotationOffset: number;
|
2025-10-26 21:12:20 +03:00
|
|
|
isTimelineActive?: boolean;
|
2025-10-16 16:24:14 +03:00
|
|
|
};
|
|
|
|
|
|
2025-10-24 15:50:06 +03:00
|
|
|
function LifecycleItem({
|
|
|
|
|
item,
|
|
|
|
|
isActive,
|
|
|
|
|
onSeek,
|
|
|
|
|
effectiveTime,
|
2025-11-01 17:19:30 +03:00
|
|
|
annotationOffset,
|
2025-10-26 21:12:20 +03:00
|
|
|
isTimelineActive = false,
|
2025-10-24 15:50:06 +03:00
|
|
|
}: 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-11-25 16:34:20 +03:00
|
|
|
const ratio = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
Array.isArray(item?.data.box) && item?.data.box.length >= 4
|
|
|
|
|
? (aspectRatio * (item?.data.box[2] / item?.data.box[3])).toFixed(2)
|
|
|
|
|
: "N/A",
|
|
|
|
|
[aspectRatio, item],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const areaPx = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
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,
|
|
|
|
|
[config, item],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const areaPct = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
Array.isArray(item?.data.box) && item?.data.box.length >= 4
|
|
|
|
|
? (item?.data.box[2] * item?.data.box[3]).toFixed(4)
|
|
|
|
|
: undefined,
|
|
|
|
|
[item],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const attributeAreaPx = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
Array.isArray(item?.data.attribute_box) &&
|
|
|
|
|
item?.data.attribute_box.length >= 4
|
|
|
|
|
? Math.round(
|
|
|
|
|
(config?.cameras[item?.camera]?.detect?.width ?? 0) *
|
|
|
|
|
(config?.cameras[item?.camera]?.detect?.height ?? 0) *
|
|
|
|
|
(item?.data.attribute_box[2] * item?.data.attribute_box[3]),
|
|
|
|
|
)
|
|
|
|
|
: undefined,
|
|
|
|
|
[config, item],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const attributeAreaPct = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
Array.isArray(item?.data.attribute_box) &&
|
|
|
|
|
item?.data.attribute_box.length >= 4
|
|
|
|
|
? (item?.data.attribute_box[2] * item?.data.attribute_box[3]).toFixed(4)
|
|
|
|
|
: undefined,
|
|
|
|
|
[item],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const score = useMemo(() => {
|
|
|
|
|
if (item?.data?.score !== undefined) {
|
|
|
|
|
return (item.data.score * 100).toFixed(0) + "%";
|
|
|
|
|
}
|
|
|
|
|
return "N/A";
|
|
|
|
|
}, [item?.data?.score]);
|
2025-10-24 15:50:06 +03:00
|
|
|
|
2025-10-16 16:24:14 +03:00
|
|
|
return (
|
|
|
|
|
<div
|
2025-10-16 23:15:23 +03:00
|
|
|
role="button"
|
|
|
|
|
onClick={() => {
|
2025-11-01 17:19:30 +03:00
|
|
|
const recordTimestamp = item.timestamp + annotationOffset / 1000;
|
|
|
|
|
onSeek?.(recordTimestamp, 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(
|
2025-10-26 21:12:20 +03:00
|
|
|
"relative z-10 size-2.5 fill-secondary-foreground stroke-none",
|
2025-10-24 15:50:06 +03:00
|
|
|
(isActive || (effectiveTime ?? 0) >= (item?.timestamp ?? 0)) &&
|
2025-10-26 21:12:20 +03:00
|
|
|
isTimelineActive &&
|
2025-10-24 15:50:06 +03:00
|
|
|
"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">
|
2025-11-25 16:34:20 +03:00
|
|
|
<div className="flex items-start gap-1">
|
|
|
|
|
<span className="text-muted-foreground">
|
|
|
|
|
{t("trackingDetails.lifecycleItemDesc.header.score")}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="font-medium text-foreground">{score}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-10-24 15:50:06 +03:00
|
|
|
<div className="flex items-start gap-1">
|
|
|
|
|
<span className="text-muted-foreground">
|
2025-10-26 21:12:20 +03:00
|
|
|
{t("trackingDetails.lifecycleItemDesc.header.ratio")}
|
2025-10-24 15:50:06 +03:00
|
|
|
</span>
|
|
|
|
|
<span className="font-medium text-foreground">{ratio}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-start gap-1">
|
|
|
|
|
<span className="text-muted-foreground">
|
2025-11-25 16:34:20 +03:00
|
|
|
{t("trackingDetails.lifecycleItemDesc.header.area")}{" "}
|
|
|
|
|
{attributeAreaPx !== undefined &&
|
|
|
|
|
attributeAreaPct !== undefined && (
|
|
|
|
|
<span className="text-muted-foreground">
|
|
|
|
|
({getTranslatedLabel(item.data.label)})
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2025-10-24 15:50:06 +03:00
|
|
|
</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>
|
2025-11-25 16:34:20 +03:00
|
|
|
|
|
|
|
|
{attributeAreaPx !== undefined &&
|
|
|
|
|
attributeAreaPct !== undefined && (
|
|
|
|
|
<div className="flex items-start gap-1">
|
|
|
|
|
<span className="text-muted-foreground">
|
|
|
|
|
{t("trackingDetails.lifecycleItemDesc.header.area")}{" "}
|
|
|
|
|
{attributeAreaPx !== undefined &&
|
|
|
|
|
attributeAreaPct !== undefined && (
|
|
|
|
|
<span className="text-muted-foreground">
|
|
|
|
|
({getTranslatedLabel(item.data.attribute)})
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="font-medium text-foreground">
|
|
|
|
|
{attributeAreaPx} {t("pixels", { ns: "common" })}{" "}
|
|
|
|
|
<span className="text-secondary-foreground">·</span>{" "}
|
|
|
|
|
{attributeAreaPct}%
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-10-24 15:50:06 +03:00
|
|
|
</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({
|
2025-11-05 17:49:31 +03:00
|
|
|
review,
|
2025-10-16 16:24:14 +03:00
|
|
|
eventId,
|
|
|
|
|
onSeek,
|
|
|
|
|
effectiveTime,
|
2025-11-01 17:19:30 +03:00
|
|
|
annotationOffset,
|
2025-10-26 21:12:20 +03:00
|
|
|
startTime,
|
|
|
|
|
endTime,
|
2025-10-16 16:24:14 +03:00
|
|
|
}: {
|
2025-11-05 17:49:31 +03:00
|
|
|
review: ReviewSegment;
|
2025-10-16 16:24:14 +03:00
|
|
|
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;
|
2025-11-01 17:19:30 +03:00
|
|
|
annotationOffset: number;
|
2025-10-26 21:12:20 +03:00
|
|
|
startTime?: number;
|
|
|
|
|
endTime?: number;
|
2025-10-16 16:24:14 +03:00
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation("views/events");
|
2025-11-05 17:49:31 +03:00
|
|
|
const { data: fullTimeline, isValidating } = useSWR<
|
|
|
|
|
TrackingDetailsSequence[]
|
|
|
|
|
>([
|
2025-10-16 16:24:14 +03:00
|
|
|
"timeline",
|
|
|
|
|
{
|
|
|
|
|
source_id: eventId,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
2025-11-07 17:02:06 +03:00
|
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
2025-11-05 17:49:31 +03:00
|
|
|
const timeline = useMemo(() => {
|
|
|
|
|
if (!fullTimeline) {
|
|
|
|
|
return fullTimeline;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-07 17:02:06 +03:00
|
|
|
return fullTimeline
|
|
|
|
|
.filter(
|
|
|
|
|
(t) =>
|
2025-11-08 15:44:30 +03:00
|
|
|
t.timestamp >= review.start_time - REVIEW_PADDING &&
|
|
|
|
|
(review.end_time == undefined ||
|
|
|
|
|
t.timestamp <= review.end_time + REVIEW_PADDING),
|
2025-11-07 17:02:06 +03:00
|
|
|
)
|
|
|
|
|
.map((event) => ({
|
|
|
|
|
...event,
|
|
|
|
|
data: {
|
|
|
|
|
...event.data,
|
|
|
|
|
zones_friendly_names: event.data?.zones?.map((zone) =>
|
|
|
|
|
resolveZoneName(config, zone),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
}, [config, fullTimeline, review]);
|
2025-11-05 17:49:31 +03:00
|
|
|
|
2025-10-24 15:50:06 +03:00
|
|
|
if (isValidating && (!timeline || timeline.length === 0)) {
|
2025-11-25 16:34:20 +03:00
|
|
|
return <ActivityIndicator className="ml-2.5 size-3" />;
|
2025-10-16 16:24:14 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!timeline || timeline.length === 0) {
|
|
|
|
|
return (
|
2025-11-07 16:53:27 +03:00
|
|
|
<div className="ml-8 text-sm text-muted-foreground">
|
2025-10-16 16:24:14 +03:00
|
|
|
{t("detail.noObjectDetailData")}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-26 21:12:20 +03:00
|
|
|
// Check if current time is within the event's start/stop range
|
|
|
|
|
const isWithinEventRange =
|
|
|
|
|
effectiveTime !== undefined &&
|
|
|
|
|
startTime !== undefined &&
|
|
|
|
|
endTime !== undefined &&
|
|
|
|
|
effectiveTime >= startTime &&
|
|
|
|
|
effectiveTime <= endTime;
|
|
|
|
|
|
2025-10-24 15:50:06 +03:00
|
|
|
// Calculate how far down the blue line should extend based on effectiveTime
|
|
|
|
|
const calculateLineHeight = () => {
|
2025-10-26 21:12:20 +03:00
|
|
|
if (!timeline || timeline.length === 0 || !isWithinEventRange) return 0;
|
2025-10-24 15:50:06 +03:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-26 21:12:20 +03:00
|
|
|
const activeLineHeight = calculateLineHeight();
|
2025-10-24 15:50:06 +03:00
|
|
|
|
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" />
|
2025-10-26 21:12:20 +03:00
|
|
|
{isWithinEventRange && (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"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: `${activeLineHeight}%` }}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2025-10-24 15:50:06 +03:00
|
|
|
<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}
|
2025-11-01 17:19:30 +03:00
|
|
|
annotationOffset={annotationOffset}
|
2025-10-26 21:12:20 +03:00
|
|
|
isTimelineActive={isWithinEventRange}
|
2025-10-24 15:50:06 +03:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
2025-10-16 16:24:14 +03:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|