mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-02 17:12:16 +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:
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { ObjectLifecycleSequence, LifecycleClassType } from "@/types/timeline";
|
||||
import { TrackingDetailsSequence, LifecycleClassType } from "@/types/timeline";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
import { useDetailStream } from "@/context/detail-stream-context";
|
||||
@@ -27,7 +27,7 @@ type PathPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
timestamp: number;
|
||||
lifecycle_item?: ObjectLifecycleSequence;
|
||||
lifecycle_item?: TrackingDetailsSequence;
|
||||
objectId: string;
|
||||
};
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function ObjectTrackOverlay({
|
||||
);
|
||||
|
||||
// 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
|
||||
? `timeline?source_id=${selectedObjectIds.join(",")}&limit=1000`
|
||||
: null,
|
||||
@@ -74,7 +74,7 @@ export default function ObjectTrackOverlay({
|
||||
// Group timeline entries by source_id
|
||||
if (!timelineData) return selectedObjectIds.map(() => []);
|
||||
|
||||
const grouped: Record<string, ObjectLifecycleSequence[]> = {};
|
||||
const grouped: Record<string, TrackingDetailsSequence[]> = {};
|
||||
for (const entry of timelineData) {
|
||||
if (!grouped[entry.source_id]) {
|
||||
grouped[entry.source_id] = [];
|
||||
@@ -152,9 +152,9 @@ export default function ObjectTrackOverlay({
|
||||
const eventSequencePoints: PathPoint[] =
|
||||
timelineData
|
||||
?.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!;
|
||||
return {
|
||||
x: left + width / 2, // Center x
|
||||
@@ -183,22 +183,22 @@ export default function ObjectTrackOverlay({
|
||||
const currentZones =
|
||||
timelineData
|
||||
?.filter(
|
||||
(event: ObjectLifecycleSequence) =>
|
||||
(event: TrackingDetailsSequence) =>
|
||||
event.timestamp <= effectiveCurrentTime,
|
||||
)
|
||||
.sort(
|
||||
(a: ObjectLifecycleSequence, b: ObjectLifecycleSequence) =>
|
||||
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
|
||||
b.timestamp - a.timestamp,
|
||||
)[0]?.data?.zones || [];
|
||||
|
||||
// Get current bounding box
|
||||
const currentBox = timelineData
|
||||
?.filter(
|
||||
(event: ObjectLifecycleSequence) =>
|
||||
(event: TrackingDetailsSequence) =>
|
||||
event.timestamp <= effectiveCurrentTime && event.data.box,
|
||||
)
|
||||
.sort(
|
||||
(a: ObjectLifecycleSequence, b: ObjectLifecycleSequence) =>
|
||||
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
|
||||
b.timestamp - a.timestamp,
|
||||
)[0]?.data?.box;
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
||||
);
|
||||
|
||||
toast.success(
|
||||
t("objectLifecycle.annotationSettings.offset.toast.success", {
|
||||
t("trackingDetails.annotationSettings.offset.toast.success", {
|
||||
camera,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
|
||||
@@ -79,7 +79,7 @@ export function AnnotationSettingsPane({
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(
|
||||
t("objectLifecycle.annotationSettings.offset.toast.success", {
|
||||
t("trackingDetails.annotationSettings.offset.toast.success", {
|
||||
camera: event?.camera,
|
||||
}),
|
||||
{
|
||||
@@ -142,7 +142,7 @@ export function AnnotationSettingsPane({
|
||||
return (
|
||||
<div className="mb-3 space-y-3 rounded-lg border border-secondary-foreground bg-background_alt p-2">
|
||||
<Heading as="h4" className="my-2">
|
||||
{t("objectLifecycle.annotationSettings.title")}
|
||||
{t("trackingDetails.annotationSettings.title")}
|
||||
</Heading>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row items-center justify-start gap-2 p-3">
|
||||
@@ -152,11 +152,11 @@ export function AnnotationSettingsPane({
|
||||
onCheckedChange={setShowZones}
|
||||
/>
|
||||
<Label className="cursor-pointer" htmlFor="show-zones">
|
||||
{t("objectLifecycle.annotationSettings.showAllZones.title")}
|
||||
{t("trackingDetails.annotationSettings.showAllZones.title")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("objectLifecycle.annotationSettings.showAllZones.desc")}
|
||||
{t("trackingDetails.annotationSettings.showAllZones.desc")}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
@@ -171,14 +171,14 @@ export function AnnotationSettingsPane({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("objectLifecycle.annotationSettings.offset.label")}
|
||||
{t("trackingDetails.annotationSettings.offset.label")}
|
||||
</FormLabel>
|
||||
<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">
|
||||
<PiWarningCircle className="size-24" />
|
||||
<div>
|
||||
<Trans ns="views/explore">
|
||||
objectLifecycle.annotationSettings.offset.desc
|
||||
trackingDetails.annotationSettings.offset.desc
|
||||
</Trans>
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
@@ -203,10 +203,10 @@ export function AnnotationSettingsPane({
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans ns="views/explore">
|
||||
objectLifecycle.annotationSettings.offset.millisecondsToOffset
|
||||
trackingDetails.annotationSettings.offset.millisecondsToOffset
|
||||
</Trans>
|
||||
<div className="mt-2">
|
||||
{t("objectLifecycle.annotationSettings.offset.tips")}
|
||||
{t("trackingDetails.annotationSettings.offset.tips")}
|
||||
</div>
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
@@ -105,7 +105,7 @@ export function ObjectPath({
|
||||
<TooltipContent side="top" className="smart-capitalize">
|
||||
{pos.lifecycle_item
|
||||
? getLifecycleItemDescription(pos.lifecycle_item)
|
||||
: t("objectLifecycle.trackedPoint")}
|
||||
: t("trackingDetails.trackedPoint")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Event } from "@/types/event";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
|
||||
import ObjectLifecycle from "./ObjectLifecycle";
|
||||
import TrackingDetails from "./TrackingDetails";
|
||||
import Chip from "@/components/indicators/Chip";
|
||||
import { FaDownload, FaImages, FaShareAlt } from "react-icons/fa";
|
||||
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
|
||||
@@ -411,7 +411,7 @@ export default function ReviewDetailDialog({
|
||||
|
||||
{pane == "details" && selectedEvent && (
|
||||
<div className="mt-0 flex size-full flex-col gap-2">
|
||||
<ObjectLifecycle event={selectedEvent} setPane={setPane} />
|
||||
<TrackingDetails event={selectedEvent} setPane={setPane} />
|
||||
</div>
|
||||
)}
|
||||
</Content>
|
||||
@@ -544,7 +544,7 @@ function EventItem({
|
||||
</Chip>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("itemMenu.viewObjectLifecycle.label")}
|
||||
{t("itemMenu.viewTrackingDetails.label")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -34,8 +34,7 @@ import {
|
||||
FaRegListAlt,
|
||||
FaVideo,
|
||||
} from "react-icons/fa";
|
||||
import { FaRotate } from "react-icons/fa6";
|
||||
import ObjectLifecycle from "./ObjectLifecycle";
|
||||
import TrackingDetails from "./TrackingDetails";
|
||||
import {
|
||||
MobilePage,
|
||||
MobilePageContent,
|
||||
@@ -80,12 +79,13 @@ import FaceSelectionDialog from "../FaceSelectionDialog";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { CgTranscript } from "react-icons/cg";
|
||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||
import { PiPath } from "react-icons/pi";
|
||||
|
||||
const SEARCH_TABS = [
|
||||
"details",
|
||||
"snapshot",
|
||||
"video",
|
||||
"object_lifecycle",
|
||||
"tracking_details",
|
||||
] as const;
|
||||
export type SearchTab = (typeof SEARCH_TABS)[number];
|
||||
|
||||
@@ -160,7 +160,7 @@ export default function SearchDetailDialog({
|
||||
}
|
||||
|
||||
if (search.data.type != "object" || !search.has_clip) {
|
||||
const index = views.indexOf("object_lifecycle");
|
||||
const index = views.indexOf("tracking_details");
|
||||
views.splice(index, 1);
|
||||
}
|
||||
|
||||
@@ -235,9 +235,7 @@ export default function SearchDetailDialog({
|
||||
{item == "details" && <FaRegListAlt className="size-4" />}
|
||||
{item == "snapshot" && <FaImage className="size-4" />}
|
||||
{item == "video" && <FaVideo className="size-4" />}
|
||||
{item == "object_lifecycle" && (
|
||||
<FaRotate className="size-4" />
|
||||
)}
|
||||
{item == "tracking_details" && <PiPath className="size-4" />}
|
||||
<div className="smart-capitalize">{t(`type.${item}`)}</div>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
@@ -268,8 +266,8 @@ export default function SearchDetailDialog({
|
||||
/>
|
||||
)}
|
||||
{page == "video" && <VideoTab search={search} />}
|
||||
{page == "object_lifecycle" && (
|
||||
<ObjectLifecycle
|
||||
{page == "tracking_details" && (
|
||||
<TrackingDetails
|
||||
className="w-full overflow-x-hidden"
|
||||
event={search as unknown as Event}
|
||||
fullscreen={true}
|
||||
|
||||
+95
-19
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Event } from "@/types/event";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ObjectLifecycleSequence } from "@/types/timeline";
|
||||
import { TrackingDetailsSequence } from "@/types/timeline";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { ReviewDetailPaneType } from "@/types/review";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
@@ -41,6 +41,13 @@ import {
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { ObjectPath } from "./ObjectPath";
|
||||
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||
@@ -48,23 +55,26 @@ import { IoPlayCircleOutline } from "react-icons/io5";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { HiDotsHorizontal } from "react-icons/hi";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type ObjectLifecycleProps = {
|
||||
type TrackingDetailsProps = {
|
||||
className?: string;
|
||||
event: Event;
|
||||
fullscreen?: boolean;
|
||||
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>;
|
||||
};
|
||||
|
||||
export default function ObjectLifecycle({
|
||||
export default function TrackingDetails({
|
||||
className,
|
||||
event,
|
||||
fullscreen = false,
|
||||
setPane,
|
||||
}: ObjectLifecycleProps) {
|
||||
}: TrackingDetailsProps) {
|
||||
const { t } = useTranslation(["views/explore"]);
|
||||
|
||||
const { data: eventSequence } = useSWR<ObjectLifecycleSequence[]>([
|
||||
const { data: eventSequence } = useSWR<TrackingDetailsSequence[]>([
|
||||
"timeline",
|
||||
{
|
||||
source_id: event.id,
|
||||
@@ -447,7 +457,7 @@ export default function ObjectLifecycle({
|
||||
<div className="relative aspect-video">
|
||||
<div className="flex flex-col items-center justify-center p-20 text-center">
|
||||
<LuFolderX className="size-16" />
|
||||
{t("objectLifecycle.noImageFound")}
|
||||
{t("trackingDetails.noImageFound")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -558,7 +568,7 @@ export default function ObjectLifecycle({
|
||||
}
|
||||
>
|
||||
<div className="text-primary">
|
||||
{t("objectLifecycle.createObjectMask")}
|
||||
{t("trackingDetails.createObjectMask")}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
@@ -568,7 +578,7 @@ export default function ObjectLifecycle({
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Tooltip>
|
||||
@@ -576,7 +586,7 @@ export default function ObjectLifecycle({
|
||||
<Button
|
||||
variant={showControls ? "select" : "default"}
|
||||
className="size-7 p-1.5"
|
||||
aria-label={t("objectLifecycle.adjustAnnotationSettings")}
|
||||
aria-label={t("trackingDetails.adjustAnnotationSettings")}
|
||||
>
|
||||
<LuSettings
|
||||
className="size-5"
|
||||
@@ -586,7 +596,7 @@ export default function ObjectLifecycle({
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("objectLifecycle.adjustAnnotationSettings")}
|
||||
{t("trackingDetails.adjustAnnotationSettings")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
@@ -594,10 +604,10 @@ export default function ObjectLifecycle({
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="mb-2 text-sm text-muted-foreground">
|
||||
{t("objectLifecycle.scrollViewTips")}
|
||||
{t("trackingDetails.scrollViewTips")}
|
||||
</div>
|
||||
<div className="min-w-20 text-right text-sm text-muted-foreground">
|
||||
{t("objectLifecycle.count", {
|
||||
{t("trackingDetails.count", {
|
||||
first: selectedIndex + 1,
|
||||
second: eventSequence?.length ?? 0,
|
||||
})}
|
||||
@@ -605,7 +615,7 @@ export default function ObjectLifecycle({
|
||||
</div>
|
||||
{config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config && (
|
||||
<div className="-mt-2 mb-2 text-sm text-danger">
|
||||
{t("objectLifecycle.autoTrackingTips")}
|
||||
{t("trackingDetails.autoTrackingTips")}
|
||||
</div>
|
||||
)}
|
||||
{showControls && (
|
||||
@@ -756,7 +766,7 @@ export default function ObjectLifecycle({
|
||||
}
|
||||
|
||||
type GetTimelineIconParams = {
|
||||
lifecycleItem: ObjectLifecycleSequence;
|
||||
lifecycleItem: TrackingDetailsSequence;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@@ -794,7 +804,7 @@ export function LifecycleIcon({
|
||||
}
|
||||
|
||||
type LifecycleIconRowProps = {
|
||||
item: ObjectLifecycleSequence;
|
||||
item: TrackingDetailsSequence;
|
||||
isActive?: boolean;
|
||||
formattedEventTimestamp: string;
|
||||
ratio: string;
|
||||
@@ -816,7 +826,11 @@ function LifecycleIconRow({
|
||||
setSelectedZone,
|
||||
getZoneColor,
|
||||
}: 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 (
|
||||
<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="flex items-center gap-1">
|
||||
<span className="text-primary-variant">
|
||||
{t("objectLifecycle.lifecycleItemDesc.header.ratio")}
|
||||
{t("trackingDetails.lifecycleItemDesc.header.ratio")}
|
||||
</span>
|
||||
<span className="font-medium text-primary">{ratio}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-primary-variant">
|
||||
{t("objectLifecycle.lifecycleItemDesc.header.area")}
|
||||
{t("trackingDetails.lifecycleItemDesc.header.area")}
|
||||
</span>
|
||||
{areaPx !== undefined && areaPct !== undefined ? (
|
||||
<span className="font-medium text-primary">
|
||||
@@ -903,7 +917,69 @@ function LifecycleIconRow({
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
Reference in New Issue
Block a user