import useSWR from "swr"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Event } from "@/types/event"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { Button } from "@/components/ui/button"; import { TrackingDetailsSequence } from "@/types/timeline"; import Heading from "@/components/ui/heading"; import { ReviewDetailPaneType } from "@/types/review"; import { FrigateConfig } from "@/types/frigateConfig"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { getIconForLabel } from "@/utils/iconUtil"; import { LuCircle, LuCircleDot, LuEar, LuFolderX, LuPlay, LuSettings, LuTruck, } from "react-icons/lu"; import { IoMdArrowRoundBack, IoMdExit } from "react-icons/io"; import { MdFaceUnlock, MdOutlineLocationOn, MdOutlinePictureInPictureAlt, } from "react-icons/md"; import { cn } from "@/lib/utils"; import { useApiHost } from "@/api"; import { isDesktop, isIOS, isSafari } from "react-device-detect"; import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { AnnotationSettingsPane } from "./AnnotationSettingsPane"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, } from "@/components/ui/dropdown-menu"; import { Link, useNavigate } from "react-router-dom"; import { ObjectPath } from "./ObjectPath"; import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; import { IoPlayCircleOutline } from "react-icons/io5"; import { useTranslation } from "react-i18next"; import { getTranslatedLabel } from "@/utils/i18n"; import { Badge } from "@/components/ui/badge"; import { HiDotsHorizontal } from "react-icons/hi"; import axios from "axios"; import { toast } from "sonner"; type TrackingDetailsProps = { className?: string; event: Event; fullscreen?: boolean; setPane: React.Dispatch>; }; export default function TrackingDetails({ className, event, fullscreen = false, setPane, }: TrackingDetailsProps) { const { t } = useTranslation(["views/explore"]); const { data: eventSequence } = useSWR([ "timeline", { source_id: event.id, }, ]); const { data: config } = useSWR("config"); const apiHost = useApiHost(); const navigate = useNavigate(); const [imgLoaded, setImgLoaded] = useState(false); const imgRef = useRef(null); const [selectedZone, setSelectedZone] = useState(""); const [lifecycleZones, setLifecycleZones] = useState([]); const [showControls, setShowControls] = useState(false); const [showZones, setShowZones] = useState(true); const aspectRatio = useMemo(() => { if (!config) { return 16 / 9; } return ( config.cameras[event.camera].detect.width / config.cameras[event.camera].detect.height ); }, [config, event]); const label = event.sub_label ? event.sub_label : getTranslatedLabel(event.label); const getZoneColor = useCallback( (zoneName: string) => { const zoneColor = config?.cameras?.[event.camera]?.zones?.[zoneName]?.color; if (zoneColor) { const reversed = [...zoneColor].reverse(); return reversed; } }, [config, event], ); const getObjectColor = useCallback( (label: string) => { const objectColor = config?.model?.colormap[label]; if (objectColor) { const reversed = [...objectColor].reverse(); return reversed; } }, [config], ); const getZonePolygon = useCallback( (zoneName: string) => { if (!imgRef.current || !config) { return; } const zonePoints = config?.cameras[event.camera].zones[zoneName].coordinates; const imgElement = imgRef.current; const imgRect = imgElement.getBoundingClientRect(); return zonePoints .split(",") .map(Number.parseFloat) .reduce((acc, value, index) => { const isXCoordinate = index % 2 === 0; const coordinate = isXCoordinate ? value * imgRect.width : value * imgRect.height; acc.push(coordinate); return acc; }, [] as number[]) .join(","); }, [config, imgRef, event], ); const [boxStyle, setBoxStyle] = useState(null); const [attributeBoxStyle, setAttributeBoxStyle] = useState(null); const configAnnotationOffset = useMemo(() => { if (!config) { return 0; } return config.cameras[event.camera]?.detect?.annotation_offset || 0; }, [config, event]); const [annotationOffset, setAnnotationOffset] = useState( configAnnotationOffset, ); const savedPathPoints = useMemo(() => { return ( event.data.path_data?.map(([coords, timestamp]: [number[], number]) => ({ x: coords[0], y: coords[1], timestamp, lifecycle_item: undefined, })) || [] ); }, [event.data.path_data]); const eventSequencePoints = useMemo(() => { return ( eventSequence ?.filter((event) => event.data.box !== undefined) .map((event) => { const [left, top, width, height] = event.data.box!; return { x: left + width / 2, // Center x-coordinate y: top + height, // Bottom y-coordinate timestamp: event.timestamp, lifecycle_item: event, }; }) || [] ); }, [eventSequence]); // final object path with timeline points included const pathPoints = useMemo(() => { // don't display a path if we don't have any saved path points if ( savedPathPoints.length === 0 || config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config ) return []; return [...savedPathPoints, ...eventSequencePoints].sort( (a, b) => a.timestamp - b.timestamp, ); }, [savedPathPoints, eventSequencePoints, config, event]); const [timeIndex, setTimeIndex] = useState(0); const handleSetBox = useCallback( (box: number[], attrBox: number[] | undefined) => { if (imgRef.current && Array.isArray(box) && box.length === 4) { const imgElement = imgRef.current; const imgRect = imgElement.getBoundingClientRect(); const style = { left: `${box[0] * imgRect.width}px`, top: `${box[1] * imgRect.height}px`, width: `${box[2] * imgRect.width}px`, height: `${box[3] * imgRect.height}px`, borderColor: `rgb(${getObjectColor(event.label)?.join(",")})`, }; if (attrBox) { const attrStyle = { left: `${attrBox[0] * imgRect.width}px`, top: `${attrBox[1] * imgRect.height}px`, width: `${attrBox[2] * imgRect.width}px`, height: `${attrBox[3] * imgRect.height}px`, borderColor: `rgb(${getObjectColor(event.label)?.join(",")})`, }; setAttributeBoxStyle(attrStyle); } else { setAttributeBoxStyle(null); } setBoxStyle(style); } }, [imgRef, event, getObjectColor], ); // image const [src, setSrc] = useState( `${apiHost}api/${event.camera}/recordings/${event.start_time + annotationOffset / 1000}/snapshot.jpg?height=500`, ); const [hasError, setHasError] = useState(false); useEffect(() => { if (timeIndex) { const newSrc = `${apiHost}api/${event.camera}/recordings/${timeIndex + annotationOffset / 1000}/snapshot.jpg?height=500`; setSrc(newSrc); } setImgLoaded(false); setHasError(false); // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [timeIndex, annotationOffset]); // carousels // Selected lifecycle item index; -1 when viewing a path-only point const handlePathPointClick = useCallback( (index: number) => { if (!eventSequence) return; const sequenceIndex = eventSequence.findIndex( (item) => item.timestamp === pathPoints[index].timestamp, ); if (sequenceIndex !== -1) { setTimeIndex(eventSequence[sequenceIndex].timestamp); handleSetBox( eventSequence[sequenceIndex]?.data.box ?? [], eventSequence[sequenceIndex]?.data?.attribute_box, ); setLifecycleZones(eventSequence[sequenceIndex]?.data.zones); } else { // click on a normal path point, not a lifecycle point setTimeIndex(pathPoints[index].timestamp); setBoxStyle(null); setLifecycleZones([]); } }, [eventSequence, pathPoints, handleSetBox], ); const formattedStart = config ? formatUnixTimestampToDateTime(event.start_time ?? 0, { timezone: config.ui.timezone, date_format: config.ui.time_format == "24hour" ? t("time.formattedTimestamp.24hour", { ns: "common", }) : t("time.formattedTimestamp.12hour", { ns: "common", }), time_style: "medium", date_style: "medium", }) : ""; const formattedEnd = config ? formatUnixTimestampToDateTime(event.end_time ?? 0, { timezone: config.ui.timezone, date_format: config.ui.time_format == "24hour" ? t("time.formattedTimestamp.24hour", { ns: "common", }) : t("time.formattedTimestamp.12hour", { ns: "common", }), time_style: "medium", date_style: "medium", }) : ""; useEffect(() => { if (!eventSequence || eventSequence.length === 0) return; // If timeIndex hasn't been set to a non-zero value, prefer the first lifecycle timestamp if (!timeIndex) { setTimeIndex(eventSequence[0].timestamp); handleSetBox( eventSequence[0]?.data.box ?? [], eventSequence[0]?.data?.attribute_box, ); setLifecycleZones(eventSequence[0]?.data.zones); } }, [eventSequence, timeIndex, handleSetBox]); // When timeIndex changes or image finishes loading, sync the box/zones to matching lifecycle, else clear useEffect(() => { if (!eventSequence || timeIndex == null) return; const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex); if (idx !== -1) { if (imgLoaded) { handleSetBox( eventSequence[idx]?.data.box ?? [], eventSequence[idx]?.data?.attribute_box, ); } setLifecycleZones(eventSequence[idx]?.data.zones); } else { // Non-lifecycle point (e.g., saved path point) setBoxStyle(null); setLifecycleZones([]); } }, [timeIndex, imgLoaded, eventSequence, handleSetBox]); const selectedLifecycle = useMemo(() => { if (!eventSequence || eventSequence.length === 0) return undefined; const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex); return idx !== -1 ? eventSequence[idx] : eventSequence[0]; }, [eventSequence, timeIndex]); const selectedIndex = useMemo(() => { if (!eventSequence || eventSequence.length === 0) return 0; const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex); return idx === -1 ? 0 : idx; }, [eventSequence, timeIndex]); // Calculate how far down the blue line should extend based on timeIndex const calculateLineHeight = () => { if (!eventSequence || eventSequence.length === 0) return 0; const currentTime = timeIndex ?? 0; // Find which events have been passed let lastPassedIndex = -1; for (let i = 0; i < eventSequence.length; i++) { if (currentTime >= (eventSequence[i].timestamp ?? 0)) { lastPassedIndex = i; } else { break; } } // No events passed yet if (lastPassedIndex < 0) return 0; // All events passed if (lastPassedIndex >= eventSequence.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 / (eventSequence.length - 1); // Find progress between current and next event for smooth transition const currentEvent = eventSequence[lastPassedIndex]; const nextEvent = eventSequence[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(); if (!config) { return ; } return (
{!fullscreen && (
)}
{hasError && (
{t("trackingDetails.noImageFound")}
)}
setImgLoaded(true)} onError={() => setHasError(true)} /> {showZones && imgRef.current?.width && imgRef.current?.height && lifecycleZones?.map((zone) => (
))} {boxStyle && (
)} {attributeBoxStyle && (
)} {imgRef.current?.width && imgRef.current?.height && pathPoints && pathPoints.length > 0 && (
)}
navigate( `/settings?page=masksAndZones&camera=${event.camera}&object_mask=${selectedLifecycle?.data.box}`, ) } >
{t("trackingDetails.createObjectMask")}
{t("trackingDetails.title")}
{t("trackingDetails.adjustAnnotationSettings")}
{t("trackingDetails.scrollViewTips")}
{t("trackingDetails.count", { first: selectedIndex + 1, second: eventSequence?.length ?? 0, })}
{config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config && (
{t("trackingDetails.autoTrackingTips")}
)} {showControls && ( )}
{ e.stopPropagation(); setTimeIndex(event.start_time ?? 0); }} role="button" >
{getIconForLabel( event.sub_label ? event.label + "-verified" : event.label, "size-4 text-white", )}
{label} {formattedStart ?? ""} - {formattedEnd ?? ""} {event.data?.recognized_license_plate && ( <> ·
{event.data.recognized_license_plate}
)}
{!eventSequence ? ( ) : eventSequence.length === 0 ? (
{t("detail.noObjectDetailData", { ns: "views/events" })}
) : (
{eventSequence.map((item, idx) => { const isActive = Math.abs((timeIndex ?? 0) - (item.timestamp ?? 0)) <= 0.5; 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[event.camera]?.detect?.width ?? 0) * (config.cameras[event.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 ( { setTimeIndex(item.timestamp ?? 0); handleSetBox( item.data.box ?? [], item.data.attribute_box, ); setLifecycleZones(item.data.zones); setSelectedZone(""); }} setSelectedZone={setSelectedZone} getZoneColor={getZoneColor} /> ); })}
)}
); } type GetTimelineIconParams = { lifecycleItem: TrackingDetailsSequence; className?: string; }; export function LifecycleIcon({ lifecycleItem, className, }: GetTimelineIconParams) { switch (lifecycleItem.class_type) { case "visible": return ; case "gone": return ; case "active": return ; case "stationary": return ; case "entered_zone": return ; case "attribute": switch (lifecycleItem.data?.attribute) { case "face": return ; case "license_plate": return ; default: return ; } case "heard": return ; case "external": return ; default: return null; } } type LifecycleIconRowProps = { item: TrackingDetailsSequence; isActive?: boolean; formattedEventTimestamp: string; ratio: string; areaPx?: number; areaPct?: string; onClick: () => void; setSelectedZone: (z: string) => void; getZoneColor: (zoneName: string) => number[] | undefined; }; function LifecycleIconRow({ item, isActive, formattedEventTimestamp, ratio, areaPx, areaPct, onClick, setSelectedZone, getZoneColor, }: LifecycleIconRowProps) { const { t } = useTranslation(["views/explore", "components/player"]); const { data: config } = useSWR("config"); const [isOpen, setIsOpen] = useState(false); const navigate = useNavigate(); return (
{getLifecycleItemDescription(item)}
{t("trackingDetails.lifecycleItemDesc.header.ratio")} {ratio}
{t("trackingDetails.lifecycleItemDesc.header.area")} {areaPx !== undefined && areaPct !== undefined ? ( {t("information.pixels", { ns: "common", area: areaPx })} ·{" "} {areaPct}% ) : ( N/A )}
{item.data?.zones && item.data.zones.length > 0 && (
{item.data.zones.map((zone, zidx) => { const color = getZoneColor(zone)?.join(",") ?? "0,0,0"; return ( { e.stopPropagation(); setSelectedZone(zone); }} style={{ borderColor: `rgba(${color}, 0.6)`, background: `rgba(${color}, 0.08)`, }} > {zone.replaceAll("_", " ")} ); })}
)}
{formattedEventTimestamp}
{(config?.plus?.enabled || item.data.box) && (
{config?.plus?.enabled && ( { const resp = await axios.post( `/${item.camera}/plus/${item.timestamp}`, ); if (resp && resp.status == 200) { toast.success( t("toast.success.submittedFrigatePlus", { ns: "components/player", }), { position: "top-center", }, ); } else { toast.success( t("toast.error.submitFrigatePlusFailed", { ns: "components/player", }), { position: "top-center", }, ); } }} > {t("itemMenu.submitToPlus.label")} )} {item.data.box && ( { setIsOpen(false); setTimeout(() => { navigate( `/settings?page=masksAndZones&camera=${item.camera}&object_mask=${item.data.box}`, ); }, 0); }} > {t("trackingDetails.createObjectMask")} )}
)}
); }