mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 13:34:13 +03:00
Detail Stream tweaks (#20553)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* show audio events in detail stream * refactor object lifecycle to look similar to detail stream * pass detail stream as prop to avoid context error * fix highlighting timing * add view in explore to menu
This commit is contained in:
parent
a8bcc109a9
commit
a2396db2aa
@ -2,14 +2,6 @@ import useSWR from "swr";
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
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 {
|
|
||||||
Carousel,
|
|
||||||
CarouselApi,
|
|
||||||
CarouselContent,
|
|
||||||
CarouselItem,
|
|
||||||
CarouselNext,
|
|
||||||
CarouselPrevious,
|
|
||||||
} from "@/components/ui/carousel";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ObjectLifecycleSequence } from "@/types/timeline";
|
import { ObjectLifecycleSequence } from "@/types/timeline";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
@ -33,7 +25,6 @@ import {
|
|||||||
MdOutlinePictureInPictureAlt,
|
MdOutlinePictureInPictureAlt,
|
||||||
} from "react-icons/md";
|
} from "react-icons/md";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { useApiHost } from "@/api";
|
import { useApiHost } from "@/api";
|
||||||
import { isDesktop, isIOS, isSafari } from "react-device-detect";
|
import { isDesktop, isIOS, isSafari } from "react-device-detect";
|
||||||
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
|
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
|
||||||
@ -55,6 +46,7 @@ import { ObjectPath } from "./ObjectPath";
|
|||||||
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||||
import { IoPlayCircleOutline } from "react-icons/io5";
|
import { IoPlayCircleOutline } from "react-icons/io5";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
|
|
||||||
type ObjectLifecycleProps = {
|
type ObjectLifecycleProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -166,16 +158,6 @@ export default function ObjectLifecycle({
|
|||||||
configAnnotationOffset,
|
configAnnotationOffset,
|
||||||
);
|
);
|
||||||
|
|
||||||
const detectArea = useMemo(() => {
|
|
||||||
if (!config) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
config.cameras[event.camera]?.detect?.width *
|
|
||||||
config.cameras[event.camera]?.detect?.height
|
|
||||||
);
|
|
||||||
}, [config, event.camera]);
|
|
||||||
|
|
||||||
const savedPathPoints = useMemo(() => {
|
const savedPathPoints = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
event.data.path_data?.map(([coords, timestamp]: [number[], number]) => ({
|
event.data.path_data?.map(([coords, timestamp]: [number[], number]) => ({
|
||||||
@ -272,91 +254,108 @@ export default function ObjectLifecycle({
|
|||||||
|
|
||||||
// carousels
|
// carousels
|
||||||
|
|
||||||
const [mainApi, setMainApi] = useState<CarouselApi>();
|
// Selected lifecycle item index; -1 when viewing a path-only point
|
||||||
const [thumbnailApi, setThumbnailApi] = useState<CarouselApi>();
|
|
||||||
const [current, setCurrent] = useState(0);
|
|
||||||
|
|
||||||
const handleThumbnailClick = (index: number) => {
|
|
||||||
if (!mainApi || !thumbnailApi) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mainApi.scrollTo(index);
|
|
||||||
setCurrent(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleThumbnailNavigation = useCallback(
|
|
||||||
(direction: "next" | "previous") => {
|
|
||||||
if (!mainApi || !thumbnailApi || !eventSequence) return;
|
|
||||||
const newIndex =
|
|
||||||
direction === "next"
|
|
||||||
? Math.min(current + 1, eventSequence.length - 1)
|
|
||||||
: Math.max(current - 1, 0);
|
|
||||||
mainApi.scrollTo(newIndex);
|
|
||||||
thumbnailApi.scrollTo(newIndex);
|
|
||||||
setCurrent(newIndex);
|
|
||||||
},
|
|
||||||
[mainApi, thumbnailApi, current, eventSequence],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (eventSequence && eventSequence.length > 0) {
|
|
||||||
if (current == -1) {
|
|
||||||
// normal path point
|
|
||||||
setBoxStyle(null);
|
|
||||||
setLifecycleZones([]);
|
|
||||||
} else {
|
|
||||||
// lifecycle point
|
|
||||||
setTimeIndex(eventSequence?.[current].timestamp);
|
|
||||||
handleSetBox(
|
|
||||||
eventSequence?.[current].data.box ?? [],
|
|
||||||
eventSequence?.[current].data?.attribute_box,
|
|
||||||
);
|
|
||||||
setLifecycleZones(eventSequence?.[current].data.zones);
|
|
||||||
}
|
|
||||||
setSelectedZone("");
|
|
||||||
}
|
|
||||||
}, [current, imgLoaded, handleSetBox, eventSequence]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!mainApi || !thumbnailApi || !eventSequence || !event) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTopSelect = () => {
|
|
||||||
const selected = mainApi.selectedScrollSnap();
|
|
||||||
setCurrent(selected);
|
|
||||||
thumbnailApi.scrollTo(selected);
|
|
||||||
};
|
|
||||||
|
|
||||||
mainApi.on("select", handleTopSelect).on("reInit", handleTopSelect);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mainApi.off("select", handleTopSelect);
|
|
||||||
};
|
|
||||||
// we know that these deps are correct
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [mainApi, thumbnailApi]);
|
|
||||||
|
|
||||||
const handlePathPointClick = useCallback(
|
const handlePathPointClick = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (!mainApi || !thumbnailApi || !eventSequence) return;
|
if (!eventSequence) return;
|
||||||
const sequenceIndex = eventSequence.findIndex(
|
const sequenceIndex = eventSequence.findIndex(
|
||||||
(item) => item.timestamp === pathPoints[index].timestamp,
|
(item) => item.timestamp === pathPoints[index].timestamp,
|
||||||
);
|
);
|
||||||
if (sequenceIndex !== -1) {
|
if (sequenceIndex !== -1) {
|
||||||
mainApi.scrollTo(sequenceIndex);
|
setTimeIndex(eventSequence[sequenceIndex].timestamp);
|
||||||
thumbnailApi.scrollTo(sequenceIndex);
|
handleSetBox(
|
||||||
setCurrent(sequenceIndex);
|
eventSequence[sequenceIndex]?.data.box ?? [],
|
||||||
|
eventSequence[sequenceIndex]?.data?.attribute_box,
|
||||||
|
);
|
||||||
|
setLifecycleZones(eventSequence[sequenceIndex]?.data.zones);
|
||||||
} else {
|
} else {
|
||||||
// click on a normal path point, not a lifecycle point
|
// click on a normal path point, not a lifecycle point
|
||||||
setCurrent(-1);
|
|
||||||
setTimeIndex(pathPoints[index].timestamp);
|
setTimeIndex(pathPoints[index].timestamp);
|
||||||
|
setBoxStyle(null);
|
||||||
|
setLifecycleZones([]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mainApi, thumbnailApi, eventSequence, pathPoints],
|
[eventSequence, pathPoints, handleSetBox],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!event.id || !eventSequence || !config || !timeIndex) {
|
const formattedStart = config
|
||||||
|
? formatUnixTimestampToDateTime(event.start_time ?? 0, {
|
||||||
|
timezone: config.ui.timezone,
|
||||||
|
date_format:
|
||||||
|
config.ui.time_format == "24hour"
|
||||||
|
? t("time.formattedTimestampHourMinuteSecond.24hour", {
|
||||||
|
ns: "common",
|
||||||
|
})
|
||||||
|
: t("time.formattedTimestampHourMinuteSecond.12hour", {
|
||||||
|
ns: "common",
|
||||||
|
}),
|
||||||
|
time_style: "medium",
|
||||||
|
date_style: "medium",
|
||||||
|
})
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const formattedEnd = config
|
||||||
|
? formatUnixTimestampToDateTime(event.end_time ?? 0, {
|
||||||
|
timezone: config.ui.timezone,
|
||||||
|
date_format:
|
||||||
|
config.ui.time_format == "24hour"
|
||||||
|
? t("time.formattedTimestampHourMinuteSecond.24hour", {
|
||||||
|
ns: "common",
|
||||||
|
})
|
||||||
|
: t("time.formattedTimestampHourMinuteSecond.12hour", {
|
||||||
|
ns: "common",
|
||||||
|
}),
|
||||||
|
time_style: "medium",
|
||||||
|
date_style: "medium",
|
||||||
|
})
|
||||||
|
: "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!eventSequence || eventSequence.length === 0) return;
|
||||||
|
// If timeIndex hasn't been set to a non-zero value, prefer the first lifecycle timestamp
|
||||||
|
if (!timeIndex) {
|
||||||
|
setTimeIndex(eventSequence[0].timestamp);
|
||||||
|
handleSetBox(
|
||||||
|
eventSequence[0]?.data.box ?? [],
|
||||||
|
eventSequence[0]?.data?.attribute_box,
|
||||||
|
);
|
||||||
|
setLifecycleZones(eventSequence[0]?.data.zones);
|
||||||
|
}
|
||||||
|
}, [eventSequence, timeIndex, handleSetBox]);
|
||||||
|
|
||||||
|
// When timeIndex changes or image finishes loading, sync the box/zones to matching lifecycle, else clear
|
||||||
|
useEffect(() => {
|
||||||
|
if (!eventSequence || timeIndex == null) return;
|
||||||
|
const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex);
|
||||||
|
if (idx !== -1) {
|
||||||
|
if (imgLoaded) {
|
||||||
|
handleSetBox(
|
||||||
|
eventSequence[idx]?.data.box ?? [],
|
||||||
|
eventSequence[idx]?.data?.attribute_box,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setLifecycleZones(eventSequence[idx]?.data.zones);
|
||||||
|
} else {
|
||||||
|
// Non-lifecycle point (e.g., saved path point)
|
||||||
|
setBoxStyle(null);
|
||||||
|
setLifecycleZones([]);
|
||||||
|
}
|
||||||
|
}, [timeIndex, imgLoaded, eventSequence, handleSetBox]);
|
||||||
|
|
||||||
|
const selectedLifecycle = useMemo(() => {
|
||||||
|
if (!eventSequence || eventSequence.length === 0) return undefined;
|
||||||
|
const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex);
|
||||||
|
return idx !== -1 ? eventSequence[idx] : eventSequence[0];
|
||||||
|
}, [eventSequence, timeIndex]);
|
||||||
|
|
||||||
|
const selectedIndex = useMemo(() => {
|
||||||
|
if (!eventSequence || eventSequence.length === 0) return 0;
|
||||||
|
const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex);
|
||||||
|
return idx === -1 ? 0 : idx;
|
||||||
|
}, [eventSequence, timeIndex]);
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
return <ActivityIndicator />;
|
return <ActivityIndicator />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -502,7 +501,7 @@ export default function ObjectLifecycle({
|
|||||||
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
|
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(
|
navigate(
|
||||||
`/settings?page=masksAndZones&camera=${event.camera}&object_mask=${eventSequence?.[current].data.box}`,
|
`/settings?page=masksAndZones&camera=${event.camera}&object_mask=${selectedLifecycle?.data.box}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -547,8 +546,8 @@ export default function ObjectLifecycle({
|
|||||||
</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("objectLifecycle.count", {
|
||||||
first: current + 1,
|
first: selectedIndex + 1,
|
||||||
second: eventSequence.length,
|
second: eventSequence?.length ?? 0,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -567,205 +566,187 @@ export default function ObjectLifecycle({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative flex flex-col items-center justify-center">
|
<div className="mt-4">
|
||||||
<Carousel className="m-0 w-full" setApi={setMainApi}>
|
<div
|
||||||
<CarouselContent>
|
className={cn(
|
||||||
{eventSequence.map((item, index) => (
|
"rounded-md bg-secondary p-2 outline outline-[3px] -outline-offset-[2.8px] outline-transparent duration-500",
|
||||||
<CarouselItem key={index}>
|
)}
|
||||||
<Card className="p-1 text-sm md:p-2" key={index}>
|
>
|
||||||
<CardContent className="flex flex-row items-center gap-3 p-1 md:p-2">
|
<div className="flex w-full items-center justify-between">
|
||||||
<div className="flex flex-1 flex-row items-center justify-start p-3 pl-1">
|
<div
|
||||||
<div
|
className="flex items-center gap-2 font-medium"
|
||||||
className="rounded-lg p-2"
|
onClick={(e) => {
|
||||||
style={{
|
e.stopPropagation();
|
||||||
backgroundColor: "rgb(110,110,110)",
|
setTimeIndex(event.start_time ?? 0);
|
||||||
}}
|
}}
|
||||||
>
|
role="button"
|
||||||
<div
|
>
|
||||||
key={item.data.label}
|
{getIconForLabel(
|
||||||
className="relative flex aspect-square size-4 flex-row items-center md:size-8"
|
event.label,
|
||||||
>
|
"size-6 text-primary dark:text-white",
|
||||||
{getIconForLabel(
|
)}
|
||||||
item.data.label,
|
<div className="flex items-end gap-2">
|
||||||
"size-4 md:size-6 absolute left-0 top-0",
|
<span>{getTranslatedLabel(event.label)}</span>
|
||||||
)}
|
<span className="text-secondary-foreground">
|
||||||
|
{formattedStart ?? ""} - {formattedEnd ?? ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
{!eventSequence ? (
|
||||||
|
<ActivityIndicator className="size-2" size={2} />
|
||||||
|
) : eventSequence.length === 0 ? (
|
||||||
|
<div className="py-2 text-muted-foreground">
|
||||||
|
{t("detail.noObjectDetailData", { ns: "views/events" })}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mx-2 mt-4 space-y-2">
|
||||||
|
{eventSequence.map((item, idx) => {
|
||||||
|
const isActive =
|
||||||
|
Math.abs((timeIndex ?? 0) - (item.timestamp ?? 0)) <= 0.5;
|
||||||
|
const formattedEventTimestamp = config
|
||||||
|
? formatUnixTimestampToDateTime(item.timestamp ?? 0, {
|
||||||
|
timezone: config.ui.timezone,
|
||||||
|
date_format:
|
||||||
|
config.ui.time_format == "24hour"
|
||||||
|
? t(
|
||||||
|
"time.formattedTimestampHourMinuteSecond.24hour",
|
||||||
|
{ ns: "common" },
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"time.formattedTimestampHourMinuteSecond.12hour",
|
||||||
|
{ ns: "common" },
|
||||||
|
),
|
||||||
|
time_style: "medium",
|
||||||
|
date_style: "medium",
|
||||||
|
})
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const ratio =
|
||||||
|
Array.isArray(item.data.box) && item.data.box.length >= 4
|
||||||
|
? (
|
||||||
|
aspectRatio *
|
||||||
|
(item.data.box[2] / item.data.box[3])
|
||||||
|
).toFixed(2)
|
||||||
|
: "N/A";
|
||||||
|
const areaPx =
|
||||||
|
Array.isArray(item.data.box) && item.data.box.length >= 4
|
||||||
|
? Math.round(
|
||||||
|
(config.cameras[event.camera]?.detect?.width ?? 0) *
|
||||||
|
(config.cameras[event.camera]?.detect?.height ??
|
||||||
|
0) *
|
||||||
|
(item.data.box[2] * item.data.box[3]),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const areaPct =
|
||||||
|
Array.isArray(item.data.box) && item.data.box.length >= 4
|
||||||
|
? (item.data.box[2] * item.data.box[3]).toFixed(4)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
|
||||||
|
role="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTimeIndex(item.timestamp ?? 0);
|
||||||
|
handleSetBox(
|
||||||
|
item.data.box ?? [],
|
||||||
|
item.data.attribute_box,
|
||||||
|
);
|
||||||
|
setLifecycleZones(item.data.zones);
|
||||||
|
setSelectedZone("");
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer flex-col gap-1 rounded-md p-2 text-sm text-primary-variant",
|
||||||
|
isActive
|
||||||
|
? "bg-secondary-highlight font-semibold text-primary outline-[1.5px] -outline-offset-[1.1px] outline-primary/40 dark:font-normal"
|
||||||
|
: "duration-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex size-7 items-center justify-center">
|
||||||
<LifecycleIcon
|
<LifecycleIcon
|
||||||
className="absolute bottom-0 right-0 size-2 md:size-4"
|
|
||||||
lifecycleItem={item}
|
lifecycleItem={item}
|
||||||
|
className="size-5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex w-full flex-row justify-between">
|
||||||
<div className="mx-3 text-lg">
|
<div>{getLifecycleItemDescription(item)}</div>
|
||||||
<div className="flex flex-row items-center text-primary smart-capitalize">
|
<div className={cn("p-1 text-sm")}>
|
||||||
{getLifecycleItemDescription(item)}
|
{formattedEventTimestamp}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-primary-variant">
|
|
||||||
{formatUnixTimestampToDateTime(item.timestamp, {
|
|
||||||
timezone: config.ui.timezone,
|
|
||||||
date_format:
|
|
||||||
config.ui.time_format == "24hour"
|
|
||||||
? t("time.formattedTimestamp2.24hour", {
|
|
||||||
ns: "common",
|
|
||||||
})
|
|
||||||
: t("time.formattedTimestamp2.12hour", {
|
|
||||||
ns: "common",
|
|
||||||
}),
|
|
||||||
time_style: "medium",
|
|
||||||
date_style: "medium",
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex w-5/12 flex-row items-start justify-start">
|
<div className="ml-8 mt-1 flex flex-wrap items-center gap-3 text-sm text-secondary-foreground">
|
||||||
<div className="text-md mr-2 w-1/3">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex flex-col items-end justify-start">
|
<div className="flex items-center gap-1">
|
||||||
<p className="mb-1.5 text-sm text-primary-variant">
|
<span className="text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"objectLifecycle.lifecycleItemDesc.header.zones",
|
"objectLifecycle.lifecycleItemDesc.header.ratio",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{ratio}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"objectLifecycle.lifecycleItemDesc.header.area",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{areaPx !== undefined && areaPct !== undefined ? (
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
px: {areaPx} · %: {areaPct}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>N/A</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</div>
|
||||||
{item.class_type === "entered_zone"
|
{item.class_type === "entered_zone" && (
|
||||||
? item.data.zones.map((zone, index) => (
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<span className="text-muted-foreground">
|
||||||
key={index}
|
{t(
|
||||||
className="flex flex-row items-center gap-1"
|
"objectLifecycle.lifecycleItemDesc.header.zones",
|
||||||
>
|
)}
|
||||||
{true && (
|
</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{item.data.zones.map((zone, zidx) => (
|
||||||
|
<div
|
||||||
|
key={`${zone}-${zidx}`}
|
||||||
|
className="flex cursor-pointer items-center gap-1"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedZone(zone);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="size-3 rounded-lg"
|
className="size-3 rounded"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `rgb(${getZoneColor(zone)})`,
|
backgroundColor: `rgb(${getZoneColor(zone)})`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
<span className="smart-capitalize">
|
||||||
<div
|
{zone.replaceAll("_", " ")}
|
||||||
key={index}
|
</span>
|
||||||
className="cursor-pointer smart-capitalize"
|
|
||||||
onClick={() => setSelectedZone(zone)}
|
|
||||||
>
|
|
||||||
{zone.replaceAll("_", " ")}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))
|
|
||||||
: "-"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-md mr-2 w-1/3">
|
|
||||||
<div className="flex flex-col items-end justify-start">
|
|
||||||
<p className="mb-1.5 text-sm text-primary-variant">
|
|
||||||
{t(
|
|
||||||
"objectLifecycle.lifecycleItemDesc.header.ratio",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
{Array.isArray(item.data.box) &&
|
|
||||||
item.data.box.length >= 4
|
|
||||||
? (
|
|
||||||
aspectRatio *
|
|
||||||
(item.data.box[2] / item.data.box[3])
|
|
||||||
).toFixed(2)
|
|
||||||
: "N/A"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-md mr-2 w-1/3">
|
|
||||||
<div className="flex flex-col items-end justify-start">
|
|
||||||
<p className="mb-1.5 text-sm text-primary-variant">
|
|
||||||
{t("objectLifecycle.lifecycleItemDesc.header.area")}
|
|
||||||
</p>
|
|
||||||
{Array.isArray(item.data.box) &&
|
|
||||||
item.data.box.length >= 4 ? (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col text-xs">
|
|
||||||
px:{" "}
|
|
||||||
{Math.round(
|
|
||||||
detectArea *
|
|
||||||
(item.data.box[2] * item.data.box[3]),
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col text-xs">
|
</div>
|
||||||
%:{" "}
|
|
||||||
{(
|
|
||||||
(detectArea *
|
|
||||||
(item.data.box[2] * item.data.box[3])) /
|
|
||||||
detectArea
|
|
||||||
).toFixed(4)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"N/A"
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
);
|
||||||
</Card>
|
})}
|
||||||
</CarouselItem>
|
</div>
|
||||||
))}
|
|
||||||
</CarouselContent>
|
|
||||||
</Carousel>
|
|
||||||
</div>
|
|
||||||
<div className="relative mt-4 flex flex-col items-center justify-center">
|
|
||||||
<Carousel
|
|
||||||
opts={{
|
|
||||||
align: "center",
|
|
||||||
containScroll: "keepSnaps",
|
|
||||||
dragFree: true,
|
|
||||||
}}
|
|
||||||
className="max-w-[72%] md:max-w-[85%]"
|
|
||||||
setApi={setThumbnailApi}
|
|
||||||
>
|
|
||||||
<CarouselContent
|
|
||||||
className={cn(
|
|
||||||
"-ml-1 flex select-none flex-row",
|
|
||||||
eventSequence.length > 4 ? "justify-start" : "justify-center",
|
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
{eventSequence.map((item, index) => (
|
</div>
|
||||||
<CarouselItem
|
|
||||||
key={index}
|
|
||||||
className={cn("basis-auto cursor-pointer pl-1")}
|
|
||||||
onClick={() => handleThumbnailClick(index)}
|
|
||||||
>
|
|
||||||
<div className="p-1">
|
|
||||||
<Card>
|
|
||||||
<CardContent
|
|
||||||
className={cn(
|
|
||||||
"flex aspect-square items-center justify-center rounded-md p-2",
|
|
||||||
index === current && "bg-selected",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<LifecycleIcon
|
|
||||||
className={cn(
|
|
||||||
"size-8",
|
|
||||||
index === current
|
|
||||||
? "bg-selected text-white"
|
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
lifecycleItem={item}
|
|
||||||
/>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPortal>
|
|
||||||
<TooltipContent className="smart-capitalize">
|
|
||||||
{getLifecycleItemDescription(item)}
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPortal>
|
|
||||||
</Tooltip>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</CarouselItem>
|
|
||||||
))}
|
|
||||||
</CarouselContent>
|
|
||||||
<CarouselPrevious
|
|
||||||
disabled={current === 0}
|
|
||||||
onClick={() => handleThumbnailNavigation("previous")}
|
|
||||||
/>
|
|
||||||
<CarouselNext
|
|
||||||
disabled={current === eventSequence.length - 1}
|
|
||||||
onClick={() => handleThumbnailNavigation("next")}
|
|
||||||
/>
|
|
||||||
</Carousel>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record";
|
import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import ObjectTrackOverlay from "@/components/overlay/ObjectTrackOverlay";
|
import ObjectTrackOverlay from "@/components/overlay/ObjectTrackOverlay";
|
||||||
import { useDetailStream } from "@/context/detail-stream-context";
|
import { DetailStreamContextType } from "@/context/detail-stream-context";
|
||||||
|
|
||||||
// Android native hls does not seek correctly
|
// Android native hls does not seek correctly
|
||||||
const USE_NATIVE_HLS = !isAndroid;
|
const USE_NATIVE_HLS = !isAndroid;
|
||||||
@ -54,6 +54,7 @@ type HlsVideoPlayerProps = {
|
|||||||
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
|
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
|
||||||
toggleFullscreen?: () => void;
|
toggleFullscreen?: () => void;
|
||||||
onError?: (error: RecordingPlayerError) => void;
|
onError?: (error: RecordingPlayerError) => void;
|
||||||
|
detail?: Partial<DetailStreamContextType>;
|
||||||
};
|
};
|
||||||
export default function HlsVideoPlayer({
|
export default function HlsVideoPlayer({
|
||||||
videoRef,
|
videoRef,
|
||||||
@ -74,16 +75,17 @@ export default function HlsVideoPlayer({
|
|||||||
onUploadFrame,
|
onUploadFrame,
|
||||||
toggleFullscreen,
|
toggleFullscreen,
|
||||||
onError,
|
onError,
|
||||||
|
detail,
|
||||||
}: HlsVideoPlayerProps) {
|
}: HlsVideoPlayerProps) {
|
||||||
const { t } = useTranslation("components/player");
|
const { t } = useTranslation("components/player");
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const {
|
|
||||||
selectedObjectId,
|
// for detail stream context in History
|
||||||
selectedObjectTimeline,
|
const selectedObjectId = detail?.selectedObjectId;
|
||||||
currentTime,
|
const selectedObjectTimeline = detail?.selectedObjectTimeline;
|
||||||
camera,
|
const currentTime = detail?.currentTime;
|
||||||
isDetailMode,
|
const camera = detail?.camera;
|
||||||
} = useDetailStream();
|
const isDetailMode = detail?.isDetailMode ?? false;
|
||||||
|
|
||||||
// playback
|
// playback
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { Preview } from "@/types/preview";
|
|||||||
import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
|
import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
|
||||||
import { DynamicVideoController } from "./DynamicVideoController";
|
import { DynamicVideoController } from "./DynamicVideoController";
|
||||||
import HlsVideoPlayer, { HlsSource } from "../HlsVideoPlayer";
|
import HlsVideoPlayer, { HlsSource } from "../HlsVideoPlayer";
|
||||||
|
import { useDetailStream } from "@/context/detail-stream-context";
|
||||||
import { TimeRange } from "@/types/timeline";
|
import { TimeRange } from "@/types/timeline";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { VideoResolutionType } from "@/types/live";
|
import { VideoResolutionType } from "@/types/live";
|
||||||
@ -59,6 +60,9 @@ export default function DynamicVideoPlayer({
|
|||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
// for detail stream context in History
|
||||||
|
const detail = useDetailStream();
|
||||||
|
|
||||||
// controlling playback
|
// controlling playback
|
||||||
|
|
||||||
const playerRef = useRef<HTMLVideoElement | null>(null);
|
const playerRef = useRef<HTMLVideoElement | null>(null);
|
||||||
@ -291,6 +295,7 @@ export default function DynamicVideoPlayer({
|
|||||||
setIsBuffering(true);
|
setIsBuffering(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
detail={detail}
|
||||||
/>
|
/>
|
||||||
<PreviewPlayer
|
<PreviewPlayer
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@ -232,15 +232,20 @@ function ReviewGroup({
|
|||||||
date_style: "medium",
|
date_style: "medium",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shouldFetchEvents = review?.data?.detections?.length > 0;
|
||||||
|
|
||||||
const { data: fetchedEvents } = useSWR<Event[]>(
|
const { data: fetchedEvents } = useSWR<Event[]>(
|
||||||
review?.data?.detections?.length
|
shouldFetchEvents
|
||||||
? ["event_ids", { ids: review.data.detections.join(",") }]
|
? ["event_ids", { ids: review.data.detections.join(",") }]
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const rawIconLabels: string[] = fetchedEvents
|
const rawIconLabels: string[] = [
|
||||||
? fetchedEvents.map((e) => e.label)
|
...(fetchedEvents
|
||||||
: (review.data?.objects ?? []);
|
? fetchedEvents.map((e) => e.label)
|
||||||
|
: (review.data?.objects ?? [])),
|
||||||
|
...(review.data?.audio ?? []),
|
||||||
|
];
|
||||||
|
|
||||||
// limit to 5 icons
|
// limit to 5 icons
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@ -310,10 +315,10 @@ function ReviewGroup({
|
|||||||
|
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
{!fetchedEvents ? (
|
{shouldFetchEvents && !fetchedEvents ? (
|
||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
) : (
|
) : (
|
||||||
fetchedEvents.map((event) => {
|
(fetchedEvents || []).map((event) => {
|
||||||
return (
|
return (
|
||||||
<EventCollapsible
|
<EventCollapsible
|
||||||
key={event.id}
|
key={event.id}
|
||||||
@ -325,6 +330,24 @@ function ReviewGroup({
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
{review.data.audio && review.data.audio.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{review.data.audio.map((audioLabel) => (
|
||||||
|
<div
|
||||||
|
key={audioLabel}
|
||||||
|
className="rounded-md bg-secondary p-2 outline outline-[3px] -outline-offset-[2.8px] outline-transparent duration-500"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
{getIconForLabel(
|
||||||
|
audioLabel,
|
||||||
|
"size-4 text-primary dark:text-white",
|
||||||
|
)}
|
||||||
|
<span>{getTranslatedLabel(audioLabel)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -384,7 +407,7 @@ function EventCollapsible({
|
|||||||
// Clear selectedObjectId when effectiveTime has passed this event's end_time
|
// Clear selectedObjectId when effectiveTime has passed this event's end_time
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedObjectId === event.id && effectiveTime && event.end_time) {
|
if (selectedObjectId === event.id && effectiveTime && event.end_time) {
|
||||||
if (effectiveTime > event.end_time) {
|
if (effectiveTime >= event.end_time) {
|
||||||
setSelectedObjectId(undefined);
|
setSelectedObjectId(undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -405,8 +428,9 @@ function EventCollapsible({
|
|||||||
? "shadow-selected outline-selected"
|
? "shadow-selected outline-selected"
|
||||||
: "outline-transparent duration-500",
|
: "outline-transparent duration-500",
|
||||||
event.id != selectedObjectId &&
|
event.id != selectedObjectId &&
|
||||||
(effectiveTime ?? 0) >= (event.start_time ?? 0) &&
|
(effectiveTime ?? 0) >= (event.start_time ?? 0) - 0.5 &&
|
||||||
(effectiveTime ?? 0) <= (event.end_time ?? event.start_time ?? 0) &&
|
(effectiveTime ?? 0) <=
|
||||||
|
(event.end_time ?? event.start_time ?? 0) + 0.5 &&
|
||||||
"bg-secondary-highlight outline-[1.5px] -outline-offset-[1.1px] outline-primary/40",
|
"bg-secondary-highlight outline-[1.5px] -outline-offset-[1.1px] outline-primary/40",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -41,6 +41,13 @@ export default function EventMenu({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
navigate(`/explore?event_id=${event.id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("details.item.button.viewInExplore")}
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<a
|
<a
|
||||||
download
|
download
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { ObjectLifecycleSequence } from "@/types/timeline";
|
import { ObjectLifecycleSequence } from "@/types/timeline";
|
||||||
|
|
||||||
interface DetailStreamContextType {
|
export interface DetailStreamContextType {
|
||||||
selectedObjectId: string | undefined;
|
selectedObjectId: string | undefined;
|
||||||
selectedObjectTimeline?: ObjectLifecycleSequence[];
|
selectedObjectTimeline?: ObjectLifecycleSequence[];
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user