mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 13:34:13 +03:00
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
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:
parent
9937a7cc3d
commit
36fb27ef56
@ -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",
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user