import { useEffect, useMemo, useRef, useState } from "react"; 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"; import { formatUnixTimestampToDateTime, getDurationFromTimestamps, } from "@/utils/dateUtil"; 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"; import { ReviewSegment } from "@/types/review"; import { LuChevronDown, LuCircle, LuChevronRight } from "react-icons/lu"; import { getTranslatedLabel } from "@/utils/i18n"; import EventMenu from "@/components/timeline/EventMenu"; import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog"; import { cn } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; type DetailStreamProps = { reviewItems?: ReviewSegment[]; currentTime: number; isPlaying?: boolean; onSeek: (timestamp: number, play?: boolean) => void; }; export default function DetailStream({ reviewItems, currentTime, isPlaying = false, onSeek, }: DetailStreamProps) { const { data: config } = useSWR("config"); const { t } = useTranslation("views/events"); const { annotationOffset } = useDetailStream(); const scrollRef = useRef(null); const [activeReviewId, setActiveReviewId] = useState( undefined, ); const { userInteracting, setProgrammaticScroll } = useUserInteraction({ elementRef: scrollRef, }); const effectiveTime = currentTime + annotationOffset / 1000; const [upload, setUpload] = useState(undefined); const onSeekCheckPlaying = (timestamp: number) => { onSeek(timestamp, isPlaying); }; // 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) { const start = r.start_time ?? 0; const end = r.end_time ?? r.start_time ?? start; 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) { const start = target.start_time ?? 0; setActiveReviewId( `review-${target.id ?? target.start_time ?? Math.floor(start)}`, ); } }, [reviewItems, activeReviewId, effectiveTime]); // Auto-scroll to current time useEffect(() => { if (!scrollRef.current || userInteracting || !isPlaying) return; // 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) { const start = r.start_time ?? 0; const end = r.end_time ?? r.start_time ?? start; 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) { const start = target.start_time ?? 0; const id = `review-${target.id ?? target.start_time ?? Math.floor(start)}`; const element = scrollRef.current.querySelector( `[data-review-id="${id}"]`, ) as HTMLElement; if (element) { // 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", }); } } } }, [ reviewItems, effectiveTime, annotationOffset, userInteracting, setProgrammaticScroll, isPlaying, ]); // Auto-select active review based on effectiveTime (if inside a review range) useEffect(() => { if (!reviewItems || reviewItems.length === 0) return; for (const r of reviewItems) { const start = r.start_time ?? 0; const end = r.end_time ?? r.start_time ?? start; if (effectiveTime >= start && effectiveTime <= end) { setActiveReviewId( `review-${r.id ?? r.start_time ?? Math.floor(start)}`, ); return; } } }, [effectiveTime, reviewItems]); if (!config) { return ; } return (
setUpload(undefined)} onEventUploaded={() => { if (upload) { upload.plus_id = "new_upload"; } }} />
{reviewItems?.length === 0 ? (
{t("detail.noDataFound")}
) : ( reviewItems?.map((review: ReviewSegment) => { const id = `review-${review.id ?? review.start_time ?? Math.floor(review.start_time ?? 0)}`; return ( setActiveReviewId(id)} onOpenUpload={(e) => setUpload(e)} /> ); }) )}
); } type ReviewGroupProps = { review: ReviewSegment; id: string; config: FrigateConfig; onSeek: (timestamp: number, play?: boolean) => void; 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"); const [open, setOpen] = useState(false); const start = review.start_time ?? 0; const displayTime = formatUnixTimestampToDateTime(start, { 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", }); const shouldFetchEvents = review?.data?.detections?.length > 0; const { data: fetchedEvents, isValidating } = useSWR( shouldFetchEvents ? ["event_ids", { ids: review.data.detections.join(",") }] : null, ); const rawIconLabels: string[] = [ ...(fetchedEvents ? fetchedEvents.map((e) => e.sub_label ? e.label + "-verified" : e.label, ) : (review.data?.objects ?? [])), ...(review.data?.audio ?? []), ]; // limit to 5 icons const seen = new Set(); const iconLabels: string[] = []; for (const lbl of rawIconLabels) { if (!seen.has(lbl)) { seen.add(lbl); iconLabels.push(lbl); if (iconLabels.length >= 5) break; } } const reviewInfo = useMemo(() => { const objectCount = fetchedEvents ? fetchedEvents.length : (review.data.objects ?? []).length; return `${objectCount} ${t("detail.trackedObject", { count: objectCount })}`; }, [review, t, fetchedEvents]); const reviewDuration = useMemo( () => getDurationFromTimestamps( review.start_time, review.end_time ?? null, true, ), [review.start_time, review.end_time], ); return (
{ onActivate?.(); onSeek(start); }} >
{displayTime}
{iconLabels.slice(0, 5).map((lbl, idx) => (
{getIconForLabel(lbl, "size-3 text-white")}
))}
{review.data.metadata?.title && (
{review.data.metadata.title}
)}
{reviewInfo}
{reviewDuration && ( <>
{reviewDuration}
)}
{ e.stopPropagation(); setOpen((v) => !v); }} className="ml-2 inline-flex items-center justify-center rounded p-1 hover:bg-secondary/10" > {open ? ( ) : ( )}
{open && (
{shouldFetchEvents && isValidating && !fetchedEvents ? ( ) : ( (fetchedEvents || []).map((event, index) => { return (
); }) )} {review.data.audio && review.data.audio.length > 0 && (
{review.data.audio.map((audioLabel) => (
{getIconForLabel(audioLabel, "size-3 text-white")}
{getTranslatedLabel(audioLabel)}
))}
)}
)}
); } type EventListProps = { event: Event; effectiveTime?: number; onSeek: (ts: number, play?: boolean) => void; onOpenUpload?: (e: Event) => void; }; function EventList({ event, effectiveTime, onSeek, onOpenUpload, }: EventListProps) { const { data: config } = useSWR("config"); const { selectedObjectIds, toggleObjectSelection } = useDetailStream(); const isSelected = selectedObjectIds.includes(event.id); const label = event.sub_label || getTranslatedLabel(event.label); const handleObjectSelect = (event: Event | undefined) => { if (event) { // onSeek(event.start_time ?? 0); toggleObjectSelection(event.id); } else { toggleObjectSelection(undefined); } }; // Clear selection when effectiveTime has passed this event's end_time useEffect(() => { if (isSelected && effectiveTime && event.end_time) { if (effectiveTime >= event.end_time) { toggleObjectSelection(event.id); } } }, [ isSelected, event.id, event.end_time, effectiveTime, toggleObjectSelection, ]); return ( <>
= (event.start_time ?? 0) - 0.5 && (effectiveTime ?? 0) <= (event.end_time ?? event.start_time ?? 0) + 0.5 && "bg-secondary-highlight", )} >
{ e.stopPropagation(); handleObjectSelect(isSelected ? undefined : event); }} > {getIconForLabel( event.sub_label ? event.label + "-verified" : event.label, "size-3 text-white", )}
{ e.stopPropagation(); onSeek(event.start_time ?? 0); }} role="button" > {label} {event.data?.recognized_license_plate && ( <> ·{" "} {event.data.recognized_license_plate} )}
onOpenUpload?.(e)} isSelected={isSelected} onToggleSelection={handleObjectSelect} />
); } type LifecycleItemProps = { item: ObjectLifecycleSequence; isActive?: boolean; onSeek?: (timestamp: number, play?: boolean) => void; effectiveTime?: number; }; function LifecycleItem({ item, isActive, onSeek, effectiveTime, }: LifecycleItemProps) { const { t } = useTranslation("views/events"); const { data: config } = useSWR("config"); 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]); const formattedEventTimestamp = config ? formatUnixTimestampToDateTime(item?.timestamp ?? 0, { 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", }) : ""; 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; return (
{ onSeek?.(item.timestamp ?? 0, false); }} className={cn( "flex cursor-pointer items-center gap-2 text-sm text-primary-variant", isActive ? "font-semibold text-primary dark:font-normal" : "duration-500", )} >
= (item?.timestamp ?? 0)) && "fill-selected duration-300", )} />
{getLifecycleItemDescription(item)}
{t("objectLifecycle.lifecycleItemDesc.header.ratio")} {ratio}
{t("objectLifecycle.lifecycleItemDesc.header.area")} {areaPx !== undefined && areaPct !== undefined ? ( {areaPx} {t("pixels", { ns: "common" })} · {areaPct}% ) : ( N/A )}
{formattedEventTimestamp}
); } // Fetch and render timeline entries for a single event id on demand. function ObjectTimeline({ eventId, onSeek, effectiveTime, }: { eventId: string; onSeek: (ts: number, play?: boolean) => void; effectiveTime?: number; }) { const { t } = useTranslation("views/events"); const { data: timeline, isValidating } = useSWR([ "timeline", { source_id: eventId, }, ]); if (isValidating && (!timeline || timeline.length === 0)) { return ; } if (!timeline || timeline.length === 0) { return (
{t("detail.noObjectDetailData")}
); } // 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(); return (
{timeline.map((event, idx) => { const isActive = Math.abs((effectiveTime ?? 0) - (event.timestamp ?? 0)) <= 0.5; return ( ); })}
); }