{
+ 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 ac98a8ebcb..1caed65e4f 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 12d7df592c..aa7b2478bc 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 d7c8ca6651..cef53475a6 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 d323401640..833dbaee87 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 i18n, { 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);
let supportedListFormat = false;
diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx
index 63cefbce0d..1e2e060397 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 +760,10 @@ export function RecordingView({
{isMobile && (