diff --git a/frigate/api/app.py b/frigate/api/app.py index 5d09ecf00..5efb8b523 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -696,7 +696,11 @@ def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = N clauses.append((Timeline.camera == camera)) if source_id: - clauses.append((Timeline.source_id == source_id)) + source_ids = [sid.strip() for sid in source_id.split(",")] + if len(source_ids) == 1: + clauses.append((Timeline.source_id == source_ids[0])) + else: + clauses.append((Timeline.source_id.in_(source_ids))) if len(clauses) == 0: clauses.append((True)) diff --git a/web/src/components/overlay/ObjectTrackOverlay.tsx b/web/src/components/overlay/ObjectTrackOverlay.tsx index 2bd355306..50cf92781 100644 --- a/web/src/components/overlay/ObjectTrackOverlay.tsx +++ b/web/src/components/overlay/ObjectTrackOverlay.tsx @@ -11,38 +11,80 @@ import { import { TooltipPortal } from "@radix-ui/react-tooltip"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; +import { Event } from "@/types/event"; type ObjectTrackOverlayProps = { camera: string; - selectedObjectId: string; showBoundingBoxes?: boolean; currentTime: number; videoWidth: number; videoHeight: number; className?: string; onSeekToTime?: (timestamp: number, play?: boolean) => void; - objectTimeline?: ObjectLifecycleSequence[]; +}; + +type PathPoint = { + x: number; + y: number; + timestamp: number; + lifecycle_item?: ObjectLifecycleSequence; + objectId: string; +}; + +type ObjectData = { + objectId: string; + label: string; + color: string; + pathPoints: PathPoint[]; + currentZones: string[]; + currentBox?: number[]; }; export default function ObjectTrackOverlay({ camera, - selectedObjectId, showBoundingBoxes = false, currentTime, videoWidth, videoHeight, className, onSeekToTime, - objectTimeline, }: ObjectTrackOverlayProps) { const { t } = useTranslation("views/events"); const { data: config } = useSWR("config"); - const { annotationOffset } = useDetailStream(); + const { annotationOffset, selectedObjectIds } = useDetailStream(); const effectiveCurrentTime = currentTime - annotationOffset / 1000; - // Fetch the full event data to get saved path points - const { data: eventData } = useSWR(["event_ids", { ids: selectedObjectId }]); + // Fetch all event data in a single request (CSV ids) + const { data: eventsData } = useSWR( + selectedObjectIds.length > 0 + ? ["event_ids", { ids: selectedObjectIds.join(",") }] + : null, + ); + + // Fetch timeline data for each object ID using fixed number of hooks + const { data: timelineData } = useSWR( + selectedObjectIds.length > 0 + ? `timeline?source_id=${selectedObjectIds.join(",")}&limit=1000` + : null, + { revalidateOnFocus: false }, + ); + + const timelineResults = useMemo(() => { + // Group timeline entries by source_id + if (!timelineData) return selectedObjectIds.map(() => []); + + const grouped: Record = {}; + for (const entry of timelineData) { + if (!grouped[entry.source_id]) { + grouped[entry.source_id] = []; + } + grouped[entry.source_id].push(entry); + } + + // Return timeline arrays in the same order as selectedObjectIds + return selectedObjectIds.map((id) => grouped[id] || []); + }, [selectedObjectIds, timelineData]); const typeColorMap = useMemo( () => ({ @@ -58,16 +100,18 @@ export default function ObjectTrackOverlay({ [], ); - const getObjectColor = useMemo(() => { - return (label: string) => { + const getObjectColor = useCallback( + (label: string, objectId: string) => { const objectColor = config?.model?.colormap[label]; if (objectColor) { const reversed = [...objectColor].reverse(); return `rgb(${reversed.join(",")})`; } - return "rgb(255, 0, 0)"; // fallback red - }; - }, [config]); + // Fallback to deterministic color based on object ID + return generateColorFromId(objectId); + }, + [config], + ); const getZoneColor = useCallback( (zoneName: string) => { @@ -81,125 +125,121 @@ export default function ObjectTrackOverlay({ [config, camera], ); - const currentObjectZones = useMemo(() => { - if (!objectTimeline) return []; - - // Find the most recent timeline event at or before effective current time - const relevantEvents = objectTimeline - .filter((event) => event.timestamp <= effectiveCurrentTime) - .sort((a, b) => b.timestamp - a.timestamp); // Most recent first - - // Get zones from the most recent event - return relevantEvents[0]?.data?.zones || []; - }, [objectTimeline, effectiveCurrentTime]); - - const zones = useMemo(() => { - if (!config?.cameras?.[camera]?.zones || !currentObjectZones.length) + // Build per-object data structures + const objectsData = useMemo(() => { + if (!eventsData || !Array.isArray(eventsData)) return []; + if (config?.cameras[camera]?.onvif.autotracking.enabled_in_config) return []; + return selectedObjectIds + .map((objectId, index) => { + const eventData = eventsData.find((e) => e.id === objectId); + const timelineData = timelineResults[index]; + + // get saved path points from event + const savedPathPoints: PathPoint[] = + eventData?.data?.path_data?.map( + ([coords, timestamp]: [number[], number]) => ({ + x: coords[0], + y: coords[1], + timestamp, + lifecycle_item: undefined, + objectId, + }), + ) || []; + + // timeline points for this object + const eventSequencePoints: PathPoint[] = + timelineData + ?.filter( + (event: ObjectLifecycleSequence) => event.data.box !== undefined, + ) + .map((event: ObjectLifecycleSequence) => { + const [left, top, width, height] = event.data.box!; + return { + x: left + width / 2, // Center x + y: top + height, // Bottom y + timestamp: event.timestamp, + lifecycle_item: event, + objectId, + }; + }) || []; + + // show full path once current time has reached the object's start time + const combinedPoints = [...savedPathPoints, ...eventSequencePoints] + .sort((a, b) => a.timestamp - b.timestamp) + .filter( + (point) => + currentTime >= (eventData?.start_time ?? 0) && + point.timestamp >= (eventData?.start_time ?? 0) && + point.timestamp <= (eventData?.end_time ?? Infinity), + ); + + // Get color for this object + const label = eventData?.label || "unknown"; + const color = getObjectColor(label, objectId); + + // Get current zones + const currentZones = + timelineData + ?.filter( + (event: ObjectLifecycleSequence) => + event.timestamp <= effectiveCurrentTime, + ) + .sort( + (a: ObjectLifecycleSequence, b: ObjectLifecycleSequence) => + b.timestamp - a.timestamp, + )[0]?.data?.zones || []; + + // Get current bounding box + const currentBox = timelineData + ?.filter( + (event: ObjectLifecycleSequence) => + event.timestamp <= effectiveCurrentTime && event.data.box, + ) + .sort( + (a: ObjectLifecycleSequence, b: ObjectLifecycleSequence) => + b.timestamp - a.timestamp, + )[0]?.data?.box; + + return { + objectId, + label, + color, + pathPoints: combinedPoints, + currentZones, + currentBox, + }; + }) + .filter((obj: ObjectData) => obj.pathPoints.length > 0); // Only include objects with path data + }, [ + eventsData, + selectedObjectIds, + timelineResults, + currentTime, + effectiveCurrentTime, + getObjectColor, + config, + camera, + ]); + + // Collect all zones across all objects + const allZones = useMemo(() => { + if (!config?.cameras?.[camera]?.zones) return []; + + const zoneNames = new Set(); + objectsData.forEach((obj) => { + obj.currentZones.forEach((zone) => zoneNames.add(zone)); + }); + return Object.entries(config.cameras[camera].zones) - .filter(([name]) => currentObjectZones.includes(name)) + .filter(([name]) => zoneNames.has(name)) .map(([name, zone]) => ({ name, coordinates: zone.coordinates, color: getZoneColor(name), })); - }, [config, camera, getZoneColor, currentObjectZones]); - - // get saved path points from event - const savedPathPoints = useMemo(() => { - return ( - eventData?.[0].data?.path_data?.map( - ([coords, timestamp]: [number[], number]) => ({ - x: coords[0], - y: coords[1], - timestamp, - lifecycle_item: undefined, - }), - ) || [] - ); - }, [eventData]); - - // timeline points for selected event - const eventSequencePoints = useMemo(() => { - return ( - objectTimeline - ?.filter((event) => event.data.box !== undefined) - .map((event) => { - const [left, top, width, height] = event.data.box!; - - return { - x: left + width / 2, // Center x - y: top + height, // Bottom y - timestamp: event.timestamp, - lifecycle_item: event, - }; - }) || [] - ); - }, [objectTimeline]); - - // final object path with timeline points included - const pathPoints = useMemo(() => { - // don't display a path for autotracking cameras - if (config?.cameras[camera]?.onvif.autotracking.enabled_in_config) - return []; - - const combinedPoints = [...savedPathPoints, ...eventSequencePoints].sort( - (a, b) => a.timestamp - b.timestamp, - ); - - // Filter points around current time (within a reasonable window) - const timeWindow = 30; // 30 seconds window - return combinedPoints.filter( - (point) => - point.timestamp >= currentTime - timeWindow && - point.timestamp <= currentTime + timeWindow, - ); - }, [savedPathPoints, eventSequencePoints, config, camera, currentTime]); - - // get absolute positions on the svg canvas for each point - const absolutePositions = useMemo(() => { - if (!pathPoints) return []; - - return pathPoints.map((point) => { - // Find the corresponding timeline entry for this point - const timelineEntry = objectTimeline?.find( - (entry) => entry.timestamp == point.timestamp, - ); - return { - x: point.x * videoWidth, - y: point.y * videoHeight, - timestamp: point.timestamp, - lifecycle_item: - timelineEntry || - (point.box // normal path point - ? { - timestamp: point.timestamp, - camera: camera, - source: "tracked_object", - source_id: selectedObjectId, - class_type: "visible" as LifecycleClassType, - data: { - camera: camera, - label: point.label, - sub_label: "", - box: point.box, - region: [0, 0, 0, 0], // placeholder - attribute: "", - zones: [], - }, - } - : undefined), - }; - }); - }, [ - pathPoints, - videoWidth, - videoHeight, - objectTimeline, - camera, - selectedObjectId, - ]); + }, [config, camera, objectsData, getZoneColor]); const generateStraightPath = useCallback( (points: { x: number; y: number }[]) => { @@ -214,15 +254,20 @@ export default function ObjectTrackOverlay({ ); const getPointColor = useCallback( - (baseColor: number[], type?: string) => { + (baseColorString: string, type?: string) => { if (type && typeColorMap[type as keyof typeof typeColorMap]) { const typeColor = typeColorMap[type as keyof typeof typeColorMap]; if (typeColor) { return `rgb(${typeColor.join(",")})`; } } - // normal path point - return `rgb(${baseColor.map((c) => Math.max(0, c - 10)).join(",")})`; + // Parse and darken base color slightly for path points + const match = baseColorString.match(/\d+/g); + if (match) { + const [r, g, b] = match.map(Number); + return `rgb(${Math.max(0, r - 10)}, ${Math.max(0, g - 10)}, ${Math.max(0, b - 10)})`; + } + return baseColorString; }, [typeColorMap], ); @@ -234,49 +279,8 @@ export default function ObjectTrackOverlay({ [onSeekToTime], ); - // render bounding box for object at current time if we have a timeline entry - const currentBoundingBox = useMemo(() => { - if (!objectTimeline) return null; - - // Find the most recent timeline event at or before effective current time with a bounding box - const relevantEvents = objectTimeline - .filter( - (event) => event.timestamp <= effectiveCurrentTime && event.data.box, - ) - .sort((a, b) => b.timestamp - a.timestamp); // Most recent first - - const currentEvent = relevantEvents[0]; - - if (!currentEvent?.data.box) return null; - - const [left, top, width, height] = currentEvent.data.box; - return { - left, - top, - width, - height, - centerX: left + width / 2, - centerY: top + height, - }; - }, [objectTimeline, effectiveCurrentTime]); - - const objectColor = useMemo(() => { - return pathPoints[0]?.label - ? getObjectColor(pathPoints[0].label) - : "rgb(255, 0, 0)"; - }, [pathPoints, getObjectColor]); - - const objectColorArray = useMemo(() => { - return pathPoints[0]?.label - ? getObjectColor(pathPoints[0].label).match(/\d+/g)?.map(Number) || [ - 255, 0, 0, - ] - : [255, 0, 0]; - }, [pathPoints, getObjectColor]); - - // render any zones for object at current time const zonePolygons = useMemo(() => { - return zones.map((zone) => { + return allZones.map((zone) => { // Convert zone coordinates from normalized (0-1) to pixel coordinates const points = zone.coordinates .split(",") @@ -298,9 +302,9 @@ export default function ObjectTrackOverlay({ stroke: zone.color, }; }); - }, [zones, videoWidth, videoHeight]); + }, [allZones, videoWidth, videoHeight]); - if (!pathPoints.length || !config) { + if (objectsData.length === 0 || !config) { return null; } @@ -325,73 +329,102 @@ export default function ObjectTrackOverlay({ /> ))} - {absolutePositions.length > 1 && ( - - )} + {objectsData.map((objData) => { + const absolutePositions = objData.pathPoints.map((point) => ({ + x: point.x * videoWidth, + y: point.y * videoHeight, + timestamp: point.timestamp, + lifecycle_item: point.lifecycle_item, + })); - {absolutePositions.map((pos, index) => ( - - - handlePointClick(pos.timestamp)} - /> - - - - {pos.lifecycle_item - ? `${pos.lifecycle_item.class_type.replace("_", " ")} at ${new Date(pos.timestamp * 1000).toLocaleTimeString()}` - : t("objectTrack.trackedPoint")} - {onSeekToTime && ( -
- {t("objectTrack.clickToSeek")} -
- )} -
-
-
- ))} + return ( + + {absolutePositions.length > 1 && ( + + )} - {currentBoundingBox && showBoundingBoxes && ( - - + {absolutePositions.map((pos, index) => ( + + + handlePointClick(pos.timestamp)} + /> + + + + {pos.lifecycle_item + ? `${pos.lifecycle_item.class_type.replace("_", " ")} at ${new Date(pos.timestamp * 1000).toLocaleTimeString()}` + : t("objectTrack.trackedPoint")} + {onSeekToTime && ( +
+ {t("objectTrack.clickToSeek")} +
+ )} +
+
+
+ ))} - -
- )} + {objData.currentBox && showBoundingBoxes && ( + + + + + )} +
+ ); + })} ); } + +// Generate a deterministic HSL color from a string (object ID) +function generateColorFromId(id: string): string { + let hash = 0; + for (let i = 0; i < id.length; i++) { + hash = id.charCodeAt(i) + ((hash << 5) - hash); + } + // Use golden ratio to distribute hues evenly + const hue = (hash * 137.508) % 360; + return `hsl(${hue}, 70%, 50%)`; +} diff --git a/web/src/components/overlay/detail/ObjectLifecycle.tsx b/web/src/components/overlay/detail/ObjectLifecycle.tsx index 0f1eaadf5..761be65ae 100644 --- a/web/src/components/overlay/detail/ObjectLifecycle.tsx +++ b/web/src/components/overlay/detail/ObjectLifecycle.tsx @@ -94,6 +94,10 @@ export default function ObjectLifecycle({ ); }, [config, event]); + const label = event.sub_label + ? event.sub_label + : getTranslatedLabel(event.label); + const getZoneColor = useCallback( (zoneName: string) => { const zoneColor = @@ -628,17 +632,29 @@ export default function ObjectLifecycle({ }} role="button" > -
+
{getIconForLabel( - event.label, - "size-6 text-primary dark:text-white", + event.sub_label ? event.label + "-verified" : event.label, + "size-4 text-white", )}
-
- {getTranslatedLabel(event.label)} +
+ {label} {formattedStart ?? ""} - {formattedEnd ?? ""} + {event.data?.recognized_license_plate && ( + <> + ·{" "} + + {event.data.recognized_license_plate} + + + )}
diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index a41d31db2..fad88815b 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -20,7 +20,6 @@ import { cn } from "@/lib/utils"; import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record"; import { useTranslation } from "react-i18next"; import ObjectTrackOverlay from "@/components/overlay/ObjectTrackOverlay"; -import { DetailStreamContextType } from "@/context/detail-stream-context"; // Android native hls does not seek correctly const USE_NATIVE_HLS = !isAndroid; @@ -54,8 +53,11 @@ type HlsVideoPlayerProps = { onUploadFrame?: (playTime: number) => Promise | undefined; toggleFullscreen?: () => void; onError?: (error: RecordingPlayerError) => void; - detail?: Partial; + isDetailMode?: boolean; + camera?: string; + currentTimeOverride?: number; }; + export default function HlsVideoPlayer({ videoRef, containerRef, @@ -75,17 +77,15 @@ export default function HlsVideoPlayer({ onUploadFrame, toggleFullscreen, onError, - detail, + isDetailMode = false, + camera, + currentTimeOverride, }: HlsVideoPlayerProps) { const { t } = useTranslation("components/player"); const { data: config } = useSWR("config"); // for detail stream context in History - const selectedObjectId = detail?.selectedObjectId; - const selectedObjectTimeline = detail?.selectedObjectTimeline; - const currentTime = detail?.currentTime; - const camera = detail?.camera; - const isDetailMode = detail?.isDetailMode ?? false; + const currentTime = currentTimeOverride; // playback @@ -316,16 +316,14 @@ export default function HlsVideoPlayer({ }} > {isDetailMode && - selectedObjectId && camera && currentTime && videoDimensions.width > 0 && videoDimensions.height > 0 && (
)} diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 1b7689804..2a6f3a1cf 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -61,7 +61,11 @@ export default function DynamicVideoPlayer({ const { data: config } = useSWR("config"); // for detail stream context in History - const detail = useDetailStream(); + const { + isDetailMode, + camera: contextCamera, + currentTime, + } = useDetailStream(); // controlling playback @@ -295,7 +299,9 @@ export default function DynamicVideoPlayer({ setIsBuffering(true); } }} - detail={detail} + isDetailMode={isDetailMode} + camera={contextCamera || camera} + currentTimeOverride={currentTime} /> setUpload(undefined)} - onEventUploaded={() => setUpload(undefined)} + onEventUploaded={() => { + if (upload) { + upload.plus_id = "new_upload"; + } + }} />
e.label) + ? fetchedEvents.map((e) => + e.sub_label ? e.label + "-verified" : e.label, + ) : (review.data?.objects ?? [])), ...(review.data?.audio ?? []), ]; @@ -317,7 +323,7 @@ function ReviewGroup({
{displayTime}
-
+
{iconLabels.slice(0, 5).map((lbl, idx) => (
("config"); - const { selectedObjectId, setSelectedObjectId } = useDetailStream(); + 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); - setSelectedObjectId(event.id); + // onSeek(event.start_time ?? 0); + toggleObjectSelection(event.id); } else { - setSelectedObjectId(undefined); + toggleObjectSelection(undefined); } }; - // Clear selectedObjectId when effectiveTime has passed this event's end_time + // Clear selection when effectiveTime has passed this event's end_time useEffect(() => { - if (selectedObjectId === event.id && effectiveTime && event.end_time) { + if (isSelected && effectiveTime && event.end_time) { if (effectiveTime >= event.end_time) { - setSelectedObjectId(undefined); + toggleObjectSelection(event.id); } } }, [ - selectedObjectId, + isSelected, event.id, event.end_time, effectiveTime, - setSelectedObjectId, + toggleObjectSelection, ]); return ( @@ -454,48 +464,59 @@ function EventList({
= (event.start_time ?? 0) - 0.5 && (effectiveTime ?? 0) <= (event.end_time ?? event.start_time ?? 0) + 0.5 && "bg-secondary-highlight", )} > -
-
{ - e.stopPropagation(); - handleObjectSelect( - event.id == selectedObjectId ? undefined : event, - ); - }} - role="button" - > +
+
{ + e.stopPropagation(); + handleObjectSelect(isSelected ? undefined : event); + }} > - {getIconForLabel(event.label, "size-3 text-white")} + {getIconForLabel( + event.sub_label ? event.label + "-verified" : event.label, + "size-3 text-white", + )}
-
- {getTranslatedLabel(event.label)} +
{ + e.stopPropagation(); + onSeek(event.start_time ?? 0); + }} + role="button" + > + {label} + {event.data?.recognized_license_plate && ( + <> + ·{" "} + + {event.data.recognized_license_plate} + + + )}
-
+
onOpenUpload?.(e)} - selectedObjectId={selectedObjectId} - setSelectedObjectId={handleObjectSelect} + isSelected={isSelected} + onToggleSelection={handleObjectSelect} />
diff --git a/web/src/components/timeline/EventMenu.tsx b/web/src/components/timeline/EventMenu.tsx index ac98a8ebc..1caed65e4 100644 --- a/web/src/components/timeline/EventMenu.tsx +++ b/web/src/components/timeline/EventMenu.tsx @@ -12,14 +12,15 @@ import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Event } from "@/types/event"; import { FrigateConfig } from "@/types/frigateConfig"; +import { useState } from "react"; type EventMenuProps = { event: Event; config?: FrigateConfig; onOpenUpload?: (e: Event) => void; onOpenSimilarity?: (e: Event) => void; - selectedObjectId?: string; - setSelectedObjectId?: (event: Event | undefined) => void; + isSelected?: boolean; + onToggleSelection?: (event: Event | undefined) => void; }; export default function EventMenu({ @@ -27,25 +28,26 @@ export default function EventMenu({ config, onOpenUpload, onOpenSimilarity, - selectedObjectId, - setSelectedObjectId, + isSelected = false, + onToggleSelection, }: EventMenuProps) { const apiHost = useApiHost(); const navigate = useNavigate(); const { t } = useTranslation("views/explore"); + const [isOpen, setIsOpen] = useState(false); const handleObjectSelect = () => { - if (event.id === selectedObjectId) { - setSelectedObjectId?.(undefined); + if (isSelected) { + onToggleSelection?.(undefined); } else { - setSelectedObjectId?.(event); + onToggleSelection?.(event); } }; return ( <> - +
@@ -54,7 +56,7 @@ export default function EventMenu({ - {event.id === selectedObjectId + {isSelected ? t("itemMenu.hideObjectDetails.label") : t("itemMenu.showObjectDetails.label")} @@ -85,6 +87,7 @@ export default function EventMenu({ config?.plus?.enabled && ( { + setIsOpen(false); onOpenUpload?.(event); }} > diff --git a/web/src/context/detail-stream-context.tsx b/web/src/context/detail-stream-context.tsx index 12d7df592..aa7b2478b 100644 --- a/web/src/context/detail-stream-context.tsx +++ b/web/src/context/detail-stream-context.tsx @@ -1,16 +1,14 @@ import React, { createContext, useContext, useState, useEffect } from "react"; import { FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; -import { ObjectLifecycleSequence } from "@/types/timeline"; export interface DetailStreamContextType { - selectedObjectId: string | undefined; - selectedObjectTimeline?: ObjectLifecycleSequence[]; + selectedObjectIds: string[]; currentTime: number; camera: string; annotationOffset: number; // milliseconds setAnnotationOffset: (ms: number) => void; - setSelectedObjectId: (id: string | undefined) => void; + toggleObjectSelection: (id: string | undefined) => void; isDetailMode: boolean; } @@ -31,13 +29,21 @@ export function DetailStreamProvider({ currentTime, camera, }: DetailStreamProviderProps) { - const [selectedObjectId, setSelectedObjectId] = useState< - string | undefined - >(); + const [selectedObjectIds, setSelectedObjectIds] = useState([]); - const { data: selectedObjectTimeline } = useSWR( - selectedObjectId ? ["timeline", { source_id: selectedObjectId }] : null, - ); + const toggleObjectSelection = (id: string | undefined) => { + if (id === undefined) { + setSelectedObjectIds([]); + } else { + setSelectedObjectIds((prev) => { + if (prev.includes(id)) { + return prev.filter((existingId) => existingId !== id); + } else { + return [...prev, id]; + } + }); + } + }; const { data: config } = useSWR("config"); @@ -53,13 +59,12 @@ export function DetailStreamProvider({ }, [config, camera]); const value: DetailStreamContextType = { - selectedObjectId, - selectedObjectTimeline, + selectedObjectIds, currentTime, camera, annotationOffset, setAnnotationOffset, - setSelectedObjectId, + toggleObjectSelection, isDetailMode, }; diff --git a/web/src/types/event.ts b/web/src/types/event.ts index d7c8ca665..cef53475a 100644 --- a/web/src/types/event.ts +++ b/web/src/types/event.ts @@ -22,6 +22,7 @@ export interface Event { area: number; ratio: number; type: "object" | "audio" | "manual"; + recognized_license_plate?: string; path_data: [number[], number][]; }; } diff --git a/web/src/utils/lifecycleUtil.ts b/web/src/utils/lifecycleUtil.ts index edb46b969..e0016ccd8 100644 --- a/web/src/utils/lifecycleUtil.ts +++ b/web/src/utils/lifecycleUtil.ts @@ -1,6 +1,7 @@ import { ObjectLifecycleSequence } from "@/types/timeline"; import { t } from "i18next"; import { getTranslatedLabel } from "./i18n"; +import { capitalizeFirstLetter } from "./stringUtil"; export function getLifecycleItemDescription( lifecycleItem: ObjectLifecycleSequence, @@ -10,7 +11,7 @@ export function getLifecycleItemDescription( : lifecycleItem.data.sub_label || lifecycleItem.data.label; const label = lifecycleItem.data.sub_label - ? rawLabel + ? capitalizeFirstLetter(rawLabel) : getTranslatedLabel(rawLabel); switch (lifecycleItem.class_type) { diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 3b001cb16..bde6c6d43 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -11,6 +11,7 @@ import DetailStream from "@/components/timeline/DetailStream"; import { Button } from "@/components/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { useOverlayState } from "@/hooks/use-overlay-state"; +import { useResizeObserver } from "@/hooks/resize-observer"; import { ExportMode } from "@/types/filter"; import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; @@ -31,12 +32,7 @@ import { useRef, useState, } from "react"; -import { - isDesktop, - isMobile, - isMobileOnly, - isTablet, -} from "react-device-detect"; +import { isDesktop, isMobile } from "react-device-detect"; import { IoMdArrowRoundBack } from "react-icons/io"; import { useNavigate } from "react-router-dom"; import { Toaster } from "@/components/ui/sonner"; @@ -55,7 +51,6 @@ import { RecordingSegment, RecordingStartingPoint, } from "@/types/record"; -import { useResizeObserver } from "@/hooks/resize-observer"; import { cn } from "@/lib/utils"; import { useFullscreen } from "@/hooks/use-fullscreen"; import { useTimezone } from "@/hooks/use-date-utils"; @@ -399,49 +394,47 @@ export function RecordingView({ } }, [mainCameraAspect]); - const [{ width: mainWidth, height: mainHeight }] = + // use a resize observer to determine whether to use w-full or h-full based on container aspect ratio + const [{ width: containerWidth, height: containerHeight }] = useResizeObserver(cameraLayoutRef); + const [{ width: previewRowWidth, height: previewRowHeight }] = + useResizeObserver(previewRowRef); - const mainCameraStyle = useMemo(() => { - if (isMobile || mainCameraAspect != "normal" || !config) { - return undefined; + const useHeightBased = useMemo(() => { + if (!containerWidth || !containerHeight) { + return false; } - const camera = config.cameras[mainCamera]; - - if (!camera) { - return undefined; + const cameraAspectRatio = getCameraAspect(mainCamera); + if (!cameraAspectRatio) { + return false; } - const aspect = getCameraAspect(mainCamera); + // Calculate available space for camera after accounting for preview row + // For tall cameras: preview row is side-by-side (takes width) + // For wide/normal cameras: preview row is stacked (takes height) + const availableWidth = + mainCameraAspect == "tall" && previewRowWidth + ? containerWidth - previewRowWidth + : containerWidth; + const availableHeight = + mainCameraAspect != "tall" && previewRowHeight + ? containerHeight - previewRowHeight + : containerHeight; - if (!aspect) { - return undefined; - } + const availableAspectRatio = availableWidth / availableHeight; - const availableHeight = mainHeight - 112; - - let percent; - if (mainWidth / availableHeight < aspect) { - percent = 100; - } else { - const availableWidth = aspect * availableHeight; - percent = - (mainWidth < availableWidth - ? mainWidth / availableWidth - : availableWidth / mainWidth) * 100; - } - - return { - width: `${Math.round(percent)}%`, - }; + // If available space is wider than camera aspect, constrain by height (h-full) + // If available space is taller than camera aspect, constrain by width (w-full) + return availableAspectRatio >= cameraAspectRatio; }, [ - config, - mainCameraAspect, - mainWidth, - mainHeight, - mainCamera, + containerWidth, + containerHeight, + previewRowWidth, + previewRowHeight, getCameraAspect, + mainCamera, + mainCameraAspect, ]); const previewRowOverflows = useMemo(() => { @@ -685,19 +678,17 @@ export function RecordingView({
{isDesktop && ( @@ -782,10 +761,10 @@ export function RecordingView({
{isMobile && (