From 36fb27ef56f415bac95fd5d615327959eb7c6308 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Sat, 1 Nov 2025 09:19:30 -0500
Subject: [PATCH] Refactor Tracked Object Details dialog (#20748)
* detail stream settings
* remove old review detail dialog
* change layout
* use detail stream in tracking details
* reusable tabs component
* pass in tabs for desktop
* fix object selection and time updating
* i18n
* aspect fixes
* include tolerance for displaying of path and zone
some browsers (firefox and probably brave) intentionally reduce precision of seeking with currentTime for privacy reasons
* detail stream seeking fixes
* tracking details seeking fixes
* layout tweaks
* add download button back for now
* remove
* remove
* snapshot is now default tab
---
web/public/locales/en/views/explore.json | 3 +-
.../components/overlay/ObjectTrackOverlay.tsx | 53 +-
.../overlay/detail/AnnotationOffsetSlider.tsx | 4 +-
.../overlay/detail/ReviewDetailDialog.tsx | 577 ----------
.../overlay/detail/SearchDetailDialog.tsx | 449 +++++---
.../overlay/detail/TrackingDetails.tsx | 1025 +++++++----------
web/src/components/timeline/DetailStream.tsx | 30 +-
web/src/context/detail-stream-context.tsx | 6 +-
.../classification/ModelTrainingView.tsx | 2 +-
web/src/views/search/SearchView.tsx | 4 +-
10 files changed, 766 insertions(+), 1387 deletions(-)
delete mode 100644 web/src/components/overlay/detail/ReviewDetailDialog.tsx
diff --git a/web/public/locales/en/views/explore.json b/web/public/locales/en/views/explore.json
index 3a0e7af00..787581bf7 100644
--- a/web/public/locales/en/views/explore.json
+++ b/web/public/locales/en/views/explore.json
@@ -33,6 +33,7 @@
"type": {
"details": "details",
"snapshot": "snapshot",
+ "thumbnail": "thumbnail",
"video": "video",
"object_lifecycle": "object lifecycle"
},
@@ -41,7 +42,7 @@
"noImageFound": "No image found for this timestamp.",
"createObjectMask": "Create Object Mask",
"adjustAnnotationSettings": "Adjust annotation settings",
- "scrollViewTips": "Scroll to view the significant moments of this object's lifecycle.",
+ "scrollViewTips": "Click to view the significant moments of this object's lifecycle.",
"autoTrackingTips": "Bounding box positions will be inaccurate for autotracking cameras.",
"count": "{{first}} of {{second}}",
"trackedPoint": "Tracked Point",
diff --git a/web/src/components/overlay/ObjectTrackOverlay.tsx b/web/src/components/overlay/ObjectTrackOverlay.tsx
index f95cabf7a..ec51786b8 100644
--- a/web/src/components/overlay/ObjectTrackOverlay.tsx
+++ b/web/src/components/overlay/ObjectTrackOverlay.tsx
@@ -13,6 +13,9 @@ import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { Event } from "@/types/event";
+// Use a small tolerance (10ms) for browsers with seek precision by-design issues
+const TOLERANCE = 0.01;
+
type ObjectTrackOverlayProps = {
camera: string;
showBoundingBoxes?: boolean;
@@ -166,41 +169,45 @@ export default function ObjectTrackOverlay({
}) || [];
// 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),
- );
+ // event.start_time is in DETECT stream time, so convert it to record stream time for comparison
+ const eventStartTimeRecord =
+ (eventData?.start_time ?? 0) + annotationOffset / 1000;
+
+ const allPoints = [...savedPathPoints, ...eventSequencePoints].sort(
+ (a, b) => a.timestamp - b.timestamp,
+ );
+ const combinedPoints = allPoints.filter(
+ (point) =>
+ currentTime >= eventStartTimeRecord - TOLERANCE &&
+ point.timestamp <= effectiveCurrentTime + TOLERANCE,
+ );
// Get color for this object
const label = eventData?.label || "unknown";
const color = getObjectColor(label, objectId);
- // Get current zones
+ // zones (with tolerance for browsers with seek precision by-design issues)
const currentZones =
timelineData
?.filter(
(event: TrackingDetailsSequence) =>
- event.timestamp <= effectiveCurrentTime,
+ event.timestamp <= effectiveCurrentTime + TOLERANCE,
)
.sort(
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
b.timestamp - a.timestamp,
)[0]?.data?.zones || [];
- // Get current bounding box
- const currentBox = timelineData
- ?.filter(
- (event: TrackingDetailsSequence) =>
- event.timestamp <= effectiveCurrentTime && event.data.box,
- )
- .sort(
- (a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
- b.timestamp - a.timestamp,
- )[0]?.data?.box;
+ // bounding box (with tolerance for browsers with seek precision by-design issues)
+ const boxCandidates = timelineData?.filter(
+ (event: TrackingDetailsSequence) =>
+ event.timestamp <= effectiveCurrentTime + TOLERANCE &&
+ event.data.box,
+ );
+ const currentBox = boxCandidates?.sort(
+ (a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
+ b.timestamp - a.timestamp,
+ )[0]?.data?.box;
return {
objectId,
@@ -221,6 +228,7 @@ export default function ObjectTrackOverlay({
getObjectColor,
config,
camera,
+ annotationOffset,
]);
// Collect all zones across all objects
@@ -274,9 +282,10 @@ export default function ObjectTrackOverlay({
const handlePointClick = useCallback(
(timestamp: number) => {
- onSeekToTime?.(timestamp, false);
+ // Convert detect stream timestamp to record stream timestamp before seeking
+ onSeekToTime?.(timestamp + annotationOffset / 1000, false);
},
- [onSeekToTime],
+ [onSeekToTime, annotationOffset],
);
const zonePolygons = useMemo(() => {
diff --git a/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx b/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx
index 9f6b6efbd..4af982da5 100644
--- a/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx
+++ b/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx
@@ -91,8 +91,8 @@ export default function AnnotationOffsetSlider({ className }: Props) {
diff --git a/web/src/components/overlay/detail/ReviewDetailDialog.tsx b/web/src/components/overlay/detail/ReviewDetailDialog.tsx
deleted file mode 100644
index 16050245c..000000000
--- a/web/src/components/overlay/detail/ReviewDetailDialog.tsx
+++ /dev/null
@@ -1,577 +0,0 @@
-import { isDesktop, isIOS, isMobile } from "react-device-detect";
-import {
- Sheet,
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetTitle,
-} from "../../ui/sheet";
-import useSWR from "swr";
-import { FrigateConfig } from "@/types/frigateConfig";
-import { useFormattedTimestamp } from "@/hooks/use-date-utils";
-import { getIconForLabel } from "@/utils/iconUtil";
-import { useApiHost } from "@/api";
-import {
- ReviewDetailPaneType,
- ReviewSegment,
- ThreatLevel,
-} from "@/types/review";
-import { Event } from "@/types/event";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { cn } from "@/lib/utils";
-import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
-import TrackingDetails from "./TrackingDetails";
-import Chip from "@/components/indicators/Chip";
-import { FaDownload, FaImages, FaShareAlt } from "react-icons/fa";
-import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
-import { FaArrowsRotate } from "react-icons/fa6";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import { useNavigate } from "react-router-dom";
-import { Button } from "@/components/ui/button";
-import { baseUrl } from "@/api/baseUrl";
-import { shareOrCopy } from "@/utils/browserUtil";
-import {
- MobilePage,
- MobilePageContent,
- MobilePageDescription,
- MobilePageHeader,
- MobilePageTitle,
-} from "@/components/mobile/MobilePage";
-import { DownloadVideoButton } from "@/components/button/DownloadVideoButton";
-import { TooltipPortal } from "@radix-ui/react-tooltip";
-import { LuSearch } from "react-icons/lu";
-import useKeyboardListener from "@/hooks/use-keyboard-listener";
-import { Trans, useTranslation } from "react-i18next";
-import { getTranslatedLabel } from "@/utils/i18n";
-import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
-
-type ReviewDetailDialogProps = {
- review?: ReviewSegment;
- setReview: (review: ReviewSegment | undefined) => void;
-};
-export default function ReviewDetailDialog({
- review,
- setReview,
-}: ReviewDetailDialogProps) {
- const { t } = useTranslation(["views/explore"]);
- const { data: config } = useSWR
("config", {
- revalidateOnFocus: false,
- });
-
- const navigate = useNavigate();
-
- // upload
-
- const [upload, setUpload] = useState();
-
- // data
-
- const { data: events } = useSWR(
- review ? ["event_ids", { ids: review.data.detections.join(",") }] : null,
- );
-
- const aiAnalysis = useMemo(() => review?.data?.metadata, [review]);
-
- const aiThreatLevel = useMemo(() => {
- if (
- !aiAnalysis ||
- (!aiAnalysis.potential_threat_level && !aiAnalysis.other_concerns)
- ) {
- return "None";
- }
-
- let concerns = "";
- switch (aiAnalysis.potential_threat_level) {
- case ThreatLevel.SUSPICIOUS:
- concerns = `• ${t("suspiciousActivity", { ns: "views/events" })}\n`;
- break;
- case ThreatLevel.DANGER:
- concerns = `• ${t("threateningActivity", { ns: "views/events" })}\n`;
- break;
- }
-
- (aiAnalysis.other_concerns ?? []).forEach((c) => {
- concerns += `• ${c}\n`;
- });
-
- return concerns || "None";
- }, [aiAnalysis, t]);
-
- const hasMismatch = useMemo(() => {
- if (!review || !events) {
- return false;
- }
-
- return events.length != review?.data.detections.length;
- }, [review, events]);
-
- const missingObjects = useMemo(() => {
- if (!review || !events) {
- return [];
- }
-
- const detectedIds = review.data.detections;
- const missing = Array.from(
- new Set(
- events
- .filter((event) => !detectedIds.includes(event.id))
- .map((event) => event.label),
- ),
- );
-
- return missing;
- }, [review, events]);
-
- const formattedDate = useFormattedTimestamp(
- review?.start_time ?? 0,
- config?.ui.time_format == "24hour"
- ? t("time.formattedTimestampMonthDayYearHourMinute.24hour", {
- ns: "common",
- })
- : t("time.formattedTimestampMonthDayYearHourMinute.12hour", {
- ns: "common",
- }),
- config?.ui.timezone,
- );
-
- // content
-
- const [selectedEvent, setSelectedEvent] = useState();
- const [pane, setPane] = useState("overview");
-
- // dialog and mobile page
-
- const [isOpen, setIsOpen] = useState(review != undefined);
-
- const handleOpenChange = useCallback(
- (open: boolean) => {
- setIsOpen(open);
- if (!open) {
- // short timeout to allow the mobile page animation
- // to complete before updating the state
- setTimeout(() => {
- setReview(undefined);
- setSelectedEvent(undefined);
- setPane("overview");
- }, 300);
- }
- },
- [setReview, setIsOpen],
- );
-
- useEffect(() => {
- setIsOpen(review != undefined);
- // we know that these deps are correct
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [review]);
-
- // keyboard listener
-
- useKeyboardListener(["Esc"], (key, modifiers) => {
- if (key == "Esc" && modifiers.down && !modifiers.repeat) {
- setIsOpen(false);
- }
-
- return true;
- });
-
- const Overlay = isDesktop ? Sheet : MobilePage;
- const Content = isDesktop ? SheetContent : MobilePageContent;
- const Header = isDesktop ? SheetHeader : MobilePageHeader;
- const Title = isDesktop ? SheetTitle : MobilePageTitle;
- const Description = isDesktop ? SheetDescription : MobilePageDescription;
-
- if (!review) {
- return;
- }
-
- return (
- <>
-
- setUpload(undefined)}
- onEventUploaded={() => {
- if (upload) {
- upload.plus_id = "new_upload";
- }
- }}
- />
-
-
-
- {pane == "overview" && (
-
- )}
- {pane == "overview" && (
-
- {aiAnalysis != undefined && (
-
- {t("aiAnalysis.title")}
-
- {t("details.description.label")}
-
-
{aiAnalysis.scene}
-
- {t("details.score.label")}
-
-
{aiAnalysis.confidence * 100}%
-
- {t("concerns.label")}
-
-
{aiThreatLevel}
-
- )}
-
-
-
-
- {t("details.camera")}
-
-
-
-
-
-
-
- {t("details.timestamp")}
-
-
{formattedDate}
-
-
-
-
-
- {t("details.objects")}
-
-
- {events?.map((event) => {
- return (
-
- {getIconForLabel(
- event.label,
- "size-3 text-primary",
- )}
- {event.sub_label ??
- event.label.replaceAll("_", " ")}{" "}
- ({Math.round(event.data.top_score * 100)}%)
-
-
- {
- navigate(`/explore?event_id=${event.id}`);
- }}
- >
-
-
-
-
-
- {t("details.item.button.viewInExplore")}
-
-
-
-
- );
- })}
-
-
- {review.data.zones.length > 0 && (
-
-
- {t("details.zones")}
-
-
- {review.data.zones.map((zone) => {
- return (
-
- {zone.replaceAll("_", " ")}
-
- );
- })}
-
-
- )}
-
-
- {hasMismatch && (
-
- {(() => {
- const detectedCount = Math.abs(
- (events?.length ?? 0) -
- (review?.data.detections.length ?? 0),
- );
-
- return t("details.item.tips.mismatch", {
- count: detectedCount,
- });
- })()}
- {missingObjects.length > 0 && (
-
- getTranslatedLabel(x))
- .join(", "),
- }}
- >
- details.item.tips.hasMissingObjects
-
-
- )}
-
- )}
-
- {events?.map((event) => (
-
- ))}
-
-
- )}
-
- {pane == "details" && selectedEvent && (
-
-
-
- )}
-
-
- >
- );
-}
-
-type EventItemProps = {
- event: Event;
- setPane: React.Dispatch>;
- setSelectedEvent: React.Dispatch>;
- setUpload?: React.Dispatch>;
-};
-
-function EventItem({
- event,
- setPane,
- setSelectedEvent,
- setUpload,
-}: EventItemProps) {
- const { t } = useTranslation(["views/explore"]);
-
- const { data: config } = useSWR("config", {
- revalidateOnFocus: false,
- });
-
- const apiHost = useApiHost();
-
- const imgRef = useRef(null);
-
- const [hovered, setHovered] = useState(isMobile);
-
- const navigate = useNavigate();
-
- return (
- <>
- setHovered(true) : undefined}
- onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
- key={event.id}
- >
- {event.has_snapshot && (
- <>
-
-
- >
- )}
-

- {hovered && (
-
-
-
-
-
-
-
-
-
-
-
- {t("button.download", { ns: "common" })}
-
-
-
- {event.has_snapshot &&
- event.plus_id == undefined &&
- event.data.type == "object" &&
- config?.plus.enabled && (
-
-
- {
- setUpload?.(event);
- }}
- >
-
-
-
-
- {t("itemMenu.submitToPlus.label")}
-
-
- )}
-
- {event.has_clip && (
-
-
- {
- setPane("details");
- setSelectedEvent(event);
- }}
- >
-
-
-
-
- {t("itemMenu.viewTrackingDetails.label")}
-
-
- )}
-
- {event.has_snapshot && config?.semantic_search.enabled && (
-
-
- {
- navigate(
- `/explore?search_type=similarity&event_id=${event.id}`,
- );
- }}
- >
-
-
-
-
- {t("itemMenu.findSimilar.label")}
-
-
- )}
-
-
- )}
-
- >
- );
-}
diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx
index 0c0339793..ee63a0c50 100644
--- a/web/src/components/overlay/detail/SearchDetailDialog.tsx
+++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx
@@ -31,10 +31,9 @@ import {
FaDownload,
FaHistory,
FaImage,
- FaRegListAlt,
- FaVideo,
} from "react-icons/fa";
-import TrackingDetails from "./TrackingDetails";
+import { TrackingDetails } from "./TrackingDetails";
+import { DetailStreamProvider } from "@/context/detail-stream-context";
import {
MobilePage,
MobilePageContent,
@@ -80,13 +79,9 @@ import { getTranslatedLabel } from "@/utils/i18n";
import { CgTranscript } from "react-icons/cg";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { PiPath } from "react-icons/pi";
+import Heading from "@/components/ui/heading";
-const SEARCH_TABS = [
- "details",
- "snapshot",
- "video",
- "tracking_details",
-] as const;
+const SEARCH_TABS = ["snapshot", "tracking_details"] as const;
export type SearchTab = (typeof SEARCH_TABS)[number];
type SearchDetailDialogProps = {
@@ -109,6 +104,7 @@ export default function SearchDetailDialog({
const { data: config } = useSWR("config", {
revalidateOnFocus: false,
});
+ const apiHost = useApiHost();
// tabs
@@ -149,16 +145,6 @@ export default function SearchDetailDialog({
const views = [...SEARCH_TABS];
- if (!search.has_snapshot) {
- const index = views.indexOf("snapshot");
- views.splice(index, 1);
- }
-
- if (!search.has_clip) {
- const index = views.indexOf("video");
- views.splice(index, 1);
- }
-
if (search.data.type != "object" || !search.has_clip) {
const index = views.indexOf("tracking_details");
views.splice(index, 1);
@@ -173,10 +159,50 @@ export default function SearchDetailDialog({
}
if (!searchTabs.includes(pageToggle)) {
- setSearchPage("details");
+ setSearchPage("snapshot");
}
}, [pageToggle, searchTabs, setSearchPage]);
+ // Tabs component for reuse
+ const tabsComponent = (
+
+
+
{
+ if (value) {
+ setPageToggle(value);
+ }
+ }}
+ >
+ {Object.values(searchTabs).map((item) => (
+
+ {item == "snapshot" && }
+ {item == "tracking_details" && }
+
+ {item === "snapshot"
+ ? search?.has_snapshot
+ ? t("type.snapshot")
+ : t("type.thumbnail")
+ : t(`type.${item}`)}
+
+
+ ))}
+
+
+
+
+ );
+
if (!search) {
return;
}
@@ -190,92 +216,188 @@ export default function SearchDetailDialog({
const Description = isDesktop ? DialogDescription : MobilePageDescription;
return (
-
-
-
- {t("trackedObjectDetails")}
-
- {t("trackedObjectDetails")}
-
-
-
-
-
{
- if (value) {
- setPageToggle(value);
- }
- }}
- >
- {Object.values(searchTabs).map((item) => (
-
+ {t("trackedObjectDetails")}
+
+ {t("trackedObjectDetails")}
+
+
+ {isDesktop ? (
+ page === "tracking_details" ? (
+
+ ) : (
+
+
- {item == "details" &&
}
- {item == "snapshot" &&
}
- {item == "video" &&
}
- {item == "tracking_details" &&
}
-
{t(`type.${item}`)}
-
- ))}
-
-
-
-
- {page == "details" && (
-
- )}
- {page == "snapshot" && (
-
{
- search.plus_id = "new_upload";
- }}
- />
- )}
- {page == "video" && }
- {page == "tracking_details" && (
- {}}
- />
- )}
-
-
+ {page === "snapshot" && search.has_snapshot && (
+ {
+ search.plus_id = "new_upload";
+ }}
+ />
+ )}
+ {page === "snapshot" && !search.has_snapshot && (
+
+ )}
+
+
+ {tabsComponent}
+
+ {page == "snapshot" && (
+
+ )}
+
+
+
+ )
+ ) : (
+ <>
+
+
+
{
+ if (value) {
+ setPageToggle(value);
+ }
+ }}
+ >
+ {Object.values(searchTabs).map((item) => (
+
+ {item == "snapshot" && }
+ {item == "tracking_details" && (
+
+ )}
+
+ {t(`type.${item}`)}
+
+
+ ))}
+
+
+
+
+ {page == "snapshot" && (
+ <>
+ {search.has_snapshot && (
+ {
+ search.plus_id = "new_upload";
+ }}
+ />
+ )}
+ {page == "snapshot" && !search.has_snapshot && (
+
+ )}
+
+ {t("type.details")}
+
+
+ >
+ )}
+ {page == "tracking_details" && (
+
+ )}
+ >
+ )}
+
+
+
);
}
@@ -285,6 +407,7 @@ type ObjectDetailsTabProps = {
setSearch: (search: SearchResult | undefined) => void;
setSimilarity?: () => void;
setInputFocused: React.Dispatch>;
+ showThumbnail?: boolean;
};
function ObjectDetailsTab({
search,
@@ -292,6 +415,7 @@ function ObjectDetailsTab({
setSearch,
setSimilarity,
setInputFocused,
+ showThumbnail = true,
}: ObjectDetailsTabProps) {
const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
@@ -873,66 +997,71 @@ function ObjectDetailsTab({
{formattedDate}
-
-

-
- {config?.semantic_search.enabled &&
- setSimilarity != undefined &&
- search.data.type == "object" && (
-
-
+ )}
{config?.cameras[search.camera].objects.genai.enabled &&
@@ -1167,7 +1296,7 @@ export function ObjectSnapshotTab({
search.label != "on_demand" && (
-
+
{t("explore.plus.submitToPlus.label")}
@@ -1176,7 +1305,7 @@ export function ObjectSnapshotTab({
-
+
{state == "reviewing" && (
<>
diff --git a/web/src/components/overlay/detail/TrackingDetails.tsx b/web/src/components/overlay/detail/TrackingDetails.tsx
index 77dce19e9..82fb14771 100644
--- a/web/src/components/overlay/detail/TrackingDetails.tsx
+++ b/web/src/components/overlay/detail/TrackingDetails.tsx
@@ -5,29 +5,11 @@ 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 { LuCircle, LuSettings } from "react-icons/lu";
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,
@@ -35,12 +17,10 @@ import {
} 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 HlsVideoPlayer from "@/components/player/HlsVideoPlayer";
+import { baseUrl } from "@/api/baseUrl";
+import { REVIEW_PADDING } from "@/types/review";
+import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -49,30 +29,40 @@ import {
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";
+import { useDetailStream } from "@/context/detail-stream-context";
+import { isDesktop, isIOS } from "react-device-detect";
+import Chip from "@/components/indicators/Chip";
+import { FaDownload, FaHistory } from "react-icons/fa";
type TrackingDetailsProps = {
className?: string;
event: Event;
fullscreen?: boolean;
- setPane: React.Dispatch
>;
+ tabs?: React.ReactNode;
};
-export default function TrackingDetails({
+export function TrackingDetails({
className,
event,
- fullscreen = false,
- setPane,
+ tabs,
}: TrackingDetailsProps) {
+ const videoRef = useRef(null);
const { t } = useTranslation(["views/explore"]);
+ const navigate = useNavigate();
+ const { setSelectedObjectIds, annotationOffset, setAnnotationOffset } =
+ useDetailStream();
+
+ // event.start_time is detect time, convert to record, then subtract padding
+ const [currentTime, setCurrentTime] = useState(
+ (event.start_time ?? 0) + annotationOffset / 1000 - REVIEW_PADDING,
+ );
const { data: eventSequence } = useSWR([
"timeline",
@@ -82,16 +72,17 @@ export default function TrackingDetails({
]);
const { data: config } = useSWR("config");
- const apiHost = useApiHost();
- const navigate = useNavigate();
- const [imgLoaded, setImgLoaded] = useState(false);
- const imgRef = useRef(null);
+ const effectiveTime = useMemo(() => {
+ return currentTime - annotationOffset / 1000;
+ }, [currentTime, annotationOffset]);
- const [selectedZone, setSelectedZone] = useState("");
- const [lifecycleZones, setLifecycleZones] = useState([]);
+ const containerRef = useRef(null);
+ const [_selectedZone, setSelectedZone] = useState("");
+ const [_lifecycleZones, setLifecycleZones] = useState([]);
const [showControls, setShowControls] = useState(false);
const [showZones, setShowZones] = useState(true);
+ const [seekToTimestamp, setSeekToTimestamp] = useState(null);
const aspectRatio = useMemo(() => {
if (!config) {
@@ -120,178 +111,27 @@ export default function TrackingDetails({
[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);
-
+ // Set the selected object ID in the context so ObjectTrackOverlay can display it
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]);
+ setSelectedObjectIds([event.id]);
+ }, [event.id, setSelectedObjectIds]);
- // carousels
+ const handleLifecycleClick = useCallback(
+ (item: TrackingDetailsSequence) => {
+ if (!videoRef.current) return;
- // Selected lifecycle item index; -1 when viewing a path-only point
+ // Convert lifecycle timestamp (detect stream) to record stream time
+ const targetTimeRecord = item.timestamp + annotationOffset / 1000;
- 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([]);
- }
+ // Convert to video-relative time for seeking
+ const eventStartRecord =
+ (event.start_time ?? 0) + annotationOffset / 1000;
+ const videoStartTime = eventStartRecord - REVIEW_PADDING;
+ const relativeTime = targetTimeRecord - videoStartTime;
+
+ videoRef.current.currentTime = relativeTime;
},
- [eventSequence, pathPoints, handleSetBox],
+ [event.start_time, annotationOffset],
);
const formattedStart = config
@@ -328,53 +168,38 @@ export default function TrackingDetails({
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]);
+ setLifecycleZones(eventSequence[0]?.data.zones);
+ }, [eventSequence]);
- // 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([]);
+ if (seekToTimestamp === null || !videoRef.current) return;
+
+ // seekToTimestamp is a record stream timestamp
+ // event.start_time is detect stream time, convert to record
+ // The video clip starts at (eventStartRecord - REVIEW_PADDING)
+ const eventStartRecord = event.start_time + annotationOffset / 1000;
+ const videoStartTime = eventStartRecord - REVIEW_PADDING;
+ const relativeTime = seekToTimestamp - videoStartTime;
+ if (relativeTime >= 0) {
+ videoRef.current.currentTime = relativeTime;
}
- }, [timeIndex, imgLoaded, eventSequence, handleSetBox]);
+ setSeekToTimestamp(null);
+ }, [seekToTimestamp, event.start_time, annotationOffset]);
- 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 isWithinEventRange =
+ effectiveTime !== undefined &&
+ event.start_time !== undefined &&
+ event.end_time !== undefined &&
+ effectiveTime >= event.start_time &&
+ effectiveTime <= event.end_time;
- 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 effectiveTime
+ const calculateLineHeight = useCallback(() => {
+ if (!eventSequence || eventSequence.length === 0 || !isWithinEventRange) {
+ return 0;
+ }
- // 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;
+ const currentTime = effectiveTime ?? 0;
// Find which events have been passed
let lastPassedIndex = -1;
@@ -412,352 +237,356 @@ export default function TrackingDetails({
100,
lastPassedIndex * itemPercentage + interpolation * itemPercentage,
);
- };
+ }, [eventSequence, effectiveTime, isWithinEventRange]);
const blueLineHeight = calculateLineHeight();
+ const videoSource = useMemo(() => {
+ // event.start_time and event.end_time are in DETECT stream time
+ // Convert to record stream time, then create video clip with padding
+ const eventStartRecord = event.start_time + annotationOffset / 1000;
+ const eventEndRecord =
+ (event.end_time ?? Date.now() / 1000) + annotationOffset / 1000;
+ const startTime = eventStartRecord - REVIEW_PADDING;
+ const endTime = eventEndRecord + REVIEW_PADDING;
+ const playlist = `${baseUrl}vod/${event.camera}/start/${startTime}/end/${endTime}/index.m3u8`;
+
+ return {
+ playlist,
+ startPosition: 0,
+ };
+ }, [event, annotationOffset]);
+
+ // Determine camera aspect ratio category
+ const cameraAspect = useMemo(() => {
+ if (!aspectRatio) {
+ return "normal";
+ } else if (aspectRatio > ASPECT_WIDE_LAYOUT) {
+ return "wide";
+ } else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) {
+ return "tall";
+ } else {
+ return "normal";
+ }
+ }, [aspectRatio]);
+
+ const handleSeekToTime = useCallback((timestamp: number, _play?: boolean) => {
+ // Set the target timestamp to seek to
+ setSeekToTimestamp(timestamp);
+ }, []);
+
+ const handleTimeUpdate = useCallback(
+ (time: number) => {
+ // event.start_time is detect stream time, convert to record
+ const eventStartRecord = event.start_time + annotationOffset / 1000;
+ const videoStartTime = eventStartRecord - REVIEW_PADDING;
+ const absoluteTime = time + videoStartTime;
+
+ setCurrentTime(absoluteTime);
+ },
+ [event.start_time, annotationOffset],
+ );
+
if (!config) {
return ;
}
return (
-
-
- {!fullscreen && (
-
-
setPane("overview")}
- >
-
- {isDesktop && (
-
- {t("button.back", { ns: "common" })}
-
- )}
-
-
+
+
-
- {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")}
-
-
-
-
-
- setShowControls(!showControls)}
- />
-
-
-
-
- {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}
- />
- );
- })}
-
-
+
+
+ {event && (
+
+
+ {
+ if (event?.id) {
+ const params = new URLSearchParams({
+ id: event.id,
+ }).toString();
+ navigate(`/review?${params}`);
+ }
+ }}
+ >
+
+
+
+
+
+ {t("itemMenu.viewInHistory.label")}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {t("button.download", { ns: "common" })}
+
+
+
+
+
+
+
+
+ {isDesktop && tabs &&
{tabs}
}
+
+
+
{t("trackingDetails.title")}
+
+
+
+
+
+ setShowControls(!showControls)}
+ />
+
+
+
+
+ {t("trackingDetails.adjustAnnotationSettings")}
+
+
+
+
+
+
+
+ {t("trackingDetails.scrollViewTips")}
+
+
+ {t("trackingDetails.count", {
+ first: eventSequence?.length ?? 0,
+ second: eventSequence?.length ?? 0,
+ })}
+
+
+ {config?.cameras[event.camera]?.onvif.autotracking
+ .enabled_in_config && (
+
+ {t("trackingDetails.autoTrackingTips")}
+
+ )}
+ {showControls && (
+
{
+ if (typeof value === "function") {
+ const newValue = value(annotationOffset);
+ setAnnotationOffset(newValue);
+ } else {
+ setAnnotationOffset(value);
+ }
+ }}
+ />
+ )}
+
+
+
+
+
{
+ e.stopPropagation();
+ // event.start_time is detect time, convert to record
+ handleSeekToTime(
+ (event.start_time ?? 0) + annotationOffset / 1000,
+ );
+ }}
+ 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" })}
+
+ ) : (
+
+
+ {isWithinEventRange && (
+
+ )}
+
+ {eventSequence.map((item, idx) => {
+ const isActive =
+ Math.abs(
+ (effectiveTime ?? 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 (
+ handleLifecycleClick(item)}
+ setSelectedZone={setSelectedZone}
+ getZoneColor={getZoneColor}
+ effectiveTime={effectiveTime}
+ isTimelineActive={isWithinEventRange}
+ />
+ );
+ })}
+
+
+ )}
+
+
@@ -765,44 +594,6 @@ export default function TrackingDetails({
);
}
-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;
@@ -813,6 +604,8 @@ type LifecycleIconRowProps = {
onClick: () => void;
setSelectedZone: (z: string) => void;
getZoneColor: (zoneName: string) => number[] | undefined;
+ effectiveTime?: number;
+ isTimelineActive?: boolean;
};
function LifecycleIconRow({
@@ -825,6 +618,8 @@ function LifecycleIconRow({
onClick,
setSelectedZone,
getZoneColor,
+ effectiveTime,
+ isTimelineActive,
}: LifecycleIconRowProps) {
const { t } = useTranslation(["views/explore", "components/player"]);
const { data: config } = useSWR
("config");
@@ -837,17 +632,19 @@ function LifecycleIconRow({
role="button"
onClick={onClick}
className={cn(
- "rounded-md p-2 text-sm text-primary-variant",
+ "rounded-md p-2 pr-0 text-sm text-primary-variant",
isActive && "bg-secondary-highlight font-semibold text-primary",
!isActive && "duration-500",
)}
>
-
+
= (item?.timestamp ?? 0)) &&
+ isTimelineActive &&
+ "fill-selected duration-300",
)}
/>
diff --git a/web/src/components/timeline/DetailStream.tsx b/web/src/components/timeline/DetailStream.tsx
index b5ce70446..e8d609cdb 100644
--- a/web/src/components/timeline/DetailStream.tsx
+++ b/web/src/components/timeline/DetailStream.tsx
@@ -57,7 +57,7 @@ export default function DetailStream({
elementRef: scrollRef,
});
- const effectiveTime = currentTime + annotationOffset / 1000;
+ const effectiveTime = currentTime - annotationOffset / 1000;
const [upload, setUpload] = useState
(undefined);
const [controlsExpanded, setControlsExpanded] = useState(false);
const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence(
@@ -213,6 +213,7 @@ export default function DetailStream({
config={config}
onSeek={onSeekCheckPlaying}
effectiveTime={effectiveTime}
+ annotationOffset={annotationOffset}
isActive={activeReviewId == id}
onActivate={() => setActiveReviewId(id)}
onOpenUpload={(e) => setUpload(e)}
@@ -278,6 +279,7 @@ type ReviewGroupProps = {
onActivate?: () => void;
onOpenUpload?: (e: Event) => void;
effectiveTime?: number;
+ annotationOffset: number;
alwaysExpandActive?: boolean;
};
@@ -290,11 +292,14 @@ function ReviewGroup({
onActivate,
onOpenUpload,
effectiveTime,
+ annotationOffset,
alwaysExpandActive = false,
}: ReviewGroupProps) {
const { t } = useTranslation("views/events");
const [open, setOpen] = useState(false);
const start = review.start_time ?? 0;
+ // review.start_time is in detect time, convert to record for seeking
+ const startRecord = start + annotationOffset / 1000;
// Auto-expand when this review becomes active and alwaysExpandActive is enabled
useEffect(() => {
@@ -371,7 +376,7 @@ function ReviewGroup({
)}
onClick={() => {
onActivate?.();
- onSeek(start);
+ onSeek(startRecord);
}}
>
@@ -450,6 +455,7 @@ function ReviewGroup({
key={event.id}
event={event}
effectiveTime={effectiveTime}
+ annotationOffset={annotationOffset}
onSeek={onSeek}
onOpenUpload={onOpenUpload}
/>
@@ -483,12 +489,14 @@ function ReviewGroup({
type EventListProps = {
event: Event;
effectiveTime?: number;
+ annotationOffset: number;
onSeek: (ts: number, play?: boolean) => void;
onOpenUpload?: (e: Event) => void;
};
function EventList({
event,
effectiveTime,
+ annotationOffset,
onSeek,
onOpenUpload,
}: EventListProps) {
@@ -505,14 +513,17 @@ function EventList({
if (event) {
setSelectedObjectIds([]);
setSelectedObjectIds([event.id]);
- onSeek(event.start_time);
+ // event.start_time is detect time, convert to record
+ const recordTime = event.start_time + annotationOffset / 1000;
+ onSeek(recordTime);
} else {
setSelectedObjectIds([]);
}
};
const handleTimelineClick = (ts: number, play?: boolean) => {
- handleObjectSelect(event);
+ setSelectedObjectIds([]);
+ setSelectedObjectIds([event.id]);
onSeek(ts, play);
};
@@ -554,7 +565,6 @@ function EventList({
)}
onClick={(e) => {
e.stopPropagation();
- onSeek(event.start_time);
handleObjectSelect(event);
}}
role="button"
@@ -568,7 +578,6 @@ function EventList({
className="flex flex-1 items-center gap-2"
onClick={(e) => {
e.stopPropagation();
- onSeek(event.start_time);
handleObjectSelect(event);
}}
role="button"
@@ -607,6 +616,7 @@ function EventList({
eventId={event.id}
onSeek={handleTimelineClick}
effectiveTime={effectiveTime}
+ annotationOffset={annotationOffset}
startTime={event.start_time}
endTime={event.end_time}
/>
@@ -621,6 +631,7 @@ type LifecycleItemProps = {
isActive?: boolean;
onSeek?: (timestamp: number, play?: boolean) => void;
effectiveTime?: number;
+ annotationOffset: number;
isTimelineActive?: boolean;
};
@@ -629,6 +640,7 @@ function LifecycleItem({
isActive,
onSeek,
effectiveTime,
+ annotationOffset,
isTimelineActive = false,
}: LifecycleItemProps) {
const { t } = useTranslation("views/events");
@@ -682,7 +694,8 @@ function LifecycleItem({
{
- onSeek?.(item.timestamp, false);
+ const recordTimestamp = item.timestamp + annotationOffset / 1000;
+ onSeek?.(recordTimestamp, false);
}}
className={cn(
"flex cursor-pointer items-center gap-2 text-sm text-primary-variant",
@@ -751,12 +764,14 @@ function ObjectTimeline({
eventId,
onSeek,
effectiveTime,
+ annotationOffset,
startTime,
endTime,
}: {
eventId: string;
onSeek: (ts: number, play?: boolean) => void;
effectiveTime?: number;
+ annotationOffset: number;
startTime?: number;
endTime?: number;
}) {
@@ -857,6 +872,7 @@ function ObjectTimeline({
onSeek={onSeek}
isActive={isActive}
effectiveTime={effectiveTime}
+ annotationOffset={annotationOffset}
isTimelineActive={isWithinEventRange}
/>
);
diff --git a/web/src/context/detail-stream-context.tsx b/web/src/context/detail-stream-context.tsx
index c1faae12c..ff909a30e 100644
--- a/web/src/context/detail-stream-context.tsx
+++ b/web/src/context/detail-stream-context.tsx
@@ -22,6 +22,7 @@ interface DetailStreamProviderProps {
isDetailMode: boolean;
currentTime: number;
camera: string;
+ initialSelectedObjectIds?: string[];
}
export function DetailStreamProvider({
@@ -29,8 +30,11 @@ export function DetailStreamProvider({
isDetailMode,
currentTime,
camera,
+ initialSelectedObjectIds,
}: DetailStreamProviderProps) {
- const [selectedObjectIds, setSelectedObjectIds] = useState([]);
+ const [selectedObjectIds, setSelectedObjectIds] = useState(
+ () => initialSelectedObjectIds ?? [],
+ );
const toggleObjectSelection = (id: string | undefined) => {
if (id === undefined) {
diff --git a/web/src/views/classification/ModelTrainingView.tsx b/web/src/views/classification/ModelTrainingView.tsx
index 7c2a4aa05..5d60ce56c 100644
--- a/web/src/views/classification/ModelTrainingView.tsx
+++ b/web/src/views/classification/ModelTrainingView.tsx
@@ -893,7 +893,7 @@ function ObjectTrainGrid({
// selection
const [selectedEvent, setSelectedEvent] = useState();
- const [dialogTab, setDialogTab] = useState("details");
+ const [dialogTab, setDialogTab] = useState("snapshot");
// handlers
diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx
index 4a824b4e8..a96d7bbb6 100644
--- a/web/src/views/search/SearchView.tsx
+++ b/web/src/views/search/SearchView.tsx
@@ -214,7 +214,7 @@ export default function SearchView({
// detail
const [searchDetail, setSearchDetail] = useState();
- const [page, setPage] = useState("details");
+ const [page, setPage] = useState("snapshot");
// search interaction
@@ -222,7 +222,7 @@ export default function SearchView({
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const onSelectSearch = useCallback(
- (item: SearchResult, ctrl: boolean, page: SearchTab = "details") => {
+ (item: SearchResult, ctrl: boolean, page: SearchTab = "snapshot") => {
if (selectedObjects.length > 1 || ctrl) {
const index = selectedObjects.indexOf(item.id);