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:
Josh Hawkins 2025-10-26 13:12:20 -05:00 committed by GitHub
parent 43706eb48d
commit 5715ed62ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 224 additions and 117 deletions

View File

@ -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",

View File

@ -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}
/> />

View File

@ -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 && (

View File

@ -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;

View File

@ -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" },

View File

@ -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>

View File

@ -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>

View File

@ -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>
)} )}

View File

@ -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}

View File

@ -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="whitespace-nowrap">{formattedEventTimestamp}</div> <div className="flex flex-row items-center gap-3">
<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>

View File

@ -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);

View File

@ -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" />
<div {isWithinEventRange && (
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" <div
style={{ height: `${blueLineHeight}%` }} className={cn(
/> "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}
/> />
); );
})} })}

View File

@ -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>
); );
}); });

View File

@ -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,
}; };

View File

@ -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;
}; };

View File

@ -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,
}); });

View File

@ -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}

View File

@ -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")