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" && ( -
- {t("details.item.title")} - - {t("details.item.desc")} - -
- - - - - - - {t("details.item.button.share")} - - - - - - - - - - {t("button.download", { ns: "common" })} - - - -
-
- )} - {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" && ( - + )} + {hasFace && ( + { - setSearch(undefined); - setSimilarity(); - }} + faceNames={faceNames} + onTrainAttempt={onTrainFace} > -
- - {t("itemMenu.findSimilar.label")} -
- - )} - {hasFace && ( - - - - )} - {config?.cameras[search?.camera].audio_transcription.enabled && - search?.label == "speech" && - search?.end_time && ( - + +
)} + {config?.cameras[search?.camera].audio_transcription.enabled && + search?.label == "speech" && + search?.end_time && ( + + )} +
- + )}
{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 && ( -
- -
+
+
- - {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} - /> - ); - })} -
-
+ +
+ {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")} + +
+ + + + + + + {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);