Refactor Tracked Object Details dialog (#20748)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* 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
This commit is contained in:
Josh Hawkins 2025-11-01 09:19:30 -05:00 committed by GitHub
parent 9937a7cc3d
commit 36fb27ef56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 766 additions and 1387 deletions

View File

@ -33,6 +33,7 @@
"type": { "type": {
"details": "details", "details": "details",
"snapshot": "snapshot", "snapshot": "snapshot",
"thumbnail": "thumbnail",
"video": "video", "video": "video",
"object_lifecycle": "object lifecycle" "object_lifecycle": "object lifecycle"
}, },
@ -41,7 +42,7 @@
"noImageFound": "No image found for this timestamp.", "noImageFound": "No image found for this timestamp.",
"createObjectMask": "Create Object Mask", "createObjectMask": "Create Object Mask",
"adjustAnnotationSettings": "Adjust annotation settings", "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.", "autoTrackingTips": "Bounding box positions will be inaccurate for autotracking cameras.",
"count": "{{first}} of {{second}}", "count": "{{first}} of {{second}}",
"trackedPoint": "Tracked Point", "trackedPoint": "Tracked Point",

View File

@ -13,6 +13,9 @@ import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
// Use a small tolerance (10ms) for browsers with seek precision by-design issues
const TOLERANCE = 0.01;
type ObjectTrackOverlayProps = { type ObjectTrackOverlayProps = {
camera: string; camera: string;
showBoundingBoxes?: boolean; showBoundingBoxes?: boolean;
@ -166,38 +169,42 @@ export default function ObjectTrackOverlay({
}) || []; }) || [];
// show full path once current time has reached the object's start time // show full path once current time has reached the object's start time
const combinedPoints = [...savedPathPoints, ...eventSequencePoints] // event.start_time is in DETECT stream time, so convert it to record stream time for comparison
.sort((a, b) => a.timestamp - b.timestamp) const eventStartTimeRecord =
.filter( (eventData?.start_time ?? 0) + annotationOffset / 1000;
const allPoints = [...savedPathPoints, ...eventSequencePoints].sort(
(a, b) => a.timestamp - b.timestamp,
);
const combinedPoints = allPoints.filter(
(point) => (point) =>
currentTime >= (eventData?.start_time ?? 0) && currentTime >= eventStartTimeRecord - TOLERANCE &&
point.timestamp >= (eventData?.start_time ?? 0) && point.timestamp <= effectiveCurrentTime + TOLERANCE,
point.timestamp <= (eventData?.end_time ?? Infinity),
); );
// Get color for this object // Get color for this object
const label = eventData?.label || "unknown"; const label = eventData?.label || "unknown";
const color = getObjectColor(label, objectId); const color = getObjectColor(label, objectId);
// Get current zones // zones (with tolerance for browsers with seek precision by-design issues)
const currentZones = const currentZones =
timelineData timelineData
?.filter( ?.filter(
(event: TrackingDetailsSequence) => (event: TrackingDetailsSequence) =>
event.timestamp <= effectiveCurrentTime, event.timestamp <= effectiveCurrentTime + TOLERANCE,
) )
.sort( .sort(
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) => (a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
b.timestamp - a.timestamp, b.timestamp - a.timestamp,
)[0]?.data?.zones || []; )[0]?.data?.zones || [];
// Get current bounding box // bounding box (with tolerance for browsers with seek precision by-design issues)
const currentBox = timelineData const boxCandidates = timelineData?.filter(
?.filter(
(event: TrackingDetailsSequence) => (event: TrackingDetailsSequence) =>
event.timestamp <= effectiveCurrentTime && event.data.box, event.timestamp <= effectiveCurrentTime + TOLERANCE &&
) event.data.box,
.sort( );
const currentBox = boxCandidates?.sort(
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) => (a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
b.timestamp - a.timestamp, b.timestamp - a.timestamp,
)[0]?.data?.box; )[0]?.data?.box;
@ -221,6 +228,7 @@ export default function ObjectTrackOverlay({
getObjectColor, getObjectColor,
config, config,
camera, camera,
annotationOffset,
]); ]);
// Collect all zones across all objects // Collect all zones across all objects
@ -274,9 +282,10 @@ export default function ObjectTrackOverlay({
const handlePointClick = useCallback( const handlePointClick = useCallback(
(timestamp: number) => { (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(() => { const zonePolygons = useMemo(() => {

View File

@ -91,8 +91,8 @@ export default function AnnotationOffsetSlider({ className }: Props) {
<div className="w-full flex-1 landscape:flex"> <div className="w-full flex-1 landscape:flex">
<Slider <Slider
value={[annotationOffset]} value={[annotationOffset]}
min={-1500} min={-2500}
max={1500} max={2500}
step={50} step={50}
onValueChange={handleChange} onValueChange={handleChange}
/> />

View File

@ -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<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const navigate = useNavigate();
// upload
const [upload, setUpload] = useState<Event>();
// data
const { data: events } = useSWR<Event[]>(
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<Event>();
const [pane, setPane] = useState<ReviewDetailPaneType>("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 (
<>
<Overlay
open={isOpen ?? false}
onOpenChange={handleOpenChange}
enableHistoryBack={true}
>
<FrigatePlusDialog
upload={upload}
onClose={() => setUpload(undefined)}
onEventUploaded={() => {
if (upload) {
upload.plus_id = "new_upload";
}
}}
/>
<Content
className={cn(
"scrollbar-container overflow-y-auto",
isDesktop && pane == "overview"
? "sm:max-w-xl"
: "pt-2 sm:max-w-4xl",
isMobile && "px-4",
)}
>
<span tabIndex={0} className="sr-only" />
{pane == "overview" && (
<Header className="justify-center">
<Title>{t("details.item.title")}</Title>
<Description className="sr-only">
{t("details.item.desc")}
</Description>
<div
className={cn(
"absolute flex gap-2 lg:flex-col",
isDesktop && "right-1 top-8",
isMobile && "right-0 top-3",
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("details.item.button.share")}
size="sm"
onClick={() =>
shareOrCopy(`${baseUrl}review?id=${review.id}`)
}
>
<FaShareAlt className="size-4 text-secondary-foreground" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("details.item.button.share")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<DownloadVideoButton
source={`${baseUrl}api/${review.camera}/start/${review.start_time}/end/${review.end_time || Date.now() / 1000}/clip.mp4`}
camera={review.camera}
startTime={review.start_time}
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("button.download", { ns: "common" })}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</Header>
)}
{pane == "overview" && (
<div className="flex flex-col gap-5 md:mt-3">
{aiAnalysis != undefined && (
<div
className={cn(
"flex h-full w-full flex-col gap-2 rounded-md bg-card p-2",
isDesktop && "m-2 w-[90%]",
)}
>
{t("aiAnalysis.title")}
<div className="text-sm text-primary/40">
{t("details.description.label")}
</div>
<div className="text-sm">{aiAnalysis.scene}</div>
<div className="text-sm text-primary/40">
{t("details.score.label")}
</div>
<div className="text-sm">{aiAnalysis.confidence * 100}%</div>
<div className="text-sm text-primary/40">
{t("concerns.label")}
</div>
<div className="text-sm">{aiThreatLevel}</div>
</div>
)}
<div className="flex w-full flex-row">
<div className="flex w-full flex-col gap-3">
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">
{t("details.camera")}
</div>
<div className="text-sm smart-capitalize">
<CameraNameLabel camera={review.camera} />
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">
{t("details.timestamp")}
</div>
<div className="text-sm">{formattedDate}</div>
</div>
</div>
<div className="flex w-full flex-col items-center gap-2">
<div className="flex w-full flex-col gap-1.5 lg:pr-8">
<div className="text-sm text-primary/40">
{t("details.objects")}
</div>
<div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-auto text-sm smart-capitalize">
{events?.map((event) => {
return (
<div
key={event.id}
className="flex flex-row items-center gap-2 smart-capitalize"
>
{getIconForLabel(
event.label,
"size-3 text-primary",
)}
{event.sub_label ??
event.label.replaceAll("_", " ")}{" "}
({Math.round(event.data.top_score * 100)}%)
<Tooltip>
<TooltipTrigger>
<div
className="cursor-pointer"
onClick={() => {
navigate(`/explore?event_id=${event.id}`);
}}
>
<LuSearch className="size-4 text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("details.item.button.viewInExplore")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
);
})}
</div>
</div>
{review.data.zones.length > 0 && (
<div className="scrollbar-container flex max-h-32 w-full flex-col gap-1.5">
<div className="text-sm text-primary/40">
{t("details.zones")}
</div>
<div className="flex flex-col items-start gap-2 text-sm smart-capitalize">
{review.data.zones.map((zone) => {
return (
<div
key={zone}
className="flex flex-row items-center gap-2 smart-capitalize"
>
{zone.replaceAll("_", " ")}
</div>
);
})}
</div>
</div>
)}
</div>
</div>
{hasMismatch && (
<div className="p-4 text-center text-sm">
{(() => {
const detectedCount = Math.abs(
(events?.length ?? 0) -
(review?.data.detections.length ?? 0),
);
return t("details.item.tips.mismatch", {
count: detectedCount,
});
})()}
{missingObjects.length > 0 && (
<div className="mt-2">
<Trans
ns="views/explore"
values={{
objects: missingObjects
.map((x) => getTranslatedLabel(x))
.join(", "),
}}
>
details.item.tips.hasMissingObjects
</Trans>
</div>
)}
</div>
)}
<div className="relative flex size-full flex-col gap-2">
{events?.map((event) => (
<EventItem
key={event.id}
event={event}
setPane={setPane}
setSelectedEvent={setSelectedEvent}
setUpload={setUpload}
/>
))}
</div>
</div>
)}
{pane == "details" && selectedEvent && (
<div className="mt-0 flex size-full flex-col gap-2">
<TrackingDetails event={selectedEvent} setPane={setPane} />
</div>
)}
</Content>
</Overlay>
</>
);
}
type EventItemProps = {
event: Event;
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>;
setSelectedEvent: React.Dispatch<React.SetStateAction<Event | undefined>>;
setUpload?: React.Dispatch<React.SetStateAction<Event | undefined>>;
};
function EventItem({
event,
setPane,
setSelectedEvent,
setUpload,
}: EventItemProps) {
const { t } = useTranslation(["views/explore"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const apiHost = useApiHost();
const imgRef = useRef(null);
const [hovered, setHovered] = useState(isMobile);
const navigate = useNavigate();
return (
<>
<div
className={cn(
"relative mr-auto",
!event.has_snapshot && "flex flex-row items-center justify-center",
)}
onMouseEnter={isDesktop ? () => setHovered(true) : undefined}
onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
key={event.id}
>
{event.has_snapshot && (
<>
<div className="pointer-events-none absolute inset-x-0 top-0 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl"></div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl"></div>
</>
)}
<img
ref={imgRef}
className={cn(
"select-none rounded-lg object-contain transition-opacity",
)}
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={
event.has_snapshot
? `${apiHost}api/events/${event.id}/snapshot.jpg`
: `${apiHost}api/events/${event.id}/thumbnail.webp`
}
/>
{hovered && (
<div>
<div
className={cn("absolute right-1 top-1 flex items-center gap-2")}
>
<Tooltip>
<TooltipTrigger asChild>
<a
download
href={
event.has_snapshot
? `${apiHost}api/events/${event.id}/snapshot.jpg`
: `${apiHost}api/events/${event.id}/thumbnail.webp`
}
>
<Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500">
<FaDownload className="size-4 text-white" />
</Chip>
</a>
</TooltipTrigger>
<TooltipContent>
{t("button.download", { ns: "common" })}
</TooltipContent>
</Tooltip>
{event.has_snapshot &&
event.plus_id == undefined &&
event.data.type == "object" &&
config?.plus.enabled && (
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
setUpload?.(event);
}}
>
<FrigatePlusIcon className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.submitToPlus.label")}
</TooltipContent>
</Tooltip>
)}
{event.has_clip && (
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
setPane("details");
setSelectedEvent(event);
}}
>
<FaArrowsRotate className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.viewTrackingDetails.label")}
</TooltipContent>
</Tooltip>
)}
{event.has_snapshot && config?.semantic_search.enabled && (
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
navigate(
`/explore?search_type=similarity&event_id=${event.id}`,
);
}}
>
<FaImages className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.findSimilar.label")}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
)}
</div>
</>
);
}

View File

@ -31,10 +31,9 @@ import {
FaDownload, FaDownload,
FaHistory, FaHistory,
FaImage, FaImage,
FaRegListAlt,
FaVideo,
} from "react-icons/fa"; } from "react-icons/fa";
import TrackingDetails from "./TrackingDetails"; import { TrackingDetails } from "./TrackingDetails";
import { DetailStreamProvider } from "@/context/detail-stream-context";
import { import {
MobilePage, MobilePage,
MobilePageContent, MobilePageContent,
@ -80,13 +79,9 @@ import { getTranslatedLabel } from "@/utils/i18n";
import { CgTranscript } from "react-icons/cg"; import { CgTranscript } from "react-icons/cg";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { PiPath } from "react-icons/pi"; import { PiPath } from "react-icons/pi";
import Heading from "@/components/ui/heading";
const SEARCH_TABS = [ const SEARCH_TABS = ["snapshot", "tracking_details"] as const;
"details",
"snapshot",
"video",
"tracking_details",
] as const;
export type SearchTab = (typeof SEARCH_TABS)[number]; export type SearchTab = (typeof SEARCH_TABS)[number];
type SearchDetailDialogProps = { type SearchDetailDialogProps = {
@ -109,6 +104,7 @@ export default function SearchDetailDialog({
const { data: config } = useSWR<FrigateConfig>("config", { const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false, revalidateOnFocus: false,
}); });
const apiHost = useApiHost();
// tabs // tabs
@ -149,16 +145,6 @@ export default function SearchDetailDialog({
const views = [...SEARCH_TABS]; 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) { if (search.data.type != "object" || !search.has_clip) {
const index = views.indexOf("tracking_details"); const index = views.indexOf("tracking_details");
views.splice(index, 1); views.splice(index, 1);
@ -173,10 +159,50 @@ export default function SearchDetailDialog({
} }
if (!searchTabs.includes(pageToggle)) { if (!searchTabs.includes(pageToggle)) {
setSearchPage("details"); setSearchPage("snapshot");
} }
}, [pageToggle, searchTabs, setSearchPage]); }, [pageToggle, searchTabs, setSearchPage]);
// Tabs component for reuse
const tabsComponent = (
<ScrollArea className="w-full whitespace-nowrap">
<div className="flex flex-row">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SearchTab) => {
if (value) {
setPageToggle(value);
}
}}
>
{Object.values(searchTabs).map((item) => (
<ToggleGroupItem
key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
data-nav-item={item}
aria-label={`Select ${item}`}
>
{item == "snapshot" && <FaImage className="size-4" />}
{item == "tracking_details" && <PiPath className="size-4" />}
<div className="smart-capitalize">
{item === "snapshot"
? search?.has_snapshot
? t("type.snapshot")
: t("type.thumbnail")
: t(`type.${item}`)}
</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
);
if (!search) { if (!search) {
return; return;
} }
@ -190,6 +216,12 @@ export default function SearchDetailDialog({
const Description = isDesktop ? DialogDescription : MobilePageDescription; const Description = isDesktop ? DialogDescription : MobilePageDescription;
return ( return (
<DetailStreamProvider
isDetailMode={true}
currentTime={(search as unknown as Event)?.start_time ?? 0}
camera={(search as unknown as Event)?.camera ?? ""}
initialSelectedObjectIds={[(search as unknown as Event).id as string]}
>
<Overlay <Overlay
open={isOpen} open={isOpen}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
@ -200,6 +232,9 @@ export default function SearchDetailDialog({
"scrollbar-container overflow-y-auto", "scrollbar-container overflow-y-auto",
isDesktop && isDesktop &&
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl", "max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
isDesktop &&
page == "tracking_details" &&
"lg:max-w-[75%] xl:max-w-[80%]",
isMobile && "px-4", isMobile && "px-4",
)} )}
> >
@ -209,6 +244,71 @@ export default function SearchDetailDialog({
{t("trackedObjectDetails")} {t("trackedObjectDetails")}
</Description> </Description>
</Header> </Header>
{isDesktop ? (
page === "tracking_details" ? (
<TrackingDetails
className="size-full"
event={search as unknown as Event}
tabs={tabsComponent}
/>
) : (
<div className="flex h-full gap-4 overflow-hidden">
<div
className={cn(
"scrollbar-container flex-[3] overflow-y-hidden",
page === "snapshot" && !search.has_snapshot && "flex-[2]",
)}
>
{page === "snapshot" && search.has_snapshot && (
<ObjectSnapshotTab
search={
{
...search,
plus_id: config?.plus?.enabled
? search.plus_id
: "not_enabled",
} as unknown as Event
}
onEventUploaded={() => {
search.plus_id = "new_upload";
}}
/>
)}
{page === "snapshot" && !search.has_snapshot && (
<img
className="size-full select-none rounded-lg object-contain transition-opacity"
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
/>
)}
</div>
<div className="flex flex-[2] flex-col gap-4 overflow-hidden">
{tabsComponent}
<div className="scrollbar-container flex-1 overflow-y-auto">
{page == "snapshot" && (
<ObjectDetailsTab
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
showThumbnail={false}
/>
)}
</div>
</div>
</div>
)
) : (
<>
<ScrollArea <ScrollArea
className={cn("w-full whitespace-nowrap", isMobile && "my-2")} className={cn("w-full whitespace-nowrap", isMobile && "my-2")}
> >
@ -227,37 +327,34 @@ export default function SearchDetailDialog({
{Object.values(searchTabs).map((item) => ( {Object.values(searchTabs).map((item) => (
<ToggleGroupItem <ToggleGroupItem
key={item} key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "details" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`} className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item} value={item}
data-nav-item={item} data-nav-item={item}
aria-label={`Select ${item}`} aria-label={`Select ${item}`}
> >
{item == "details" && <FaRegListAlt className="size-4" />}
{item == "snapshot" && <FaImage className="size-4" />} {item == "snapshot" && <FaImage className="size-4" />}
{item == "video" && <FaVideo className="size-4" />} {item == "tracking_details" && (
{item == "tracking_details" && <PiPath className="size-4" />} <PiPath className="size-4" />
<div className="smart-capitalize">{t(`type.${item}`)}</div> )}
<div className="smart-capitalize">
{t(`type.${item}`)}
</div>
</ToggleGroupItem> </ToggleGroupItem>
))} ))}
</ToggleGroup> </ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" /> <ScrollBar orientation="horizontal" className="h-0" />
</div> </div>
</ScrollArea> </ScrollArea>
{page == "details" && (
<ObjectDetailsTab
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
/>
)}
{page == "snapshot" && ( {page == "snapshot" && (
<>
{search.has_snapshot && (
<ObjectSnapshotTab <ObjectSnapshotTab
search={ search={
{ {
...search, ...search,
plus_id: config?.plus?.enabled ? search.plus_id : "not_enabled", plus_id: config?.plus?.enabled
? search.plus_id
: "not_enabled",
} as unknown as Event } as unknown as Event
} }
onEventUploaded={() => { onEventUploaded={() => {
@ -265,17 +362,42 @@ export default function SearchDetailDialog({
}} }}
/> />
)} )}
{page == "video" && <VideoTab search={search} />} {page == "snapshot" && !search.has_snapshot && (
{page == "tracking_details" && ( <img
<TrackingDetails className="w-full select-none rounded-lg object-contain transition-opacity"
className="w-full overflow-x-hidden" style={
event={search as unknown as Event} isIOS
fullscreen={true} ? {
setPane={() => {}} WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
/> />
)} )}
<Heading as="h3" className="mt-2 smart-capitalize">
{t("type.details")}
</Heading>
<ObjectDetailsTab
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
showThumbnail={false}
/>
</>
)}
{page == "tracking_details" && (
<TrackingDetails event={search as unknown as Event} />
)}
</>
)}
</Content> </Content>
</Overlay> </Overlay>
</DetailStreamProvider>
); );
} }
@ -285,6 +407,7 @@ type ObjectDetailsTabProps = {
setSearch: (search: SearchResult | undefined) => void; setSearch: (search: SearchResult | undefined) => void;
setSimilarity?: () => void; setSimilarity?: () => void;
setInputFocused: React.Dispatch<React.SetStateAction<boolean>>; setInputFocused: React.Dispatch<React.SetStateAction<boolean>>;
showThumbnail?: boolean;
}; };
function ObjectDetailsTab({ function ObjectDetailsTab({
search, search,
@ -292,6 +415,7 @@ function ObjectDetailsTab({
setSearch, setSearch,
setSimilarity, setSimilarity,
setInputFocused, setInputFocused,
showThumbnail = true,
}: ObjectDetailsTabProps) { }: ObjectDetailsTabProps) {
const { t } = useTranslation(["views/explore", "views/faceLibrary"]); const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
@ -873,6 +997,7 @@ function ObjectDetailsTab({
<div className="text-sm">{formattedDate}</div> <div className="text-sm">{formattedDate}</div>
</div> </div>
</div> </div>
{showThumbnail && (
<div className="flex w-full flex-col gap-2 pl-6"> <div className="flex w-full flex-col gap-2 pl-6">
<img <img
className="aspect-video select-none rounded-lg object-contain transition-opacity" className="aspect-video select-none rounded-lg object-contain transition-opacity"
@ -888,7 +1013,10 @@ function ObjectDetailsTab({
src={`${apiHost}api/events/${search.id}/thumbnail.webp`} src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
/> />
<div <div
className={cn("flex w-full flex-row gap-2", isMobile && "flex-col")} className={cn(
"flex w-full flex-row gap-2",
isMobile && "flex-col",
)}
> >
{config?.semantic_search.enabled && {config?.semantic_search.enabled &&
setSimilarity != undefined && setSimilarity != undefined &&
@ -933,6 +1061,7 @@ function ObjectDetailsTab({
)} )}
</div> </div>
</div> </div>
)}
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{config?.cameras[search.camera].objects.genai.enabled && {config?.cameras[search.camera].objects.genai.enabled &&
@ -1167,7 +1296,7 @@ export function ObjectSnapshotTab({
search.label != "on_demand" && ( search.label != "on_demand" && (
<Card className="p-1 text-sm md:p-2"> <Card className="p-1 text-sm md:p-2">
<CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row"> <CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row">
<div className={cn("flex flex-col space-y-3")}> <div className={cn("flex max-w-sm flex-col space-y-3")}>
<div className={"text-lg leading-none"}> <div className={"text-lg leading-none"}>
{t("explore.plus.submitToPlus.label")} {t("explore.plus.submitToPlus.label")}
</div> </div>
@ -1176,7 +1305,7 @@ export function ObjectSnapshotTab({
</div> </div>
</div> </div>
<div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:w-auto md:justify-end"> <div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:flex-1 md:justify-end">
{state == "reviewing" && ( {state == "reviewing" && (
<> <>
<div> <div>

View File

@ -5,29 +5,11 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { TrackingDetailsSequence } from "@/types/timeline"; import { TrackingDetailsSequence } from "@/types/timeline";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import { ReviewDetailPaneType } from "@/types/review";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import { import { LuCircle, LuSettings } from "react-icons/lu";
LuCircle,
LuCircleDot,
LuEar,
LuFolderX,
LuPlay,
LuSettings,
LuTruck,
} from "react-icons/lu";
import { IoMdArrowRoundBack, IoMdExit } from "react-icons/io";
import {
MdFaceUnlock,
MdOutlineLocationOn,
MdOutlinePictureInPictureAlt,
} from "react-icons/md";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useApiHost } from "@/api";
import { isDesktop, isIOS, isSafari } from "react-device-detect";
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -35,12 +17,10 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { AnnotationSettingsPane } from "./AnnotationSettingsPane"; import { AnnotationSettingsPane } from "./AnnotationSettingsPane";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import { import HlsVideoPlayer from "@/components/player/HlsVideoPlayer";
ContextMenu, import { baseUrl } from "@/api/baseUrl";
ContextMenuContent, import { REVIEW_PADDING } from "@/types/review";
ContextMenuItem, import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
@ -49,30 +29,40 @@ import {
DropdownMenuPortal, DropdownMenuPortal,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { ObjectPath } from "./ObjectPath";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { IoPlayCircleOutline } from "react-icons/io5";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HiDotsHorizontal } from "react-icons/hi"; import { HiDotsHorizontal } from "react-icons/hi";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; 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 = { type TrackingDetailsProps = {
className?: string; className?: string;
event: Event; event: Event;
fullscreen?: boolean; fullscreen?: boolean;
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>; tabs?: React.ReactNode;
}; };
export default function TrackingDetails({ export function TrackingDetails({
className, className,
event, event,
fullscreen = false, tabs,
setPane,
}: TrackingDetailsProps) { }: TrackingDetailsProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const { t } = useTranslation(["views/explore"]); 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<TrackingDetailsSequence[]>([ const { data: eventSequence } = useSWR<TrackingDetailsSequence[]>([
"timeline", "timeline",
@ -82,16 +72,17 @@ export default function TrackingDetails({
]); ]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const apiHost = useApiHost();
const navigate = useNavigate();
const [imgLoaded, setImgLoaded] = useState(false); const effectiveTime = useMemo(() => {
const imgRef = useRef<HTMLImageElement>(null); return currentTime - annotationOffset / 1000;
}, [currentTime, annotationOffset]);
const [selectedZone, setSelectedZone] = useState(""); const containerRef = useRef<HTMLDivElement | null>(null);
const [lifecycleZones, setLifecycleZones] = useState<string[]>([]); const [_selectedZone, setSelectedZone] = useState("");
const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]);
const [showControls, setShowControls] = useState(false); const [showControls, setShowControls] = useState(false);
const [showZones, setShowZones] = useState(true); const [showZones, setShowZones] = useState(true);
const [seekToTimestamp, setSeekToTimestamp] = useState<number | null>(null);
const aspectRatio = useMemo(() => { const aspectRatio = useMemo(() => {
if (!config) { if (!config) {
@ -120,178 +111,27 @@ export default function TrackingDetails({
[config, event], [config, event],
); );
const getObjectColor = useCallback( // Set the selected object ID in the context so ObjectTrackOverlay can display it
(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<React.CSSProperties | null>(null);
const [attributeBoxStyle, setAttributeBoxStyle] =
useState<React.CSSProperties | null>(null);
const configAnnotationOffset = useMemo(() => {
if (!config) {
return 0;
}
return config.cameras[event.camera]?.detect?.annotation_offset || 0;
}, [config, event]);
const [annotationOffset, setAnnotationOffset] = useState<number>(
configAnnotationOffset,
);
const savedPathPoints = useMemo(() => {
return (
event.data.path_data?.map(([coords, timestamp]: [number[], number]) => ({
x: coords[0],
y: coords[1],
timestamp,
lifecycle_item: undefined,
})) || []
);
}, [event.data.path_data]);
const eventSequencePoints = useMemo(() => {
return (
eventSequence
?.filter((event) => event.data.box !== undefined)
.map((event) => {
const [left, top, width, height] = event.data.box!;
return {
x: left + width / 2, // Center x-coordinate
y: top + height, // Bottom y-coordinate
timestamp: event.timestamp,
lifecycle_item: event,
};
}) || []
);
}, [eventSequence]);
// final object path with timeline points included
const pathPoints = useMemo(() => {
// don't display a path if we don't have any saved path points
if (
savedPathPoints.length === 0 ||
config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config
)
return [];
return [...savedPathPoints, ...eventSequencePoints].sort(
(a, b) => a.timestamp - b.timestamp,
);
}, [savedPathPoints, eventSequencePoints, config, event]);
const [timeIndex, setTimeIndex] = useState(0);
const handleSetBox = useCallback(
(box: number[], attrBox: number[] | undefined) => {
if (imgRef.current && Array.isArray(box) && box.length === 4) {
const imgElement = imgRef.current;
const imgRect = imgElement.getBoundingClientRect();
const style = {
left: `${box[0] * imgRect.width}px`,
top: `${box[1] * imgRect.height}px`,
width: `${box[2] * imgRect.width}px`,
height: `${box[3] * imgRect.height}px`,
borderColor: `rgb(${getObjectColor(event.label)?.join(",")})`,
};
if (attrBox) {
const attrStyle = {
left: `${attrBox[0] * imgRect.width}px`,
top: `${attrBox[1] * imgRect.height}px`,
width: `${attrBox[2] * imgRect.width}px`,
height: `${attrBox[3] * imgRect.height}px`,
borderColor: `rgb(${getObjectColor(event.label)?.join(",")})`,
};
setAttributeBoxStyle(attrStyle);
} else {
setAttributeBoxStyle(null);
}
setBoxStyle(style);
}
},
[imgRef, event, getObjectColor],
);
// image
const [src, setSrc] = useState(
`${apiHost}api/${event.camera}/recordings/${event.start_time + annotationOffset / 1000}/snapshot.jpg?height=500`,
);
const [hasError, setHasError] = useState(false);
useEffect(() => { useEffect(() => {
if (timeIndex) { setSelectedObjectIds([event.id]);
const newSrc = `${apiHost}api/${event.camera}/recordings/${timeIndex + annotationOffset / 1000}/snapshot.jpg?height=500`; }, [event.id, setSelectedObjectIds]);
setSrc(newSrc);
}
setImgLoaded(false);
setHasError(false);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeIndex, annotationOffset]);
// 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( // Convert to video-relative time for seeking
(index: number) => { const eventStartRecord =
if (!eventSequence) return; (event.start_time ?? 0) + annotationOffset / 1000;
const sequenceIndex = eventSequence.findIndex( const videoStartTime = eventStartRecord - REVIEW_PADDING;
(item) => item.timestamp === pathPoints[index].timestamp, const relativeTime = targetTimeRecord - videoStartTime;
);
if (sequenceIndex !== -1) { videoRef.current.currentTime = relativeTime;
setTimeIndex(eventSequence[sequenceIndex].timestamp);
handleSetBox(
eventSequence[sequenceIndex]?.data.box ?? [],
eventSequence[sequenceIndex]?.data?.attribute_box,
);
setLifecycleZones(eventSequence[sequenceIndex]?.data.zones);
} else {
// click on a normal path point, not a lifecycle point
setTimeIndex(pathPoints[index].timestamp);
setBoxStyle(null);
setLifecycleZones([]);
}
}, },
[eventSequence, pathPoints, handleSetBox], [event.start_time, annotationOffset],
); );
const formattedStart = config const formattedStart = config
@ -328,53 +168,38 @@ export default function TrackingDetails({
useEffect(() => { useEffect(() => {
if (!eventSequence || eventSequence.length === 0) return; 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); setLifecycleZones(eventSequence[0]?.data.zones);
} }, [eventSequence]);
}, [eventSequence, timeIndex, handleSetBox]);
// When timeIndex changes or image finishes loading, sync the box/zones to matching lifecycle, else clear
useEffect(() => { useEffect(() => {
if (!eventSequence || timeIndex == null) return; if (seekToTimestamp === null || !videoRef.current) return;
const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex);
if (idx !== -1) { // seekToTimestamp is a record stream timestamp
if (imgLoaded) { // event.start_time is detect stream time, convert to record
handleSetBox( // The video clip starts at (eventStartRecord - REVIEW_PADDING)
eventSequence[idx]?.data.box ?? [], const eventStartRecord = event.start_time + annotationOffset / 1000;
eventSequence[idx]?.data?.attribute_box, const videoStartTime = eventStartRecord - REVIEW_PADDING;
); const relativeTime = seekToTimestamp - videoStartTime;
if (relativeTime >= 0) {
videoRef.current.currentTime = relativeTime;
} }
setLifecycleZones(eventSequence[idx]?.data.zones); setSeekToTimestamp(null);
} else { }, [seekToTimestamp, event.start_time, annotationOffset]);
// Non-lifecycle point (e.g., saved path point)
setBoxStyle(null); const isWithinEventRange =
setLifecycleZones([]); effectiveTime !== undefined &&
event.start_time !== undefined &&
event.end_time !== undefined &&
effectiveTime >= event.start_time &&
effectiveTime <= event.end_time;
// Calculate how far down the blue line should extend based on effectiveTime
const calculateLineHeight = useCallback(() => {
if (!eventSequence || eventSequence.length === 0 || !isWithinEventRange) {
return 0;
} }
}, [timeIndex, imgLoaded, eventSequence, handleSetBox]);
const selectedLifecycle = useMemo(() => { const currentTime = effectiveTime ?? 0;
if (!eventSequence || eventSequence.length === 0) return undefined;
const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex);
return idx !== -1 ? eventSequence[idx] : eventSequence[0];
}, [eventSequence, timeIndex]);
const selectedIndex = useMemo(() => {
if (!eventSequence || eventSequence.length === 0) return 0;
const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex);
return idx === -1 ? 0 : idx;
}, [eventSequence, timeIndex]);
// Calculate how far down the blue line should extend based on timeIndex
const calculateLineHeight = () => {
if (!eventSequence || eventSequence.length === 0) return 0;
const currentTime = timeIndex ?? 0;
// Find which events have been passed // Find which events have been passed
let lastPassedIndex = -1; let lastPassedIndex = -1;
@ -412,172 +237,161 @@ export default function TrackingDetails({
100, 100,
lastPassedIndex * itemPercentage + interpolation * itemPercentage, lastPassedIndex * itemPercentage + interpolation * itemPercentage,
); );
}; }, [eventSequence, effectiveTime, isWithinEventRange]);
const blueLineHeight = calculateLineHeight(); 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) { if (!config) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
return ( return (
<div className={className}> <div
className={cn(
isDesktop
? "flex size-full gap-4 overflow-hidden"
: "flex size-full flex-col gap-2",
className,
)}
>
<span tabIndex={0} className="sr-only" /> <span tabIndex={0} className="sr-only" />
{!fullscreen && (
<div className={cn("flex items-center gap-2")}>
<Button
className="mb-2 mt-3 flex items-center gap-2.5 rounded-lg md:mt-0"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={() => setPane("overview")}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
</div>
)}
<div <div
className={cn( className={cn(
"relative mx-auto flex max-h-[50dvh] flex-row justify-center", "flex w-full items-center justify-center",
isDesktop && "overflow-hidden",
cameraAspect === "tall" ? "max-h-[50dvh] lg:max-h-[70dvh]" : "w-full",
cameraAspect !== "tall" && isDesktop && "flex-[3]",
)} )}
style={{ style={{ aspectRatio: aspectRatio }}
aspectRatio: !imgLoaded ? aspectRatio : undefined, ref={containerRef}
}}
> >
<ImageLoadingIndicator
className="absolute inset-0"
imgLoaded={imgLoaded}
/>
{hasError && (
<div className="relative aspect-video">
<div className="flex flex-col items-center justify-center p-20 text-center">
<LuFolderX className="size-16" />
{t("trackingDetails.noImageFound")}
</div>
</div>
)}
<div <div
className={cn( className={cn(
"relative inline-block", "relative",
imgLoaded ? "visible" : "invisible", cameraAspect === "tall" ? "h-full" : "w-full",
)} )}
> >
<ContextMenu> <HlsVideoPlayer
<ContextMenuTrigger> videoRef={videoRef}
<img containerRef={containerRef}
key={event.id} visible={true}
ref={imgRef} currentSource={videoSource}
hotKeys={false}
supportsFullscreen={false}
fullscreen={false}
frigateControls={true}
onTimeUpdate={handleTimeUpdate}
onSeekToTime={handleSeekToTime}
isDetailMode={true}
camera={event.camera}
currentTimeOverride={currentTime}
/>
<div
className={cn( className={cn(
"max-h-[50dvh] max-w-full select-none rounded-lg object-contain", "absolute top-2 z-[5] flex items-center gap-2",
isIOS ? "right-8" : "right-2",
)} )}
loading={isSafari ? "eager" : "lazy"} >
style={ {event && (
isIOS <Tooltip>
? { <TooltipTrigger>
WebkitUserSelect: "none", <Chip
WebkitTouchCallout: "none", className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
if (event?.id) {
const params = new URLSearchParams({
id: event.id,
}).toString();
navigate(`/review?${params}`);
} }
: undefined
}
draggable={false}
src={src}
onLoad={() => setImgLoaded(true)}
onError={() => setHasError(true)}
/>
{showZones &&
imgRef.current?.width &&
imgRef.current?.height &&
lifecycleZones?.map((zone) => (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
width: imgRef.current?.clientWidth,
height: imgRef.current?.clientHeight,
}} }}
key={zone}
> >
<svg <FaHistory className="size-4 text-white" />
viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`} </Chip>
className="absolute inset-0" </TooltipTrigger>
> <TooltipPortal>
<polygon <TooltipContent>
points={getZonePolygon(zone)} {t("itemMenu.viewInHistory.label")}
className="fill-none stroke-2" </TooltipContent>
style={{ </TooltipPortal>
stroke: `rgb(${getZoneColor(zone)?.join(",")})`, </Tooltip>
fill:
selectedZone == zone
? `rgba(${getZoneColor(zone)?.join(",")}, 0.5)`
: `rgba(${getZoneColor(zone)?.join(",")}, 0.3)`,
strokeWidth: selectedZone == zone ? 4 : 2,
}}
/>
</svg>
</div>
))}
{boxStyle && (
<div className="absolute border-2" style={boxStyle}>
<div className="absolute bottom-[-3px] left-1/2 h-[5px] w-[5px] -translate-x-1/2 transform bg-yellow-500" />
</div>
)} )}
{attributeBoxStyle && ( <Tooltip>
<div className="absolute border-2" style={attributeBoxStyle} /> <TooltipTrigger asChild>
)} <a
{imgRef.current?.width && download
imgRef.current?.height && href={`${baseUrl}api/${event.camera}/start/${event.start_time - REVIEW_PADDING}/end/${(event.end_time ?? Date.now() / 1000) + REVIEW_PADDING}/clip.mp4`}
pathPoints &&
pathPoints.length > 0 && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
width: imgRef.current?.clientWidth,
height: imgRef.current?.clientHeight,
}}
key="path"
> >
<svg <Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500">
viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`} <FaDownload className="size-4 text-white" />
className="absolute inset-0" </Chip>
> </a>
<ObjectPath </TooltipTrigger>
positions={pathPoints} <TooltipPortal>
color={getObjectColor(event.label)} <TooltipContent>
width={2} {t("button.download", { ns: "common" })}
imgRef={imgRef} </TooltipContent>
onPointClick={handlePathPointClick} </TooltipPortal>
/> </Tooltip>
</svg>
</div> </div>
)}
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={() =>
navigate(
`/settings?page=masksAndZones&camera=${event.camera}&object_mask=${selectedLifecycle?.data.box}`,
)
}
>
<div className="text-primary">
{t("trackingDetails.createObjectMask")}
</div>
</div>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</div> </div>
</div> </div>
<div className="mt-3 flex flex-row items-center justify-between"> <div className={cn(isDesktop && "flex-[2] overflow-hidden")}>
{isDesktop && tabs && <div className="mb-4">{tabs}</div>}
<div
className={cn(
isDesktop && "scrollbar-container h-full overflow-y-auto",
)}
>
<div className="flex flex-row items-center justify-between">
<Heading as="h4">{t("trackingDetails.title")}</Heading> <Heading as="h4">{t("trackingDetails.title")}</Heading>
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
@ -608,12 +422,13 @@ export default function TrackingDetails({
</div> </div>
<div className="min-w-20 text-right text-sm text-muted-foreground"> <div className="min-w-20 text-right text-sm text-muted-foreground">
{t("trackingDetails.count", { {t("trackingDetails.count", {
first: selectedIndex + 1, first: eventSequence?.length ?? 0,
second: eventSequence?.length ?? 0, second: eventSequence?.length ?? 0,
})} })}
</div> </div>
</div> </div>
{config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config && ( {config?.cameras[event.camera]?.onvif.autotracking
.enabled_in_config && (
<div className="-mt-2 mb-2 text-sm text-danger"> <div className="-mt-2 mb-2 text-sm text-danger">
{t("trackingDetails.autoTrackingTips")} {t("trackingDetails.autoTrackingTips")}
</div> </div>
@ -624,7 +439,14 @@ export default function TrackingDetails({
showZones={showZones} showZones={showZones}
setShowZones={setShowZones} setShowZones={setShowZones}
annotationOffset={annotationOffset} annotationOffset={annotationOffset}
setAnnotationOffset={setAnnotationOffset} setAnnotationOffset={(value) => {
if (typeof value === "function") {
const newValue = value(annotationOffset);
setAnnotationOffset(newValue);
} else {
setAnnotationOffset(value);
}
}}
/> />
)} )}
@ -639,7 +461,10 @@ export default function TrackingDetails({
className="flex items-center gap-2 font-medium" className="flex items-center gap-2 font-medium"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setTimeIndex(event.start_time ?? 0); // event.start_time is detect time, convert to record
handleSeekToTime(
(event.start_time ?? 0) + annotationOffset / 1000,
);
}} }}
role="button" role="button"
> >
@ -683,16 +508,20 @@ export default function TrackingDetails({
{t("detail.noObjectDetailData", { ns: "views/events" })} {t("detail.noObjectDetailData", { ns: "views/events" })}
</div> </div>
) : ( ) : (
<div className="-pb-2 relative mx-2"> <div className="-pb-2 relative mx-0">
<div className="absolute -top-2 bottom-8 left-4 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" /> <div className="absolute -top-2 bottom-8 left-6 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" />
{isWithinEventRange && (
<div <div
className="absolute left-4 top-2 z-[5] max-h-[calc(100%-3rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300" className="absolute left-6 top-2 z-[5] max-h-[calc(100%-3rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
style={{ height: `${blueLineHeight}%` }} style={{ height: `${blueLineHeight}%` }}
/> />
)}
<div className="space-y-2"> <div className="space-y-2">
{eventSequence.map((item, idx) => { {eventSequence.map((item, idx) => {
const isActive = const isActive =
Math.abs((timeIndex ?? 0) - (item.timestamp ?? 0)) <= 0.5; Math.abs(
(effectiveTime ?? 0) - (item.timestamp ?? 0),
) <= 0.5;
const formattedEventTimestamp = config const formattedEventTimestamp = config
? formatUnixTimestampToDateTime(item.timestamp ?? 0, { ? formatUnixTimestampToDateTime(item.timestamp ?? 0, {
timezone: config.ui.timezone, timezone: config.ui.timezone,
@ -712,23 +541,27 @@ export default function TrackingDetails({
: ""; : "";
const ratio = const ratio =
Array.isArray(item.data.box) && item.data.box.length >= 4 Array.isArray(item.data.box) &&
item.data.box.length >= 4
? ( ? (
aspectRatio * aspectRatio *
(item.data.box[2] / item.data.box[3]) (item.data.box[2] / item.data.box[3])
).toFixed(2) ).toFixed(2)
: "N/A"; : "N/A";
const areaPx = const areaPx =
Array.isArray(item.data.box) && item.data.box.length >= 4 Array.isArray(item.data.box) &&
item.data.box.length >= 4
? Math.round( ? Math.round(
(config.cameras[event.camera]?.detect?.width ?? 0) * (config.cameras[event.camera]?.detect?.width ??
(config.cameras[event.camera]?.detect?.height ??
0) * 0) *
(config.cameras[event.camera]?.detect
?.height ?? 0) *
(item.data.box[2] * item.data.box[3]), (item.data.box[2] * item.data.box[3]),
) )
: undefined; : undefined;
const areaPct = const areaPct =
Array.isArray(item.data.box) && item.data.box.length >= 4 Array.isArray(item.data.box) &&
item.data.box.length >= 4
? (item.data.box[2] * item.data.box[3]).toFixed(4) ? (item.data.box[2] * item.data.box[3]).toFixed(4)
: undefined; : undefined;
@ -741,17 +574,11 @@ export default function TrackingDetails({
ratio={ratio} ratio={ratio}
areaPx={areaPx} areaPx={areaPx}
areaPct={areaPct} areaPct={areaPct}
onClick={() => { onClick={() => handleLifecycleClick(item)}
setTimeIndex(item.timestamp ?? 0);
handleSetBox(
item.data.box ?? [],
item.data.attribute_box,
);
setLifecycleZones(item.data.zones);
setSelectedZone("");
}}
setSelectedZone={setSelectedZone} setSelectedZone={setSelectedZone}
getZoneColor={getZoneColor} getZoneColor={getZoneColor}
effectiveTime={effectiveTime}
isTimelineActive={isWithinEventRange}
/> />
); );
})} })}
@ -762,47 +589,11 @@ export default function TrackingDetails({
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
); );
} }
type GetTimelineIconParams = {
lifecycleItem: TrackingDetailsSequence;
className?: string;
};
export function LifecycleIcon({
lifecycleItem,
className,
}: GetTimelineIconParams) {
switch (lifecycleItem.class_type) {
case "visible":
return <LuPlay className={cn(className)} />;
case "gone":
return <IoMdExit className={cn(className)} />;
case "active":
return <IoPlayCircleOutline className={cn(className)} />;
case "stationary":
return <LuCircle className={cn(className)} />;
case "entered_zone":
return <MdOutlineLocationOn className={cn(className)} />;
case "attribute":
switch (lifecycleItem.data?.attribute) {
case "face":
return <MdFaceUnlock className={cn(className)} />;
case "license_plate":
return <MdOutlinePictureInPictureAlt className={cn(className)} />;
default:
return <LuTruck className={cn(className)} />;
}
case "heard":
return <LuEar className={cn(className)} />;
case "external":
return <LuCircleDot className={cn(className)} />;
default:
return null;
}
}
type LifecycleIconRowProps = { type LifecycleIconRowProps = {
item: TrackingDetailsSequence; item: TrackingDetailsSequence;
isActive?: boolean; isActive?: boolean;
@ -813,6 +604,8 @@ type LifecycleIconRowProps = {
onClick: () => void; onClick: () => void;
setSelectedZone: (z: string) => void; setSelectedZone: (z: string) => void;
getZoneColor: (zoneName: string) => number[] | undefined; getZoneColor: (zoneName: string) => number[] | undefined;
effectiveTime?: number;
isTimelineActive?: boolean;
}; };
function LifecycleIconRow({ function LifecycleIconRow({
@ -825,6 +618,8 @@ function LifecycleIconRow({
onClick, onClick,
setSelectedZone, setSelectedZone,
getZoneColor, getZoneColor,
effectiveTime,
isTimelineActive,
}: LifecycleIconRowProps) { }: LifecycleIconRowProps) {
const { t } = useTranslation(["views/explore", "components/player"]); const { t } = useTranslation(["views/explore", "components/player"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -837,17 +632,19 @@ function LifecycleIconRow({
role="button" role="button"
onClick={onClick} onClick={onClick}
className={cn( 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 && "bg-secondary-highlight font-semibold text-primary",
!isActive && "duration-500", !isActive && "duration-500",
)} )}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative flex size-4 items-center justify-center"> <div className="relative ml-2 flex size-4 items-center justify-center">
<LuCircle <LuCircle
className={cn( className={cn(
"relative z-10 ml-[1px] size-2.5 fill-secondary-foreground stroke-none", "relative z-10 size-2.5 fill-secondary-foreground stroke-none",
isActive && "fill-selected duration-300", (isActive || (effectiveTime ?? 0) >= (item?.timestamp ?? 0)) &&
isTimelineActive &&
"fill-selected duration-300",
)} )}
/> />
</div> </div>

View File

@ -57,7 +57,7 @@ export default function DetailStream({
elementRef: scrollRef, elementRef: scrollRef,
}); });
const effectiveTime = currentTime + annotationOffset / 1000; const effectiveTime = currentTime - annotationOffset / 1000;
const [upload, setUpload] = useState<Event | undefined>(undefined); const [upload, setUpload] = useState<Event | undefined>(undefined);
const [controlsExpanded, setControlsExpanded] = useState(false); const [controlsExpanded, setControlsExpanded] = useState(false);
const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence( const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence(
@ -213,6 +213,7 @@ export default function DetailStream({
config={config} config={config}
onSeek={onSeekCheckPlaying} onSeek={onSeekCheckPlaying}
effectiveTime={effectiveTime} effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
isActive={activeReviewId == id} isActive={activeReviewId == id}
onActivate={() => setActiveReviewId(id)} onActivate={() => setActiveReviewId(id)}
onOpenUpload={(e) => setUpload(e)} onOpenUpload={(e) => setUpload(e)}
@ -278,6 +279,7 @@ type ReviewGroupProps = {
onActivate?: () => void; onActivate?: () => void;
onOpenUpload?: (e: Event) => void; onOpenUpload?: (e: Event) => void;
effectiveTime?: number; effectiveTime?: number;
annotationOffset: number;
alwaysExpandActive?: boolean; alwaysExpandActive?: boolean;
}; };
@ -290,11 +292,14 @@ function ReviewGroup({
onActivate, onActivate,
onOpenUpload, onOpenUpload,
effectiveTime, effectiveTime,
annotationOffset,
alwaysExpandActive = false, alwaysExpandActive = false,
}: ReviewGroupProps) { }: ReviewGroupProps) {
const { t } = useTranslation("views/events"); const { t } = useTranslation("views/events");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const start = review.start_time ?? 0; 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 // Auto-expand when this review becomes active and alwaysExpandActive is enabled
useEffect(() => { useEffect(() => {
@ -371,7 +376,7 @@ function ReviewGroup({
)} )}
onClick={() => { onClick={() => {
onActivate?.(); onActivate?.();
onSeek(start); onSeek(startRecord);
}} }}
> >
<div className="ml-4 mr-2 mt-1.5 flex flex-row items-start"> <div className="ml-4 mr-2 mt-1.5 flex flex-row items-start">
@ -450,6 +455,7 @@ function ReviewGroup({
key={event.id} key={event.id}
event={event} event={event}
effectiveTime={effectiveTime} effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
onSeek={onSeek} onSeek={onSeek}
onOpenUpload={onOpenUpload} onOpenUpload={onOpenUpload}
/> />
@ -483,12 +489,14 @@ function ReviewGroup({
type EventListProps = { type EventListProps = {
event: Event; event: Event;
effectiveTime?: number; effectiveTime?: number;
annotationOffset: number;
onSeek: (ts: number, play?: boolean) => void; onSeek: (ts: number, play?: boolean) => void;
onOpenUpload?: (e: Event) => void; onOpenUpload?: (e: Event) => void;
}; };
function EventList({ function EventList({
event, event,
effectiveTime, effectiveTime,
annotationOffset,
onSeek, onSeek,
onOpenUpload, onOpenUpload,
}: EventListProps) { }: EventListProps) {
@ -505,14 +513,17 @@ function EventList({
if (event) { if (event) {
setSelectedObjectIds([]); setSelectedObjectIds([]);
setSelectedObjectIds([event.id]); 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 { } else {
setSelectedObjectIds([]); setSelectedObjectIds([]);
} }
}; };
const handleTimelineClick = (ts: number, play?: boolean) => { const handleTimelineClick = (ts: number, play?: boolean) => {
handleObjectSelect(event); setSelectedObjectIds([]);
setSelectedObjectIds([event.id]);
onSeek(ts, play); onSeek(ts, play);
}; };
@ -554,7 +565,6 @@ function EventList({
)} )}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onSeek(event.start_time);
handleObjectSelect(event); handleObjectSelect(event);
}} }}
role="button" role="button"
@ -568,7 +578,6 @@ function EventList({
className="flex flex-1 items-center gap-2" className="flex flex-1 items-center gap-2"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onSeek(event.start_time);
handleObjectSelect(event); handleObjectSelect(event);
}} }}
role="button" role="button"
@ -607,6 +616,7 @@ function EventList({
eventId={event.id} eventId={event.id}
onSeek={handleTimelineClick} onSeek={handleTimelineClick}
effectiveTime={effectiveTime} effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
startTime={event.start_time} startTime={event.start_time}
endTime={event.end_time} endTime={event.end_time}
/> />
@ -621,6 +631,7 @@ type LifecycleItemProps = {
isActive?: boolean; isActive?: boolean;
onSeek?: (timestamp: number, play?: boolean) => void; onSeek?: (timestamp: number, play?: boolean) => void;
effectiveTime?: number; effectiveTime?: number;
annotationOffset: number;
isTimelineActive?: boolean; isTimelineActive?: boolean;
}; };
@ -629,6 +640,7 @@ function LifecycleItem({
isActive, isActive,
onSeek, onSeek,
effectiveTime, effectiveTime,
annotationOffset,
isTimelineActive = false, isTimelineActive = false,
}: LifecycleItemProps) { }: LifecycleItemProps) {
const { t } = useTranslation("views/events"); const { t } = useTranslation("views/events");
@ -682,7 +694,8 @@ function LifecycleItem({
<div <div
role="button" role="button"
onClick={() => { onClick={() => {
onSeek?.(item.timestamp, false); const recordTimestamp = item.timestamp + annotationOffset / 1000;
onSeek?.(recordTimestamp, false);
}} }}
className={cn( className={cn(
"flex cursor-pointer items-center gap-2 text-sm text-primary-variant", "flex cursor-pointer items-center gap-2 text-sm text-primary-variant",
@ -751,12 +764,14 @@ function ObjectTimeline({
eventId, eventId,
onSeek, onSeek,
effectiveTime, effectiveTime,
annotationOffset,
startTime, startTime,
endTime, endTime,
}: { }: {
eventId: string; eventId: string;
onSeek: (ts: number, play?: boolean) => void; onSeek: (ts: number, play?: boolean) => void;
effectiveTime?: number; effectiveTime?: number;
annotationOffset: number;
startTime?: number; startTime?: number;
endTime?: number; endTime?: number;
}) { }) {
@ -857,6 +872,7 @@ function ObjectTimeline({
onSeek={onSeek} onSeek={onSeek}
isActive={isActive} isActive={isActive}
effectiveTime={effectiveTime} effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
isTimelineActive={isWithinEventRange} isTimelineActive={isWithinEventRange}
/> />
); );

View File

@ -22,6 +22,7 @@ interface DetailStreamProviderProps {
isDetailMode: boolean; isDetailMode: boolean;
currentTime: number; currentTime: number;
camera: string; camera: string;
initialSelectedObjectIds?: string[];
} }
export function DetailStreamProvider({ export function DetailStreamProvider({
@ -29,8 +30,11 @@ export function DetailStreamProvider({
isDetailMode, isDetailMode,
currentTime, currentTime,
camera, camera,
initialSelectedObjectIds,
}: DetailStreamProviderProps) { }: DetailStreamProviderProps) {
const [selectedObjectIds, setSelectedObjectIds] = useState<string[]>([]); const [selectedObjectIds, setSelectedObjectIds] = useState<string[]>(
() => initialSelectedObjectIds ?? [],
);
const toggleObjectSelection = (id: string | undefined) => { const toggleObjectSelection = (id: string | undefined) => {
if (id === undefined) { if (id === undefined) {

View File

@ -893,7 +893,7 @@ function ObjectTrainGrid({
// selection // selection
const [selectedEvent, setSelectedEvent] = useState<Event>(); const [selectedEvent, setSelectedEvent] = useState<Event>();
const [dialogTab, setDialogTab] = useState<SearchTab>("details"); const [dialogTab, setDialogTab] = useState<SearchTab>("snapshot");
// handlers // handlers

View File

@ -214,7 +214,7 @@ export default function SearchView({
// detail // detail
const [searchDetail, setSearchDetail] = useState<SearchResult>(); const [searchDetail, setSearchDetail] = useState<SearchResult>();
const [page, setPage] = useState<SearchTab>("details"); const [page, setPage] = useState<SearchTab>("snapshot");
// search interaction // search interaction
@ -222,7 +222,7 @@ export default function SearchView({
const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const onSelectSearch = useCallback( const onSelectSearch = useCallback(
(item: SearchResult, ctrl: boolean, page: SearchTab = "details") => { (item: SearchResult, ctrl: boolean, page: SearchTab = "snapshot") => {
if (selectedObjects.length > 1 || ctrl) { if (selectedObjects.length > 1 || ctrl) {
const index = selectedObjects.indexOf(item.id); const index = selectedObjects.indexOf(item.id);