mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 13:34:13 +03:00
More detail pane tweaks (#20681)
* More detail pane tweaks * remove unneeded check * add ability to submit frames to frigate+ * rename object lifecycle to tracking details * add object mask creation to lifecycle item menu * change tracking details icon
This commit is contained in:
parent
43706eb48d
commit
5715ed62ad
@ -36,8 +36,8 @@
|
|||||||
"video": "video",
|
"video": "video",
|
||||||
"object_lifecycle": "object lifecycle"
|
"object_lifecycle": "object lifecycle"
|
||||||
},
|
},
|
||||||
"objectLifecycle": {
|
"trackingDetails": {
|
||||||
"title": "Object Lifecycle",
|
"title": "Tracking Details",
|
||||||
"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",
|
||||||
@ -168,9 +168,9 @@
|
|||||||
"label": "Download snapshot",
|
"label": "Download snapshot",
|
||||||
"aria": "Download snapshot"
|
"aria": "Download snapshot"
|
||||||
},
|
},
|
||||||
"viewObjectLifecycle": {
|
"viewTrackingDetails": {
|
||||||
"label": "View object lifecycle",
|
"label": "View tracking details",
|
||||||
"aria": "Show the object lifecycle"
|
"aria": "Show the tracking details"
|
||||||
},
|
},
|
||||||
"findSimilar": {
|
"findSimilar": {
|
||||||
"label": "Find similar",
|
"label": "Find similar",
|
||||||
@ -205,7 +205,7 @@
|
|||||||
"dialog": {
|
"dialog": {
|
||||||
"confirmDelete": {
|
"confirmDelete": {
|
||||||
"title": "Confirm Delete",
|
"title": "Confirm Delete",
|
||||||
"desc": "Deleting this tracked object removes the snapshot, any saved embeddings, and any associated object lifecycle entries. Recorded footage of this tracked object in History view will <em>NOT</em> be deleted.<br /><br />Are you sure you want to proceed?"
|
"desc": "Deleting this tracked object removes the snapshot, any saved embeddings, and any associated tracking details entries. Recorded footage of this tracked object in History view will <em>NOT</em> be deleted.<br /><br />Are you sure you want to proceed?"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"noTrackedObjects": "No Tracked Objects Found",
|
"noTrackedObjects": "No Tracked Objects Found",
|
||||||
|
|||||||
@ -13,7 +13,7 @@ type SearchThumbnailProps = {
|
|||||||
columns: number;
|
columns: number;
|
||||||
findSimilar: () => void;
|
findSimilar: () => void;
|
||||||
refreshResults: () => void;
|
refreshResults: () => void;
|
||||||
showObjectLifecycle: () => void;
|
showTrackingDetails: () => void;
|
||||||
showSnapshot: () => void;
|
showSnapshot: () => void;
|
||||||
addTrigger: () => void;
|
addTrigger: () => void;
|
||||||
};
|
};
|
||||||
@ -23,7 +23,7 @@ export default function SearchThumbnailFooter({
|
|||||||
columns,
|
columns,
|
||||||
findSimilar,
|
findSimilar,
|
||||||
refreshResults,
|
refreshResults,
|
||||||
showObjectLifecycle,
|
showTrackingDetails,
|
||||||
showSnapshot,
|
showSnapshot,
|
||||||
addTrigger,
|
addTrigger,
|
||||||
}: SearchThumbnailProps) {
|
}: SearchThumbnailProps) {
|
||||||
@ -61,7 +61,7 @@ export default function SearchThumbnailFooter({
|
|||||||
searchResult={searchResult}
|
searchResult={searchResult}
|
||||||
findSimilar={findSimilar}
|
findSimilar={findSimilar}
|
||||||
refreshResults={refreshResults}
|
refreshResults={refreshResults}
|
||||||
showObjectLifecycle={showObjectLifecycle}
|
showTrackingDetails={showTrackingDetails}
|
||||||
showSnapshot={showSnapshot}
|
showSnapshot={showSnapshot}
|
||||||
addTrigger={addTrigger}
|
addTrigger={addTrigger}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -47,7 +47,7 @@ type SearchResultActionsProps = {
|
|||||||
searchResult: SearchResult;
|
searchResult: SearchResult;
|
||||||
findSimilar: () => void;
|
findSimilar: () => void;
|
||||||
refreshResults: () => void;
|
refreshResults: () => void;
|
||||||
showObjectLifecycle: () => void;
|
showTrackingDetails: () => void;
|
||||||
showSnapshot: () => void;
|
showSnapshot: () => void;
|
||||||
addTrigger: () => void;
|
addTrigger: () => void;
|
||||||
isContextMenu?: boolean;
|
isContextMenu?: boolean;
|
||||||
@ -58,7 +58,7 @@ export default function SearchResultActions({
|
|||||||
searchResult,
|
searchResult,
|
||||||
findSimilar,
|
findSimilar,
|
||||||
refreshResults,
|
refreshResults,
|
||||||
showObjectLifecycle,
|
showTrackingDetails,
|
||||||
showSnapshot,
|
showSnapshot,
|
||||||
addTrigger,
|
addTrigger,
|
||||||
isContextMenu = false,
|
isContextMenu = false,
|
||||||
@ -125,11 +125,11 @@ export default function SearchResultActions({
|
|||||||
)}
|
)}
|
||||||
{searchResult.data.type == "object" && (
|
{searchResult.data.type == "object" && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
aria-label={t("itemMenu.viewObjectLifecycle.aria")}
|
aria-label={t("itemMenu.viewTrackingDetails.aria")}
|
||||||
onClick={showObjectLifecycle}
|
onClick={showTrackingDetails}
|
||||||
>
|
>
|
||||||
<FaArrowsRotate className="mr-2 size-4" />
|
<FaArrowsRotate className="mr-2 size-4" />
|
||||||
<span>{t("itemMenu.viewObjectLifecycle.label")}</span>
|
<span>{t("itemMenu.viewTrackingDetails.label")}</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{config?.semantic_search?.enabled && isContextMenu && (
|
{config?.semantic_search?.enabled && isContextMenu && (
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useCallback } from "react";
|
import { useMemo, useCallback } from "react";
|
||||||
import { ObjectLifecycleSequence, LifecycleClassType } from "@/types/timeline";
|
import { TrackingDetailsSequence, LifecycleClassType } from "@/types/timeline";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useDetailStream } from "@/context/detail-stream-context";
|
import { useDetailStream } from "@/context/detail-stream-context";
|
||||||
@ -27,7 +27,7 @@ type PathPoint = {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
lifecycle_item?: ObjectLifecycleSequence;
|
lifecycle_item?: TrackingDetailsSequence;
|
||||||
objectId: string;
|
objectId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -63,7 +63,7 @@ export default function ObjectTrackOverlay({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Fetch timeline data for each object ID using fixed number of hooks
|
// Fetch timeline data for each object ID using fixed number of hooks
|
||||||
const { data: timelineData } = useSWR<ObjectLifecycleSequence[]>(
|
const { data: timelineData } = useSWR<TrackingDetailsSequence[]>(
|
||||||
selectedObjectIds.length > 0
|
selectedObjectIds.length > 0
|
||||||
? `timeline?source_id=${selectedObjectIds.join(",")}&limit=1000`
|
? `timeline?source_id=${selectedObjectIds.join(",")}&limit=1000`
|
||||||
: null,
|
: null,
|
||||||
@ -74,7 +74,7 @@ export default function ObjectTrackOverlay({
|
|||||||
// Group timeline entries by source_id
|
// Group timeline entries by source_id
|
||||||
if (!timelineData) return selectedObjectIds.map(() => []);
|
if (!timelineData) return selectedObjectIds.map(() => []);
|
||||||
|
|
||||||
const grouped: Record<string, ObjectLifecycleSequence[]> = {};
|
const grouped: Record<string, TrackingDetailsSequence[]> = {};
|
||||||
for (const entry of timelineData) {
|
for (const entry of timelineData) {
|
||||||
if (!grouped[entry.source_id]) {
|
if (!grouped[entry.source_id]) {
|
||||||
grouped[entry.source_id] = [];
|
grouped[entry.source_id] = [];
|
||||||
@ -152,9 +152,9 @@ export default function ObjectTrackOverlay({
|
|||||||
const eventSequencePoints: PathPoint[] =
|
const eventSequencePoints: PathPoint[] =
|
||||||
timelineData
|
timelineData
|
||||||
?.filter(
|
?.filter(
|
||||||
(event: ObjectLifecycleSequence) => event.data.box !== undefined,
|
(event: TrackingDetailsSequence) => event.data.box !== undefined,
|
||||||
)
|
)
|
||||||
.map((event: ObjectLifecycleSequence) => {
|
.map((event: TrackingDetailsSequence) => {
|
||||||
const [left, top, width, height] = event.data.box!;
|
const [left, top, width, height] = event.data.box!;
|
||||||
return {
|
return {
|
||||||
x: left + width / 2, // Center x
|
x: left + width / 2, // Center x
|
||||||
@ -183,22 +183,22 @@ export default function ObjectTrackOverlay({
|
|||||||
const currentZones =
|
const currentZones =
|
||||||
timelineData
|
timelineData
|
||||||
?.filter(
|
?.filter(
|
||||||
(event: ObjectLifecycleSequence) =>
|
(event: TrackingDetailsSequence) =>
|
||||||
event.timestamp <= effectiveCurrentTime,
|
event.timestamp <= effectiveCurrentTime,
|
||||||
)
|
)
|
||||||
.sort(
|
.sort(
|
||||||
(a: ObjectLifecycleSequence, b: ObjectLifecycleSequence) =>
|
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
|
||||||
b.timestamp - a.timestamp,
|
b.timestamp - a.timestamp,
|
||||||
)[0]?.data?.zones || [];
|
)[0]?.data?.zones || [];
|
||||||
|
|
||||||
// Get current bounding box
|
// Get current bounding box
|
||||||
const currentBox = timelineData
|
const currentBox = timelineData
|
||||||
?.filter(
|
?.filter(
|
||||||
(event: ObjectLifecycleSequence) =>
|
(event: TrackingDetailsSequence) =>
|
||||||
event.timestamp <= effectiveCurrentTime && event.data.box,
|
event.timestamp <= effectiveCurrentTime && event.data.box,
|
||||||
)
|
)
|
||||||
.sort(
|
.sort(
|
||||||
(a: ObjectLifecycleSequence, b: ObjectLifecycleSequence) =>
|
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
|
||||||
b.timestamp - a.timestamp,
|
b.timestamp - a.timestamp,
|
||||||
)[0]?.data?.box;
|
)[0]?.data?.box;
|
||||||
|
|
||||||
|
|||||||
@ -40,7 +40,7 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
t("objectLifecycle.annotationSettings.offset.toast.success", {
|
t("trackingDetails.annotationSettings.offset.toast.success", {
|
||||||
camera,
|
camera,
|
||||||
}),
|
}),
|
||||||
{ position: "top-center" },
|
{ position: "top-center" },
|
||||||
|
|||||||
@ -79,7 +79,7 @@ export function AnnotationSettingsPane({
|
|||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
toast.success(
|
toast.success(
|
||||||
t("objectLifecycle.annotationSettings.offset.toast.success", {
|
t("trackingDetails.annotationSettings.offset.toast.success", {
|
||||||
camera: event?.camera,
|
camera: event?.camera,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@ -142,7 +142,7 @@ export function AnnotationSettingsPane({
|
|||||||
return (
|
return (
|
||||||
<div className="mb-3 space-y-3 rounded-lg border border-secondary-foreground bg-background_alt p-2">
|
<div className="mb-3 space-y-3 rounded-lg border border-secondary-foreground bg-background_alt p-2">
|
||||||
<Heading as="h4" className="my-2">
|
<Heading as="h4" className="my-2">
|
||||||
{t("objectLifecycle.annotationSettings.title")}
|
{t("trackingDetails.annotationSettings.title")}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex flex-row items-center justify-start gap-2 p-3">
|
<div className="flex flex-row items-center justify-start gap-2 p-3">
|
||||||
@ -152,11 +152,11 @@ export function AnnotationSettingsPane({
|
|||||||
onCheckedChange={setShowZones}
|
onCheckedChange={setShowZones}
|
||||||
/>
|
/>
|
||||||
<Label className="cursor-pointer" htmlFor="show-zones">
|
<Label className="cursor-pointer" htmlFor="show-zones">
|
||||||
{t("objectLifecycle.annotationSettings.showAllZones.title")}
|
{t("trackingDetails.annotationSettings.showAllZones.title")}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t("objectLifecycle.annotationSettings.showAllZones.desc")}
|
{t("trackingDetails.annotationSettings.showAllZones.desc")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-2 flex bg-secondary" />
|
<Separator className="my-2 flex bg-secondary" />
|
||||||
@ -171,14 +171,14 @@ export function AnnotationSettingsPane({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("objectLifecycle.annotationSettings.offset.label")}
|
{t("trackingDetails.annotationSettings.offset.label")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<div className="flex flex-col gap-3 md:flex-row-reverse md:gap-8">
|
<div className="flex flex-col gap-3 md:flex-row-reverse md:gap-8">
|
||||||
<div className="flex flex-row items-center gap-3 rounded-lg bg-destructive/50 p-3 text-sm text-primary-variant md:my-5">
|
<div className="flex flex-row items-center gap-3 rounded-lg bg-destructive/50 p-3 text-sm text-primary-variant md:my-5">
|
||||||
<PiWarningCircle className="size-24" />
|
<PiWarningCircle className="size-24" />
|
||||||
<div>
|
<div>
|
||||||
<Trans ns="views/explore">
|
<Trans ns="views/explore">
|
||||||
objectLifecycle.annotationSettings.offset.desc
|
trackingDetails.annotationSettings.offset.desc
|
||||||
</Trans>
|
</Trans>
|
||||||
<div className="mt-2 flex items-center text-primary">
|
<div className="mt-2 flex items-center text-primary">
|
||||||
<Link
|
<Link
|
||||||
@ -203,10 +203,10 @@ export function AnnotationSettingsPane({
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans ns="views/explore">
|
<Trans ns="views/explore">
|
||||||
objectLifecycle.annotationSettings.offset.millisecondsToOffset
|
trackingDetails.annotationSettings.offset.millisecondsToOffset
|
||||||
</Trans>
|
</Trans>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
{t("objectLifecycle.annotationSettings.offset.tips")}
|
{t("trackingDetails.annotationSettings.offset.tips")}
|
||||||
</div>
|
</div>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -105,7 +105,7 @@ export function ObjectPath({
|
|||||||
<TooltipContent side="top" className="smart-capitalize">
|
<TooltipContent side="top" className="smart-capitalize">
|
||||||
{pos.lifecycle_item
|
{pos.lifecycle_item
|
||||||
? getLifecycleItemDescription(pos.lifecycle_item)
|
? getLifecycleItemDescription(pos.lifecycle_item)
|
||||||
: t("objectLifecycle.trackedPoint")}
|
: t("trackingDetails.trackedPoint")}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPortal>
|
</TooltipPortal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import { Event } from "@/types/event";
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
|
import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
|
||||||
import ObjectLifecycle from "./ObjectLifecycle";
|
import TrackingDetails from "./TrackingDetails";
|
||||||
import Chip from "@/components/indicators/Chip";
|
import Chip from "@/components/indicators/Chip";
|
||||||
import { FaDownload, FaImages, FaShareAlt } from "react-icons/fa";
|
import { FaDownload, FaImages, FaShareAlt } from "react-icons/fa";
|
||||||
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
|
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
|
||||||
@ -411,7 +411,7 @@ export default function ReviewDetailDialog({
|
|||||||
|
|
||||||
{pane == "details" && selectedEvent && (
|
{pane == "details" && selectedEvent && (
|
||||||
<div className="mt-0 flex size-full flex-col gap-2">
|
<div className="mt-0 flex size-full flex-col gap-2">
|
||||||
<ObjectLifecycle event={selectedEvent} setPane={setPane} />
|
<TrackingDetails event={selectedEvent} setPane={setPane} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Content>
|
</Content>
|
||||||
@ -544,7 +544,7 @@ function EventItem({
|
|||||||
</Chip>
|
</Chip>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{t("itemMenu.viewObjectLifecycle.label")}
|
{t("itemMenu.viewTrackingDetails.label")}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -34,8 +34,7 @@ import {
|
|||||||
FaRegListAlt,
|
FaRegListAlt,
|
||||||
FaVideo,
|
FaVideo,
|
||||||
} from "react-icons/fa";
|
} from "react-icons/fa";
|
||||||
import { FaRotate } from "react-icons/fa6";
|
import TrackingDetails from "./TrackingDetails";
|
||||||
import ObjectLifecycle from "./ObjectLifecycle";
|
|
||||||
import {
|
import {
|
||||||
MobilePage,
|
MobilePage,
|
||||||
MobilePageContent,
|
MobilePageContent,
|
||||||
@ -80,12 +79,13 @@ import FaceSelectionDialog from "../FaceSelectionDialog";
|
|||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
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";
|
||||||
|
|
||||||
const SEARCH_TABS = [
|
const SEARCH_TABS = [
|
||||||
"details",
|
"details",
|
||||||
"snapshot",
|
"snapshot",
|
||||||
"video",
|
"video",
|
||||||
"object_lifecycle",
|
"tracking_details",
|
||||||
] as const;
|
] as const;
|
||||||
export type SearchTab = (typeof SEARCH_TABS)[number];
|
export type SearchTab = (typeof SEARCH_TABS)[number];
|
||||||
|
|
||||||
@ -160,7 +160,7 @@ export default function SearchDetailDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (search.data.type != "object" || !search.has_clip) {
|
if (search.data.type != "object" || !search.has_clip) {
|
||||||
const index = views.indexOf("object_lifecycle");
|
const index = views.indexOf("tracking_details");
|
||||||
views.splice(index, 1);
|
views.splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -235,9 +235,7 @@ export default function SearchDetailDialog({
|
|||||||
{item == "details" && <FaRegListAlt className="size-4" />}
|
{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 == "video" && <FaVideo className="size-4" />}
|
||||||
{item == "object_lifecycle" && (
|
{item == "tracking_details" && <PiPath className="size-4" />}
|
||||||
<FaRotate className="size-4" />
|
|
||||||
)}
|
|
||||||
<div className="smart-capitalize">{t(`type.${item}`)}</div>
|
<div className="smart-capitalize">{t(`type.${item}`)}</div>
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
))}
|
))}
|
||||||
@ -268,8 +266,8 @@ export default function SearchDetailDialog({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{page == "video" && <VideoTab search={search} />}
|
{page == "video" && <VideoTab search={search} />}
|
||||||
{page == "object_lifecycle" && (
|
{page == "tracking_details" && (
|
||||||
<ObjectLifecycle
|
<TrackingDetails
|
||||||
className="w-full overflow-x-hidden"
|
className="w-full overflow-x-hidden"
|
||||||
event={search as unknown as Event}
|
event={search as unknown as Event}
|
||||||
fullscreen={true}
|
fullscreen={true}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ObjectLifecycleSequence } 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 { ReviewDetailPaneType } from "@/types/review";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
@ -41,6 +41,13 @@ import {
|
|||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { ObjectPath } from "./ObjectPath";
|
import { ObjectPath } from "./ObjectPath";
|
||||||
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||||
@ -48,23 +55,26 @@ 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 axios from "axios";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
type ObjectLifecycleProps = {
|
type TrackingDetailsProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
event: Event;
|
event: Event;
|
||||||
fullscreen?: boolean;
|
fullscreen?: boolean;
|
||||||
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>;
|
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ObjectLifecycle({
|
export default function TrackingDetails({
|
||||||
className,
|
className,
|
||||||
event,
|
event,
|
||||||
fullscreen = false,
|
fullscreen = false,
|
||||||
setPane,
|
setPane,
|
||||||
}: ObjectLifecycleProps) {
|
}: TrackingDetailsProps) {
|
||||||
const { t } = useTranslation(["views/explore"]);
|
const { t } = useTranslation(["views/explore"]);
|
||||||
|
|
||||||
const { data: eventSequence } = useSWR<ObjectLifecycleSequence[]>([
|
const { data: eventSequence } = useSWR<TrackingDetailsSequence[]>([
|
||||||
"timeline",
|
"timeline",
|
||||||
{
|
{
|
||||||
source_id: event.id,
|
source_id: event.id,
|
||||||
@ -447,7 +457,7 @@ export default function ObjectLifecycle({
|
|||||||
<div className="relative aspect-video">
|
<div className="relative aspect-video">
|
||||||
<div className="flex flex-col items-center justify-center p-20 text-center">
|
<div className="flex flex-col items-center justify-center p-20 text-center">
|
||||||
<LuFolderX className="size-16" />
|
<LuFolderX className="size-16" />
|
||||||
{t("objectLifecycle.noImageFound")}
|
{t("trackingDetails.noImageFound")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -558,7 +568,7 @@ export default function ObjectLifecycle({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="text-primary">
|
<div className="text-primary">
|
||||||
{t("objectLifecycle.createObjectMask")}
|
{t("trackingDetails.createObjectMask")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
@ -568,7 +578,7 @@ export default function ObjectLifecycle({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-row items-center justify-between">
|
<div className="mt-3 flex flex-row items-center justify-between">
|
||||||
<Heading as="h4">{t("objectLifecycle.title")}</Heading>
|
<Heading as="h4">{t("trackingDetails.title")}</Heading>
|
||||||
|
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@ -576,7 +586,7 @@ export default function ObjectLifecycle({
|
|||||||
<Button
|
<Button
|
||||||
variant={showControls ? "select" : "default"}
|
variant={showControls ? "select" : "default"}
|
||||||
className="size-7 p-1.5"
|
className="size-7 p-1.5"
|
||||||
aria-label={t("objectLifecycle.adjustAnnotationSettings")}
|
aria-label={t("trackingDetails.adjustAnnotationSettings")}
|
||||||
>
|
>
|
||||||
<LuSettings
|
<LuSettings
|
||||||
className="size-5"
|
className="size-5"
|
||||||
@ -586,7 +596,7 @@ export default function ObjectLifecycle({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{t("objectLifecycle.adjustAnnotationSettings")}
|
{t("trackingDetails.adjustAnnotationSettings")}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPortal>
|
</TooltipPortal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@ -594,10 +604,10 @@ export default function ObjectLifecycle({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center justify-between">
|
<div className="flex flex-row items-center justify-between">
|
||||||
<div className="mb-2 text-sm text-muted-foreground">
|
<div className="mb-2 text-sm text-muted-foreground">
|
||||||
{t("objectLifecycle.scrollViewTips")}
|
{t("trackingDetails.scrollViewTips")}
|
||||||
</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("objectLifecycle.count", {
|
{t("trackingDetails.count", {
|
||||||
first: selectedIndex + 1,
|
first: selectedIndex + 1,
|
||||||
second: eventSequence?.length ?? 0,
|
second: eventSequence?.length ?? 0,
|
||||||
})}
|
})}
|
||||||
@ -605,7 +615,7 @@ export default function ObjectLifecycle({
|
|||||||
</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("objectLifecycle.autoTrackingTips")}
|
{t("trackingDetails.autoTrackingTips")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showControls && (
|
{showControls && (
|
||||||
@ -756,7 +766,7 @@ export default function ObjectLifecycle({
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GetTimelineIconParams = {
|
type GetTimelineIconParams = {
|
||||||
lifecycleItem: ObjectLifecycleSequence;
|
lifecycleItem: TrackingDetailsSequence;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -794,7 +804,7 @@ export function LifecycleIcon({
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LifecycleIconRowProps = {
|
type LifecycleIconRowProps = {
|
||||||
item: ObjectLifecycleSequence;
|
item: TrackingDetailsSequence;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
formattedEventTimestamp: string;
|
formattedEventTimestamp: string;
|
||||||
ratio: string;
|
ratio: string;
|
||||||
@ -816,7 +826,11 @@ function LifecycleIconRow({
|
|||||||
setSelectedZone,
|
setSelectedZone,
|
||||||
getZoneColor,
|
getZoneColor,
|
||||||
}: LifecycleIconRowProps) {
|
}: LifecycleIconRowProps) {
|
||||||
const { t } = useTranslation(["views/explore"]);
|
const { t } = useTranslation(["views/explore", "components/player"]);
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -846,13 +860,13 @@ function LifecycleIconRow({
|
|||||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-secondary-foreground md:gap-5">
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-secondary-foreground md:gap-5">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-primary-variant">
|
<span className="text-primary-variant">
|
||||||
{t("objectLifecycle.lifecycleItemDesc.header.ratio")}
|
{t("trackingDetails.lifecycleItemDesc.header.ratio")}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium text-primary">{ratio}</span>
|
<span className="font-medium text-primary">{ratio}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-primary-variant">
|
<span className="text-primary-variant">
|
||||||
{t("objectLifecycle.lifecycleItemDesc.header.area")}
|
{t("trackingDetails.lifecycleItemDesc.header.area")}
|
||||||
</span>
|
</span>
|
||||||
{areaPx !== undefined && areaPct !== undefined ? (
|
{areaPx !== undefined && areaPct !== undefined ? (
|
||||||
<span className="font-medium text-primary">
|
<span className="font-medium text-primary">
|
||||||
@ -903,7 +917,69 @@ function LifecycleIconRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 flex-shrink-0 px-1 text-right text-xs text-primary-variant">
|
<div className="ml-3 flex-shrink-0 px-1 text-right text-xs text-primary-variant">
|
||||||
|
<div className="flex flex-row items-center gap-3">
|
||||||
<div className="whitespace-nowrap">{formattedEventTimestamp}</div>
|
<div className="whitespace-nowrap">{formattedEventTimestamp}</div>
|
||||||
|
{(config?.plus?.enabled || item.data.box) && (
|
||||||
|
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<div className="rounded p-1 pr-2" role="button">
|
||||||
|
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
{config?.plus?.enabled && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
onSelect={async () => {
|
||||||
|
const resp = await axios.post(
|
||||||
|
`/${item.camera}/plus/${item.timestamp}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resp && resp.status == 200) {
|
||||||
|
toast.success(
|
||||||
|
t("toast.success.submittedFrigatePlus", {
|
||||||
|
ns: "components/player",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
position: "top-center",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
t("toast.error.submitFrigatePlusFailed", {
|
||||||
|
ns: "components/player",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
position: "top-center",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("itemMenu.submitToPlus.label")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{item.data.box && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
onSelect={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate(
|
||||||
|
`/settings?page=masksAndZones&camera=${item.camera}&object_mask=${item.data.box}`,
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("trackingDetails.createObjectMask")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { Recording } from "@/types/record";
|
import { Recording } from "@/types/record";
|
||||||
import { DynamicPlayback } from "@/types/playback";
|
import { DynamicPlayback } from "@/types/playback";
|
||||||
import { PreviewController } from "../PreviewPlayer";
|
import { PreviewController } from "../PreviewPlayer";
|
||||||
import { TimeRange, ObjectLifecycleSequence } from "@/types/timeline";
|
import { TimeRange, TrackingDetailsSequence } from "@/types/timeline";
|
||||||
import { calculateInpointOffset } from "@/utils/videoUtil";
|
import { calculateInpointOffset } from "@/utils/videoUtil";
|
||||||
|
|
||||||
type PlayerMode = "playback" | "scrubbing";
|
type PlayerMode = "playback" | "scrubbing";
|
||||||
@ -12,7 +12,7 @@ export class DynamicVideoController {
|
|||||||
private playerController: HTMLVideoElement;
|
private playerController: HTMLVideoElement;
|
||||||
private previewController: PreviewController;
|
private previewController: PreviewController;
|
||||||
private setNoRecording: (noRecs: boolean) => void;
|
private setNoRecording: (noRecs: boolean) => void;
|
||||||
private setFocusedItem: (timeline: ObjectLifecycleSequence) => void;
|
private setFocusedItem: (timeline: TrackingDetailsSequence) => void;
|
||||||
private playerMode: PlayerMode = "playback";
|
private playerMode: PlayerMode = "playback";
|
||||||
|
|
||||||
// playback
|
// playback
|
||||||
@ -29,7 +29,7 @@ export class DynamicVideoController {
|
|||||||
annotationOffset: number,
|
annotationOffset: number,
|
||||||
defaultMode: PlayerMode,
|
defaultMode: PlayerMode,
|
||||||
setNoRecording: (noRecs: boolean) => void,
|
setNoRecording: (noRecs: boolean) => void,
|
||||||
setFocusedItem: (timeline: ObjectLifecycleSequence) => void,
|
setFocusedItem: (timeline: TrackingDetailsSequence) => void,
|
||||||
) {
|
) {
|
||||||
this.camera = camera;
|
this.camera = camera;
|
||||||
this.playerController = playerController;
|
this.playerController = playerController;
|
||||||
@ -132,7 +132,7 @@ export class DynamicVideoController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
seekToTimelineItem(timeline: ObjectLifecycleSequence) {
|
seekToTimelineItem(timeline: TrackingDetailsSequence) {
|
||||||
this.playerController.pause();
|
this.playerController.pause();
|
||||||
this.seekToTimestamp(timeline.timestamp + this.annotationOffset);
|
this.seekToTimestamp(timeline.timestamp + this.annotationOffset);
|
||||||
this.setFocusedItem(timeline);
|
this.setFocusedItem(timeline);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ObjectLifecycleSequence } from "@/types/timeline";
|
import { TrackingDetailsSequence } from "@/types/timeline";
|
||||||
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||||
import { useDetailStream } from "@/context/detail-stream-context";
|
import { useDetailStream } from "@/context/detail-stream-context";
|
||||||
import scrollIntoView from "scroll-into-view-if-needed";
|
import scrollIntoView from "scroll-into-view-if-needed";
|
||||||
@ -430,7 +430,8 @@ function EventList({
|
|||||||
}: EventListProps) {
|
}: EventListProps) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
const { selectedObjectIds, toggleObjectSelection } = useDetailStream();
|
const { selectedObjectIds, setSelectedObjectIds, toggleObjectSelection } =
|
||||||
|
useDetailStream();
|
||||||
|
|
||||||
const isSelected = selectedObjectIds.includes(event.id);
|
const isSelected = selectedObjectIds.includes(event.id);
|
||||||
|
|
||||||
@ -438,13 +439,19 @@ function EventList({
|
|||||||
|
|
||||||
const handleObjectSelect = (event: Event | undefined) => {
|
const handleObjectSelect = (event: Event | undefined) => {
|
||||||
if (event) {
|
if (event) {
|
||||||
// onSeek(event.start_time ?? 0);
|
setSelectedObjectIds([]);
|
||||||
toggleObjectSelection(event.id);
|
setSelectedObjectIds([event.id]);
|
||||||
|
onSeek(event.start_time);
|
||||||
} else {
|
} else {
|
||||||
toggleObjectSelection(undefined);
|
setSelectedObjectIds([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTimelineClick = (ts: number, play?: boolean) => {
|
||||||
|
handleObjectSelect(event);
|
||||||
|
onSeek(ts, play);
|
||||||
|
};
|
||||||
|
|
||||||
// Clear selection when effectiveTime has passed this event's end_time
|
// Clear selection when effectiveTime has passed this event's end_time
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSelected && effectiveTime && event.end_time) {
|
if (isSelected && effectiveTime && event.end_time) {
|
||||||
@ -468,11 +475,6 @@ function EventList({
|
|||||||
isSelected
|
isSelected
|
||||||
? "bg-secondary-highlight"
|
? "bg-secondary-highlight"
|
||||||
: "outline-transparent duration-500",
|
: "outline-transparent duration-500",
|
||||||
!isSelected &&
|
|
||||||
(effectiveTime ?? 0) >= (event.start_time ?? 0) - 0.5 &&
|
|
||||||
(effectiveTime ?? 0) <=
|
|
||||||
(event.end_time ?? event.start_time ?? 0) + 0.5 &&
|
|
||||||
"bg-secondary-highlight",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="ml-1.5 flex w-full items-end justify-between">
|
<div className="ml-1.5 flex w-full items-end justify-between">
|
||||||
@ -480,12 +482,18 @@ function EventList({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative rounded-full p-1 text-white",
|
"relative rounded-full p-1 text-white",
|
||||||
isSelected ? "bg-selected" : "bg-muted-foreground",
|
(effectiveTime ?? 0) >= (event.start_time ?? 0) - 0.5 &&
|
||||||
|
(effectiveTime ?? 0) <=
|
||||||
|
(event.end_time ?? event.start_time ?? 0) + 0.5
|
||||||
|
? "bg-selected"
|
||||||
|
: "bg-muted-foreground",
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleObjectSelect(isSelected ? undefined : event);
|
onSeek(event.start_time);
|
||||||
|
handleObjectSelect(event);
|
||||||
}}
|
}}
|
||||||
|
role="button"
|
||||||
>
|
>
|
||||||
{getIconForLabel(
|
{getIconForLabel(
|
||||||
event.sub_label ? event.label + "-verified" : event.label,
|
event.sub_label ? event.label + "-verified" : event.label,
|
||||||
@ -496,7 +504,8 @@ 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 ?? 0);
|
onSeek(event.start_time);
|
||||||
|
handleObjectSelect(event);
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
>
|
>
|
||||||
@ -532,8 +541,10 @@ function EventList({
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<ObjectTimeline
|
<ObjectTimeline
|
||||||
eventId={event.id}
|
eventId={event.id}
|
||||||
onSeek={onSeek}
|
onSeek={handleTimelineClick}
|
||||||
effectiveTime={effectiveTime}
|
effectiveTime={effectiveTime}
|
||||||
|
startTime={event.start_time}
|
||||||
|
endTime={event.end_time}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -542,10 +553,11 @@ function EventList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LifecycleItemProps = {
|
type LifecycleItemProps = {
|
||||||
item: ObjectLifecycleSequence;
|
item: TrackingDetailsSequence;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
onSeek?: (timestamp: number, play?: boolean) => void;
|
onSeek?: (timestamp: number, play?: boolean) => void;
|
||||||
effectiveTime?: number;
|
effectiveTime?: number;
|
||||||
|
isTimelineActive?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function LifecycleItem({
|
function LifecycleItem({
|
||||||
@ -553,6 +565,7 @@ function LifecycleItem({
|
|||||||
isActive,
|
isActive,
|
||||||
onSeek,
|
onSeek,
|
||||||
effectiveTime,
|
effectiveTime,
|
||||||
|
isTimelineActive = false,
|
||||||
}: LifecycleItemProps) {
|
}: LifecycleItemProps) {
|
||||||
const { t } = useTranslation("views/events");
|
const { t } = useTranslation("views/events");
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
@ -605,7 +618,7 @@ function LifecycleItem({
|
|||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSeek?.(item.timestamp ?? 0, false);
|
onSeek?.(item.timestamp, 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",
|
||||||
@ -617,8 +630,9 @@ function LifecycleItem({
|
|||||||
<div className="relative flex size-4 items-center justify-center">
|
<div className="relative 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 || (effectiveTime ?? 0) >= (item?.timestamp ?? 0)) &&
|
(isActive || (effectiveTime ?? 0) >= (item?.timestamp ?? 0)) &&
|
||||||
|
isTimelineActive &&
|
||||||
"fill-selected duration-300",
|
"fill-selected duration-300",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -636,14 +650,14 @@ function LifecycleItem({
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-start gap-1">
|
<div className="flex items-start gap-1">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{t("objectLifecycle.lifecycleItemDesc.header.ratio")}
|
{t("trackingDetails.lifecycleItemDesc.header.ratio")}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium text-foreground">{ratio}</span>
|
<span className="font-medium text-foreground">{ratio}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-1">
|
<div className="flex items-start gap-1">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{t("objectLifecycle.lifecycleItemDesc.header.area")}
|
{t("trackingDetails.lifecycleItemDesc.header.area")}
|
||||||
</span>
|
</span>
|
||||||
{areaPx !== undefined && areaPct !== undefined ? (
|
{areaPx !== undefined && areaPct !== undefined ? (
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
@ -673,13 +687,17 @@ function ObjectTimeline({
|
|||||||
eventId,
|
eventId,
|
||||||
onSeek,
|
onSeek,
|
||||||
effectiveTime,
|
effectiveTime,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
}: {
|
}: {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
onSeek: (ts: number, play?: boolean) => void;
|
onSeek: (ts: number, play?: boolean) => void;
|
||||||
effectiveTime?: number;
|
effectiveTime?: number;
|
||||||
|
startTime?: number;
|
||||||
|
endTime?: number;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("views/events");
|
const { t } = useTranslation("views/events");
|
||||||
const { data: timeline, isValidating } = useSWR<ObjectLifecycleSequence[]>([
|
const { data: timeline, isValidating } = useSWR<TrackingDetailsSequence[]>([
|
||||||
"timeline",
|
"timeline",
|
||||||
{
|
{
|
||||||
source_id: eventId,
|
source_id: eventId,
|
||||||
@ -698,9 +716,17 @@ function ObjectTimeline({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if current time is within the event's start/stop range
|
||||||
|
const isWithinEventRange =
|
||||||
|
effectiveTime !== undefined &&
|
||||||
|
startTime !== undefined &&
|
||||||
|
endTime !== undefined &&
|
||||||
|
effectiveTime >= startTime &&
|
||||||
|
effectiveTime <= endTime;
|
||||||
|
|
||||||
// Calculate how far down the blue line should extend based on effectiveTime
|
// Calculate how far down the blue line should extend based on effectiveTime
|
||||||
const calculateLineHeight = () => {
|
const calculateLineHeight = () => {
|
||||||
if (!timeline || timeline.length === 0) return 0;
|
if (!timeline || timeline.length === 0 || !isWithinEventRange) return 0;
|
||||||
|
|
||||||
const currentTime = effectiveTime ?? 0;
|
const currentTime = effectiveTime ?? 0;
|
||||||
|
|
||||||
@ -742,15 +768,19 @@ function ObjectTimeline({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const blueLineHeight = calculateLineHeight();
|
const activeLineHeight = calculateLineHeight();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="-pb-2 relative mx-2">
|
<div className="-pb-2 relative mx-2">
|
||||||
<div className="absolute -top-2 bottom-2 left-2 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" />
|
<div className="absolute -top-2 bottom-2 left-2 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" />
|
||||||
|
{isWithinEventRange && (
|
||||||
<div
|
<div
|
||||||
className="absolute left-2 top-2 z-[5] max-h-[calc(100%-1rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
|
className={cn(
|
||||||
style={{ height: `${blueLineHeight}%` }}
|
"absolute left-2 top-2 z-[5] max-h-[calc(100%-1rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300",
|
||||||
|
)}
|
||||||
|
style={{ height: `${activeLineHeight}%` }}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{timeline.map((event, idx) => {
|
{timeline.map((event, idx) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
@ -763,6 +793,7 @@ function ObjectTimeline({
|
|||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
effectiveTime={effectiveTime}
|
effectiveTime={effectiveTime}
|
||||||
|
isTimelineActive={isWithinEventRange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -212,13 +212,13 @@ const CarouselPrevious = React.forwardRef<
|
|||||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
aria-label={t("objectLifecycle.carousel.previous")}
|
aria-label={t("trackingDetails.carousel.previous")}
|
||||||
disabled={!canScrollPrev}
|
disabled={!canScrollPrev}
|
||||||
onClick={scrollPrev}
|
onClick={scrollPrev}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
<span className="sr-only">{t("objectLifecycle.carousel.previous")}</span>
|
<span className="sr-only">{t("trackingDetails.carousel.previous")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -243,13 +243,13 @@ const CarouselNext = React.forwardRef<
|
|||||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
aria-label={t("objectLifecycle.carousel.next")}
|
aria-label={t("trackingDetails.carousel.next")}
|
||||||
disabled={!canScrollNext}
|
disabled={!canScrollNext}
|
||||||
onClick={scrollNext}
|
onClick={scrollNext}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
<span className="sr-only">{t("objectLifecycle.carousel.next")}</span>
|
<span className="sr-only">{t("trackingDetails.carousel.next")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export interface DetailStreamContextType {
|
|||||||
currentTime: number;
|
currentTime: number;
|
||||||
camera: string;
|
camera: string;
|
||||||
annotationOffset: number; // milliseconds
|
annotationOffset: number; // milliseconds
|
||||||
|
setSelectedObjectIds: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
setAnnotationOffset: (ms: number) => void;
|
setAnnotationOffset: (ms: number) => void;
|
||||||
toggleObjectSelection: (id: string | undefined) => void;
|
toggleObjectSelection: (id: string | undefined) => void;
|
||||||
isDetailMode: boolean;
|
isDetailMode: boolean;
|
||||||
@ -69,6 +70,7 @@ export function DetailStreamProvider({
|
|||||||
camera,
|
camera,
|
||||||
annotationOffset,
|
annotationOffset,
|
||||||
setAnnotationOffset,
|
setAnnotationOffset,
|
||||||
|
setSelectedObjectIds,
|
||||||
toggleObjectSelection,
|
toggleObjectSelection,
|
||||||
isDetailMode,
|
isDetailMode,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export enum LifecycleClassType {
|
|||||||
PATH_POINT = "path_point",
|
PATH_POINT = "path_point",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ObjectLifecycleSequence = {
|
export type TrackingDetailsSequence = {
|
||||||
camera: string;
|
camera: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
data: {
|
data: {
|
||||||
@ -38,5 +38,5 @@ export type Position = {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
lifecycle_item?: ObjectLifecycleSequence;
|
lifecycle_item?: TrackingDetailsSequence;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { ObjectLifecycleSequence } from "@/types/timeline";
|
import { TrackingDetailsSequence } from "@/types/timeline";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { getTranslatedLabel } from "./i18n";
|
import { getTranslatedLabel } from "./i18n";
|
||||||
import { capitalizeFirstLetter } from "./stringUtil";
|
import { capitalizeFirstLetter } from "./stringUtil";
|
||||||
|
|
||||||
export function getLifecycleItemDescription(
|
export function getLifecycleItemDescription(
|
||||||
lifecycleItem: ObjectLifecycleSequence,
|
lifecycleItem: TrackingDetailsSequence,
|
||||||
) {
|
) {
|
||||||
const rawLabel = Array.isArray(lifecycleItem.data.sub_label)
|
const rawLabel = Array.isArray(lifecycleItem.data.sub_label)
|
||||||
? lifecycleItem.data.sub_label[0]
|
? lifecycleItem.data.sub_label[0]
|
||||||
@ -16,23 +16,23 @@ export function getLifecycleItemDescription(
|
|||||||
|
|
||||||
switch (lifecycleItem.class_type) {
|
switch (lifecycleItem.class_type) {
|
||||||
case "visible":
|
case "visible":
|
||||||
return t("objectLifecycle.lifecycleItemDesc.visible", {
|
return t("trackingDetails.lifecycleItemDesc.visible", {
|
||||||
ns: "views/explore",
|
ns: "views/explore",
|
||||||
label,
|
label,
|
||||||
});
|
});
|
||||||
case "entered_zone":
|
case "entered_zone":
|
||||||
return t("objectLifecycle.lifecycleItemDesc.entered_zone", {
|
return t("trackingDetails.lifecycleItemDesc.entered_zone", {
|
||||||
ns: "views/explore",
|
ns: "views/explore",
|
||||||
label,
|
label,
|
||||||
zones: lifecycleItem.data.zones.join(" and ").replaceAll("_", " "),
|
zones: lifecycleItem.data.zones.join(" and ").replaceAll("_", " "),
|
||||||
});
|
});
|
||||||
case "active":
|
case "active":
|
||||||
return t("objectLifecycle.lifecycleItemDesc.active", {
|
return t("trackingDetails.lifecycleItemDesc.active", {
|
||||||
ns: "views/explore",
|
ns: "views/explore",
|
||||||
label,
|
label,
|
||||||
});
|
});
|
||||||
case "stationary":
|
case "stationary":
|
||||||
return t("objectLifecycle.lifecycleItemDesc.stationary", {
|
return t("trackingDetails.lifecycleItemDesc.stationary", {
|
||||||
ns: "views/explore",
|
ns: "views/explore",
|
||||||
label,
|
label,
|
||||||
});
|
});
|
||||||
@ -43,7 +43,7 @@ export function getLifecycleItemDescription(
|
|||||||
lifecycleItem.data.attribute == "license_plate"
|
lifecycleItem.data.attribute == "license_plate"
|
||||||
) {
|
) {
|
||||||
title = t(
|
title = t(
|
||||||
"objectLifecycle.lifecycleItemDesc.attribute.faceOrLicense_plate",
|
"trackingDetails.lifecycleItemDesc.attribute.faceOrLicense_plate",
|
||||||
{
|
{
|
||||||
ns: "views/explore",
|
ns: "views/explore",
|
||||||
label,
|
label,
|
||||||
@ -53,7 +53,7 @@ export function getLifecycleItemDescription(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
title = t("objectLifecycle.lifecycleItemDesc.attribute.other", {
|
title = t("trackingDetails.lifecycleItemDesc.attribute.other", {
|
||||||
ns: "views/explore",
|
ns: "views/explore",
|
||||||
label: lifecycleItem.data.label,
|
label: lifecycleItem.data.label,
|
||||||
attribute: getTranslatedLabel(
|
attribute: getTranslatedLabel(
|
||||||
@ -64,17 +64,17 @@ export function getLifecycleItemDescription(
|
|||||||
return title;
|
return title;
|
||||||
}
|
}
|
||||||
case "gone":
|
case "gone":
|
||||||
return t("objectLifecycle.lifecycleItemDesc.gone", {
|
return t("trackingDetails.lifecycleItemDesc.gone", {
|
||||||
ns: "views/explore",
|
ns: "views/explore",
|
||||||
label,
|
label,
|
||||||
});
|
});
|
||||||
case "heard":
|
case "heard":
|
||||||
return t("objectLifecycle.lifecycleItemDesc.heard", {
|
return t("trackingDetails.lifecycleItemDesc.heard", {
|
||||||
ns: "views/explore",
|
ns: "views/explore",
|
||||||
label,
|
label,
|
||||||
});
|
});
|
||||||
case "external":
|
case "external":
|
||||||
return t("objectLifecycle.lifecycleItemDesc.external", {
|
return t("trackingDetails.lifecycleItemDesc.external", {
|
||||||
ns: "views/explore",
|
ns: "views/explore",
|
||||||
label,
|
label,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -232,8 +232,8 @@ function ExploreThumbnailImage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShowObjectLifecycle = () => {
|
const handleShowTrackingDetails = () => {
|
||||||
onSelectSearch(event, false, "object_lifecycle");
|
onSelectSearch(event, false, "tracking_details");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShowSnapshot = () => {
|
const handleShowSnapshot = () => {
|
||||||
@ -251,7 +251,7 @@ function ExploreThumbnailImage({
|
|||||||
searchResult={event}
|
searchResult={event}
|
||||||
findSimilar={handleFindSimilar}
|
findSimilar={handleFindSimilar}
|
||||||
refreshResults={mutate}
|
refreshResults={mutate}
|
||||||
showObjectLifecycle={handleShowObjectLifecycle}
|
showTrackingDetails={handleShowTrackingDetails}
|
||||||
showSnapshot={handleShowSnapshot}
|
showSnapshot={handleShowSnapshot}
|
||||||
addTrigger={handleAddTrigger}
|
addTrigger={handleAddTrigger}
|
||||||
isContextMenu={true}
|
isContextMenu={true}
|
||||||
|
|||||||
@ -644,8 +644,8 @@ export default function SearchView({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
refreshResults={refresh}
|
refreshResults={refresh}
|
||||||
showObjectLifecycle={() =>
|
showTrackingDetails={() =>
|
||||||
onSelectSearch(value, false, "object_lifecycle")
|
onSelectSearch(value, false, "tracking_details")
|
||||||
}
|
}
|
||||||
showSnapshot={() =>
|
showSnapshot={() =>
|
||||||
onSelectSearch(value, false, "snapshot")
|
onSelectSearch(value, false, "snapshot")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user