mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 13:34:13 +03:00
Review stream tweaks (#20648)
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
* add detail stream selector to mobile drawer * tweak getDurationFromTimestamps for i18n and abbreviations * improve lifecycle description labeling * i18n * match figma * fix progress line and add area and ratio tooltip * allow clicking on chevron without triggering playback * tweaks * add key * change wording * clean up * clean up * remove check * clean up
This commit is contained in:
parent
e2da8aa04c
commit
49f5d595ea
@ -19,10 +19,11 @@
|
|||||||
"noFoundForTimePeriod": "No events found for this time period."
|
"noFoundForTimePeriod": "No events found for this time period."
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
|
"label": "Detail",
|
||||||
"noDataFound": "No detail data to review",
|
"noDataFound": "No detail data to review",
|
||||||
"aria": "Toggle detail view",
|
"aria": "Toggle detail view",
|
||||||
"trackedObject_one": "tracked object",
|
"trackedObject_one": "object",
|
||||||
"trackedObject_other": "tracked objects",
|
"trackedObject_other": "objects",
|
||||||
"noObjectDetailData": "No object detail data available."
|
"noObjectDetailData": "No object detail data available."
|
||||||
},
|
},
|
||||||
"objectTrack": {
|
"objectTrack": {
|
||||||
|
|||||||
@ -194,6 +194,12 @@
|
|||||||
},
|
},
|
||||||
"deleteTrackedObject": {
|
"deleteTrackedObject": {
|
||||||
"label": "Delete this tracked object"
|
"label": "Delete this tracked object"
|
||||||
|
},
|
||||||
|
"showObjectDetails": {
|
||||||
|
"label": "Show object path"
|
||||||
|
},
|
||||||
|
"hideObjectDetails": {
|
||||||
|
"label": "Hide object path"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
|
|||||||
@ -51,6 +51,15 @@ export default function MobileTimelineDrawer({
|
|||||||
>
|
>
|
||||||
{t("events.label")}
|
{t("events.label")}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className={`mx-4 w-full py-2 text-center smart-capitalize ${selected == "detail" ? "rounded-lg bg-secondary" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect("detail");
|
||||||
|
setDrawer(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("detail.label")}
|
||||||
|
</div>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ObjectLifecycleSequence } from "@/types/timeline";
|
import { ObjectLifecycleSequence } from "@/types/timeline";
|
||||||
import { LifecycleIcon } from "@/components/overlay/detail/ObjectLifecycle";
|
|
||||||
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";
|
||||||
import useUserInteraction from "@/hooks/use-user-interaction";
|
import useUserInteraction from "@/hooks/use-user-interaction";
|
||||||
import {
|
import {
|
||||||
formatUnixTimestampToDateTime,
|
formatUnixTimestampToDateTime,
|
||||||
formatSecondsToDuration,
|
getDurationFromTimestamps,
|
||||||
} from "@/utils/dateUtil";
|
} from "@/utils/dateUtil";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import AnnotationOffsetSlider from "@/components/overlay/detail/AnnotationOffsetSlider";
|
import AnnotationOffsetSlider from "@/components/overlay/detail/AnnotationOffsetSlider";
|
||||||
@ -17,26 +16,24 @@ import ActivityIndicator from "../indicators/activity-indicator";
|
|||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import { getIconForLabel } from "@/utils/iconUtil";
|
import { getIconForLabel } from "@/utils/iconUtil";
|
||||||
import { ReviewSegment } from "@/types/review";
|
import { ReviewSegment } from "@/types/review";
|
||||||
import {
|
import { LuChevronDown, LuCircle, LuChevronRight } from "react-icons/lu";
|
||||||
Collapsible,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
CollapsibleContent,
|
|
||||||
} from "@/components/ui/collapsible";
|
|
||||||
import { LuChevronUp, LuChevronDown } from "react-icons/lu";
|
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
import EventMenu from "@/components/timeline/EventMenu";
|
import EventMenu from "@/components/timeline/EventMenu";
|
||||||
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
|
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
|
|
||||||
type DetailStreamProps = {
|
type DetailStreamProps = {
|
||||||
reviewItems?: ReviewSegment[];
|
reviewItems?: ReviewSegment[];
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
|
isPlaying?: boolean;
|
||||||
onSeek: (timestamp: number, play?: boolean) => void;
|
onSeek: (timestamp: number, play?: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DetailStream({
|
export default function DetailStream({
|
||||||
reviewItems,
|
reviewItems,
|
||||||
currentTime,
|
currentTime,
|
||||||
|
isPlaying = false,
|
||||||
onSeek,
|
onSeek,
|
||||||
}: DetailStreamProps) {
|
}: DetailStreamProps) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
@ -54,6 +51,10 @@ export default function DetailStream({
|
|||||||
const effectiveTime = currentTime + annotationOffset / 1000;
|
const effectiveTime = currentTime + annotationOffset / 1000;
|
||||||
const [upload, setUpload] = useState<Event | undefined>(undefined);
|
const [upload, setUpload] = useState<Event | undefined>(undefined);
|
||||||
|
|
||||||
|
const onSeekCheckPlaying = (timestamp: number) => {
|
||||||
|
onSeek(timestamp, isPlaying);
|
||||||
|
};
|
||||||
|
|
||||||
// Ensure we initialize the active review when reviewItems first arrive.
|
// Ensure we initialize the active review when reviewItems first arrive.
|
||||||
// This helps when the component mounts while the video is already
|
// This helps when the component mounts while the video is already
|
||||||
// playing — it guarantees the matching review is highlighted right
|
// playing — it guarantees the matching review is highlighted right
|
||||||
@ -89,7 +90,7 @@ export default function DetailStream({
|
|||||||
|
|
||||||
// Auto-scroll to current time
|
// Auto-scroll to current time
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scrollRef.current || userInteracting) return;
|
if (!scrollRef.current || userInteracting || !isPlaying) return;
|
||||||
// Prefer the review whose range contains the effectiveTime. If none
|
// Prefer the review whose range contains the effectiveTime. If none
|
||||||
// contains it, pick the nearest review (by mid-point distance). This is
|
// contains it, pick the nearest review (by mid-point distance). This is
|
||||||
// robust to unordered reviewItems and avoids always picking the last
|
// robust to unordered reviewItems and avoids always picking the last
|
||||||
@ -121,11 +122,20 @@ export default function DetailStream({
|
|||||||
`[data-review-id="${id}"]`,
|
`[data-review-id="${id}"]`,
|
||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
if (element) {
|
if (element) {
|
||||||
setProgrammaticScroll();
|
// Only scroll if element is completely out of view
|
||||||
scrollIntoView(element, {
|
const containerRect = scrollRef.current.getBoundingClientRect();
|
||||||
scrollMode: "if-needed",
|
const elementRect = element.getBoundingClientRect();
|
||||||
behavior: "smooth",
|
const isFullyInvisible =
|
||||||
});
|
elementRect.bottom < containerRect.top ||
|
||||||
|
elementRect.top > containerRect.bottom;
|
||||||
|
|
||||||
|
if (isFullyInvisible) {
|
||||||
|
setProgrammaticScroll();
|
||||||
|
scrollIntoView(element, {
|
||||||
|
scrollMode: "if-needed",
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@ -134,6 +144,7 @@ export default function DetailStream({
|
|||||||
annotationOffset,
|
annotationOffset,
|
||||||
userInteracting,
|
userInteracting,
|
||||||
setProgrammaticScroll,
|
setProgrammaticScroll,
|
||||||
|
isPlaying,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Auto-select active review based on effectiveTime (if inside a review range)
|
// Auto-select active review based on effectiveTime (if inside a review range)
|
||||||
@ -165,9 +176,9 @@ export default function DetailStream({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="scrollbar-container h-[calc(100vh-70px)] overflow-y-auto bg-secondary"
|
className="scrollbar-container h-[calc(100vh-70px)] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<div className="space-y-2 p-4">
|
<div className="space-y-4 py-2">
|
||||||
{reviewItems?.length === 0 ? (
|
{reviewItems?.length === 0 ? (
|
||||||
<div className="py-8 text-center text-muted-foreground">
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
{t("detail.noDataFound")}
|
{t("detail.noDataFound")}
|
||||||
@ -181,7 +192,7 @@ export default function DetailStream({
|
|||||||
id={id}
|
id={id}
|
||||||
review={review}
|
review={review}
|
||||||
config={config}
|
config={config}
|
||||||
onSeek={onSeek}
|
onSeek={onSeekCheckPlaying}
|
||||||
effectiveTime={effectiveTime}
|
effectiveTime={effectiveTime}
|
||||||
isActive={activeReviewId == id}
|
isActive={activeReviewId == id}
|
||||||
onActivate={() => setActiveReviewId(id)}
|
onActivate={() => setActiveReviewId(id)}
|
||||||
@ -220,6 +231,7 @@ function ReviewGroup({
|
|||||||
effectiveTime,
|
effectiveTime,
|
||||||
}: ReviewGroupProps) {
|
}: ReviewGroupProps) {
|
||||||
const { t } = useTranslation("views/events");
|
const { t } = useTranslation("views/events");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
const start = review.start_time ?? 0;
|
const start = review.start_time ?? 0;
|
||||||
|
|
||||||
const displayTime = formatUnixTimestampToDateTime(start, {
|
const displayTime = formatUnixTimestampToDateTime(start, {
|
||||||
@ -234,7 +246,7 @@ function ReviewGroup({
|
|||||||
|
|
||||||
const shouldFetchEvents = review?.data?.detections?.length > 0;
|
const shouldFetchEvents = review?.data?.detections?.length > 0;
|
||||||
|
|
||||||
const { data: fetchedEvents } = useSWR<Event[]>(
|
const { data: fetchedEvents, isValidating } = useSWR<Event[]>(
|
||||||
shouldFetchEvents
|
shouldFetchEvents
|
||||||
? ["event_ids", { ids: review.data.detections.join(",") }]
|
? ["event_ids", { ids: review.data.detections.join(",") }]
|
||||||
: null,
|
: null,
|
||||||
@ -259,28 +271,27 @@ function ReviewGroup({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reviewInfo = useMemo(() => {
|
const reviewInfo = useMemo(() => {
|
||||||
if (review.data.metadata?.title) {
|
const objectCount = fetchedEvents
|
||||||
return review.data.metadata.title;
|
? fetchedEvents.length
|
||||||
} else {
|
: (review.data.objects ?? []).length;
|
||||||
const objectCount = fetchedEvents
|
|
||||||
? fetchedEvents.length
|
|
||||||
: (review.data.objects ?? []).length;
|
|
||||||
|
|
||||||
return `${objectCount} ${t("detail.trackedObject", { count: objectCount })}`;
|
return `${objectCount} ${t("detail.trackedObject", { count: objectCount })}`;
|
||||||
}
|
|
||||||
}, [review, t, fetchedEvents]);
|
}, [review, t, fetchedEvents]);
|
||||||
|
|
||||||
const reviewDuration =
|
const reviewDuration = useMemo(
|
||||||
review.end_time != null
|
() =>
|
||||||
? formatSecondsToDuration(
|
getDurationFromTimestamps(
|
||||||
Math.max(0, Math.floor((review.end_time ?? 0) - start)),
|
review.start_time,
|
||||||
)
|
review.end_time ?? null,
|
||||||
: null;
|
true,
|
||||||
|
),
|
||||||
|
[review.start_time, review.end_time],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-review-id={id}
|
data-review-id={id}
|
||||||
className={`cursor-pointer rounded-lg border bg-background p-3 outline outline-[3px] -outline-offset-[2.8px] ${
|
className={`cursor-pointer rounded-lg bg-secondary p-3 outline outline-[3px] -outline-offset-[2.8px] ${
|
||||||
isActive
|
isActive
|
||||||
? "shadow-selected outline-selected"
|
? "shadow-selected outline-selected"
|
||||||
: "outline-transparent duration-500"
|
: "outline-transparent duration-500"
|
||||||
@ -293,40 +304,75 @@ function ReviewGroup({
|
|||||||
onSeek(start);
|
onSeek(start);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="ml-1 flex flex-col items-start gap-1.5">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-row gap-3">
|
||||||
<div className="text-sm font-medium">{displayTime}</div>
|
<div className="text-sm font-medium">{displayTime}</div>
|
||||||
{reviewDuration && (
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-xs text-muted-foreground">
|
{iconLabels.slice(0, 5).map((lbl, idx) => (
|
||||||
{reviewDuration}
|
<div
|
||||||
|
key={`${lbl}-${idx}`}
|
||||||
|
className="rounded-full bg-muted-foreground p-1"
|
||||||
|
>
|
||||||
|
{getIconForLabel(lbl, "size-3 text-primary dark:text-white")}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{review.data.metadata?.title && (
|
||||||
|
<div className="mb-1 text-sm text-primary-variant">
|
||||||
|
{review.data.metadata.title}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-xs text-muted-foreground">{reviewInfo}</div>
|
<div className="flex flex-row items-center gap-1.5">
|
||||||
|
<div className="text-xs text-primary-variant">{reviewInfo}</div>
|
||||||
|
|
||||||
|
{reviewDuration && (
|
||||||
|
<>
|
||||||
|
<span className="text-[5px] text-primary-variant">•</span>
|
||||||
|
<div className="text-xs text-primary-variant">
|
||||||
|
{reviewDuration}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
{iconLabels.slice(0, 5).map((lbl, idx) => (
|
onClick={(e) => {
|
||||||
<span key={`${lbl}-${idx}`}>
|
e.stopPropagation();
|
||||||
{getIconForLabel(lbl, "size-4 text-primary dark:text-white")}
|
setOpen((v) => !v);
|
||||||
</span>
|
}}
|
||||||
))}
|
aria-label={open ? "Collapse" : "Expand"}
|
||||||
|
className="ml-2 inline-flex items-center justify-center rounded p-1 hover:bg-secondary/10"
|
||||||
|
>
|
||||||
|
{open ? (
|
||||||
|
<LuChevronDown className="size-4 text-primary-variant" />
|
||||||
|
) : (
|
||||||
|
<LuChevronRight className="size-4 text-primary-variant" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isActive && (
|
{open && (
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-0.5">
|
||||||
{shouldFetchEvents && !fetchedEvents ? (
|
{shouldFetchEvents && isValidating && !fetchedEvents ? (
|
||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
) : (
|
) : (
|
||||||
(fetchedEvents || []).map((event) => {
|
(fetchedEvents || []).map((event, index) => {
|
||||||
return (
|
return (
|
||||||
<EventCollapsible
|
<div
|
||||||
key={event.id}
|
key={`event-${event.id}-${index}`}
|
||||||
event={event}
|
className="border-b border-secondary-highlight pb-0.5 last:border-0 last:pb-0"
|
||||||
effectiveTime={effectiveTime}
|
>
|
||||||
onSeek={onSeek}
|
<EventList
|
||||||
onOpenUpload={onOpenUpload}
|
key={event.id}
|
||||||
/>
|
event={event}
|
||||||
|
effectiveTime={effectiveTime}
|
||||||
|
onSeek={onSeek}
|
||||||
|
onOpenUpload={onOpenUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
@ -337,11 +383,13 @@ function ReviewGroup({
|
|||||||
key={audioLabel}
|
key={audioLabel}
|
||||||
className="rounded-md bg-secondary p-2 outline outline-[3px] -outline-offset-[2.8px] outline-transparent duration-500"
|
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">
|
<div className="ml-1.5 flex items-center gap-2 text-sm font-medium">
|
||||||
{getIconForLabel(
|
<div className="rounded-full bg-muted-foreground p-1">
|
||||||
audioLabel,
|
{getIconForLabel(
|
||||||
"size-4 text-primary dark:text-white",
|
audioLabel,
|
||||||
)}
|
"size-3 text-primary dark:text-white",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<span>{getTranslatedLabel(audioLabel)}</span>
|
<span>{getTranslatedLabel(audioLabel)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -354,55 +402,30 @@ function ReviewGroup({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventCollapsibleProps = {
|
type EventListProps = {
|
||||||
event: Event;
|
event: Event;
|
||||||
effectiveTime?: number;
|
effectiveTime?: number;
|
||||||
onSeek: (ts: number, play?: boolean) => void;
|
onSeek: (ts: number, play?: boolean) => void;
|
||||||
onOpenUpload?: (e: Event) => void;
|
onOpenUpload?: (e: Event) => void;
|
||||||
};
|
};
|
||||||
function EventCollapsible({
|
function EventList({
|
||||||
event,
|
event,
|
||||||
effectiveTime,
|
effectiveTime,
|
||||||
onSeek,
|
onSeek,
|
||||||
onOpenUpload,
|
onOpenUpload,
|
||||||
}: EventCollapsibleProps) {
|
}: EventListProps) {
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const { t } = useTranslation("views/events");
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
const { selectedObjectId, setSelectedObjectId } = useDetailStream();
|
const { selectedObjectId, setSelectedObjectId } = useDetailStream();
|
||||||
|
|
||||||
const formattedStart = config
|
const handleObjectSelect = (event: Event | undefined) => {
|
||||||
? formatUnixTimestampToDateTime(event.start_time ?? 0, {
|
if (event) {
|
||||||
timezone: config.ui.timezone,
|
onSeek(event.start_time ?? 0);
|
||||||
date_format:
|
setSelectedObjectId(event.id);
|
||||||
config.ui.time_format == "24hour"
|
} else {
|
||||||
? t("time.formattedTimestampHourMinuteSecond.24hour", {
|
setSelectedObjectId(undefined);
|
||||||
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",
|
|
||||||
})
|
|
||||||
: "";
|
|
||||||
|
|
||||||
// Clear selectedObjectId when effectiveTime has passed this event's end_time
|
// Clear selectedObjectId when effectiveTime has passed this event's end_time
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -420,91 +443,100 @@ function EventCollapsible({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible open={open} onOpenChange={(o) => setOpen(o)}>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-md bg-secondary p-2 outline outline-[3px] -outline-offset-[2.8px]",
|
"rounded-md bg-secondary p-2 outline outline-[3px] -outline-offset-[2.8px]",
|
||||||
event.id == selectedObjectId
|
event.id == selectedObjectId
|
||||||
? "shadow-selected outline-selected"
|
? "bg-secondary-highlight shadow-selected outline-selected"
|
||||||
: "outline-transparent duration-500",
|
: "outline-transparent duration-500",
|
||||||
event.id != selectedObjectId &&
|
event.id != selectedObjectId &&
|
||||||
(effectiveTime ?? 0) >= (event.start_time ?? 0) - 0.5 &&
|
(effectiveTime ?? 0) >= (event.start_time ?? 0) - 0.5 &&
|
||||||
(effectiveTime ?? 0) <=
|
(effectiveTime ?? 0) <=
|
||||||
(event.end_time ?? event.start_time ?? 0) + 0.5 &&
|
(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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="ml-1.5 flex w-full items-center justify-between">
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 text-sm font-medium"
|
className="flex items-center gap-2 text-sm font-medium"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSeek(event.start_time ?? 0);
|
handleObjectSelect(
|
||||||
if (event.id) setSelectedObjectId(event.id);
|
event.id == selectedObjectId ? undefined : event,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
>
|
>
|
||||||
{getIconForLabel(
|
<div
|
||||||
event.label,
|
className={cn(
|
||||||
"size-4 text-primary dark:text-white",
|
"rounded-full p-1",
|
||||||
)}
|
event.id == selectedObjectId
|
||||||
|
? "bg-selected"
|
||||||
|
: "bg-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getIconForLabel(
|
||||||
|
event.label,
|
||||||
|
"size-3 text-primary dark:text-white",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<span>{getTranslatedLabel(event.label)}</span>
|
<span>{getTranslatedLabel(event.label)}</span>
|
||||||
<span className="text-xs text-secondary-foreground">
|
|
||||||
{formattedStart ?? ""} - {formattedEnd ?? ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-row justify-end">
|
<div className="mr-2 flex flex-1 flex-row justify-end">
|
||||||
<EventMenu
|
<EventMenu
|
||||||
event={event}
|
event={event}
|
||||||
config={config}
|
config={config}
|
||||||
onOpenUpload={(e) => onOpenUpload?.(e)}
|
onOpenUpload={(e) => onOpenUpload?.(e)}
|
||||||
|
selectedObjectId={selectedObjectId}
|
||||||
|
setSelectedObjectId={handleObjectSelect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<button
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="rounded bg-muted px-2 py-1 text-xs"
|
|
||||||
aria-label={t("detail.aria")}
|
|
||||||
>
|
|
||||||
{open ? (
|
|
||||||
<LuChevronUp className="size-3" />
|
|
||||||
) : (
|
|
||||||
<LuChevronDown className="size-3" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<CollapsibleContent>
|
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<ObjectTimeline
|
<ObjectTimeline
|
||||||
eventId={event.id}
|
eventId={event.id}
|
||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
effectiveTime={effectiveTime}
|
effectiveTime={effectiveTime}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
|
||||||
</div>
|
</div>
|
||||||
</Collapsible>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type LifecycleItemProps = {
|
type LifecycleItemProps = {
|
||||||
event: ObjectLifecycleSequence;
|
item: ObjectLifecycleSequence;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
onSeek?: (timestamp: number, play?: boolean) => void;
|
onSeek?: (timestamp: number, play?: boolean) => void;
|
||||||
|
effectiveTime?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function LifecycleItem({ event, isActive, onSeek }: LifecycleItemProps) {
|
function LifecycleItem({
|
||||||
|
item,
|
||||||
|
isActive,
|
||||||
|
onSeek,
|
||||||
|
effectiveTime,
|
||||||
|
}: LifecycleItemProps) {
|
||||||
const { t } = useTranslation("views/events");
|
const { t } = useTranslation("views/events");
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
const aspectRatio = useMemo(() => {
|
||||||
|
if (!config || !item?.camera) {
|
||||||
|
return 16 / 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
config.cameras[item.camera].detect.width /
|
||||||
|
config.cameras[item.camera].detect.height
|
||||||
|
);
|
||||||
|
}, [config, item]);
|
||||||
|
|
||||||
const formattedEventTimestamp = config
|
const formattedEventTimestamp = config
|
||||||
? formatUnixTimestampToDateTime(event.timestamp ?? 0, {
|
? formatUnixTimestampToDateTime(item?.timestamp ?? 0, {
|
||||||
timezone: config.ui.timezone,
|
timezone: config.ui.timezone,
|
||||||
date_format:
|
date_format:
|
||||||
config.ui.time_format == "24hour"
|
config.ui.time_format == "24hour"
|
||||||
@ -519,11 +551,28 @@ function LifecycleItem({ event, isActive, onSeek }: LifecycleItemProps) {
|
|||||||
})
|
})
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
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[item?.camera]?.detect?.width ?? 0) *
|
||||||
|
(config?.cameras[item?.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 (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSeek?.(event.timestamp ?? 0, false);
|
onSeek?.(item.timestamp ?? 0, 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",
|
||||||
@ -532,11 +581,46 @@ function LifecycleItem({ event, isActive, onSeek }: LifecycleItemProps) {
|
|||||||
: "duration-500",
|
: "duration-500",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex size-4 items-center justify-center">
|
<div className="relative flex size-4 items-center justify-center">
|
||||||
<LifecycleIcon lifecycleItem={event} className="size-3" />
|
<LuCircle
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 ml-[1px] size-2.5 fill-secondary-foreground stroke-none",
|
||||||
|
(isActive || (effectiveTime ?? 0) >= (item?.timestamp ?? 0)) &&
|
||||||
|
"fill-selected duration-300",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-row justify-between">
|
<div className="flex w-full flex-row justify-between">
|
||||||
<div>{getLifecycleItemDescription(event)}</div>
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<span>{getLifecycleItemDescription(item)}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<div className="mt-1 flex flex-wrap items-start gap-3 text-sm text-secondary-foreground">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-start gap-1">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("objectLifecycle.lifecycleItemDesc.header.ratio")}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-foreground">{ratio}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-1">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("objectLifecycle.lifecycleItemDesc.header.area")}
|
||||||
|
</span>
|
||||||
|
{areaPx !== undefined && areaPct !== undefined ? (
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{areaPx} {t("pixels", { ns: "common" })} · {areaPct}%
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>N/A</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<div className={cn("p-1 text-xs")}>{formattedEventTimestamp}</div>
|
<div className={cn("p-1 text-xs")}>{formattedEventTimestamp}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -561,8 +645,8 @@ function ObjectTimeline({
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ((!timeline || timeline.length === 0) && isValidating) {
|
if (isValidating && (!timeline || timeline.length === 0)) {
|
||||||
return <ActivityIndicator className="h-2 w-2" size={2} />;
|
return <ActivityIndicator className="ml-2 size-3" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!timeline || timeline.length === 0) {
|
if (!timeline || timeline.length === 0) {
|
||||||
@ -573,20 +657,75 @@ function ObjectTimeline({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate how far down the blue line should extend based on effectiveTime
|
||||||
|
const calculateLineHeight = () => {
|
||||||
|
if (!timeline || timeline.length === 0) return 0;
|
||||||
|
|
||||||
|
const currentTime = effectiveTime ?? 0;
|
||||||
|
|
||||||
|
// Find which events have been passed
|
||||||
|
let lastPassedIndex = -1;
|
||||||
|
for (let i = 0; i < timeline.length; i++) {
|
||||||
|
if (currentTime >= (timeline[i].timestamp ?? 0)) {
|
||||||
|
lastPassedIndex = i;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No events passed yet
|
||||||
|
if (lastPassedIndex < 0) return 0;
|
||||||
|
|
||||||
|
// All events passed
|
||||||
|
if (lastPassedIndex >= timeline.length - 1) return 100;
|
||||||
|
|
||||||
|
// Calculate percentage based on item position, not time
|
||||||
|
// Each item occupies an equal visual space regardless of time gaps
|
||||||
|
const itemPercentage = 100 / (timeline.length - 1);
|
||||||
|
|
||||||
|
// Find progress between current and next event for smooth transition
|
||||||
|
const currentEvent = timeline[lastPassedIndex];
|
||||||
|
const nextEvent = timeline[lastPassedIndex + 1];
|
||||||
|
const currentTimestamp = currentEvent.timestamp ?? 0;
|
||||||
|
const nextTimestamp = nextEvent.timestamp ?? 0;
|
||||||
|
|
||||||
|
// Calculate interpolation between the two events
|
||||||
|
const timeBetween = nextTimestamp - currentTimestamp;
|
||||||
|
const timeElapsed = currentTime - currentTimestamp;
|
||||||
|
const interpolation = timeBetween > 0 ? timeElapsed / timeBetween : 0;
|
||||||
|
|
||||||
|
// Base position plus interpolated progress to next item
|
||||||
|
return Math.min(
|
||||||
|
100,
|
||||||
|
lastPassedIndex * itemPercentage + interpolation * itemPercentage,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const blueLineHeight = calculateLineHeight();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-2 mt-4 space-y-2">
|
<div className="-pb-2 relative mx-2">
|
||||||
{timeline.map((event, idx) => {
|
<div className="absolute -top-2 bottom-2 left-2 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" />
|
||||||
const isActive =
|
<div
|
||||||
Math.abs((effectiveTime ?? 0) - (event.timestamp ?? 0)) <= 0.5;
|
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"
|
||||||
return (
|
style={{ height: `${blueLineHeight}%` }}
|
||||||
<LifecycleItem
|
/>
|
||||||
key={`${event.timestamp}-${event.source_id ?? ""}-${idx}`}
|
<div className="space-y-2">
|
||||||
event={event}
|
{timeline.map((event, idx) => {
|
||||||
onSeek={onSeek}
|
const isActive =
|
||||||
isActive={isActive}
|
Math.abs((effectiveTime ?? 0) - (event.timestamp ?? 0)) <= 0.5;
|
||||||
/>
|
|
||||||
);
|
return (
|
||||||
})}
|
<LifecycleItem
|
||||||
|
key={`${event.timestamp}-${event.source_id ?? ""}-${idx}`}
|
||||||
|
item={event}
|
||||||
|
onSeek={onSeek}
|
||||||
|
isActive={isActive}
|
||||||
|
effectiveTime={effectiveTime}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,19 +4,22 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuPortal,
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSeparator,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { HiDotsHorizontal } from "react-icons/hi";
|
import { HiDotsHorizontal } from "react-icons/hi";
|
||||||
import { useApiHost } from "@/api";
|
import { useApiHost } from "@/api";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
|
||||||
type EventMenuProps = {
|
type EventMenuProps = {
|
||||||
event: Event;
|
event: Event;
|
||||||
config?: FrigateConfig;
|
config?: FrigateConfig;
|
||||||
onOpenUpload?: (e: Event) => void;
|
onOpenUpload?: (e: Event) => void;
|
||||||
onOpenSimilarity?: (e: Event) => void;
|
onOpenSimilarity?: (e: Event) => void;
|
||||||
|
selectedObjectId?: string;
|
||||||
|
setSelectedObjectId?: (event: Event | undefined) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function EventMenu({
|
export default function EventMenu({
|
||||||
@ -24,71 +27,87 @@ export default function EventMenu({
|
|||||||
config,
|
config,
|
||||||
onOpenUpload,
|
onOpenUpload,
|
||||||
onOpenSimilarity,
|
onOpenSimilarity,
|
||||||
|
selectedObjectId,
|
||||||
|
setSelectedObjectId,
|
||||||
}: EventMenuProps) {
|
}: EventMenuProps) {
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation("views/explore");
|
const { t } = useTranslation("views/explore");
|
||||||
|
|
||||||
|
const handleObjectSelect = () => {
|
||||||
|
if (event.id === selectedObjectId) {
|
||||||
|
setSelectedObjectId?.(undefined);
|
||||||
|
} else {
|
||||||
|
setSelectedObjectId?.(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<>
|
||||||
<DropdownMenuTrigger>
|
<span tabIndex={0} className="sr-only" />
|
||||||
<button
|
<DropdownMenu>
|
||||||
className="mr-2 rounded p-1"
|
<DropdownMenuTrigger>
|
||||||
aria-label={t("itemMenu.openMenu", { ns: "common" })}
|
<div className="rounded p-1 pr-2" role="button">
|
||||||
>
|
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||||
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
</div>
|
||||||
</button>
|
</DropdownMenuTrigger>
|
||||||
</DropdownMenuTrigger>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuItem onSelect={handleObjectSelect}>
|
||||||
<DropdownMenuItem
|
{event.id === selectedObjectId
|
||||||
onSelect={() => {
|
? t("itemMenu.hideObjectDetails.label")
|
||||||
navigate(`/explore?event_id=${event.id}`);
|
: t("itemMenu.showObjectDetails.label")}
|
||||||
}}
|
</DropdownMenuItem>
|
||||||
>
|
<DropdownMenuSeparator className="my-0.5" />
|
||||||
{t("details.item.button.viewInExplore")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<a
|
|
||||||
download
|
|
||||||
href={
|
|
||||||
event.has_snapshot
|
|
||||||
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
|
||||||
: `${apiHost}api/events/${event.id}/thumbnail.webp`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("itemMenu.downloadSnapshot.label")}
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
{event.has_snapshot &&
|
|
||||||
event.plus_id == undefined &&
|
|
||||||
event.data.type == "object" &&
|
|
||||||
config?.plus?.enabled && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
onOpenUpload?.(event);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("itemMenu.submitToPlus.label")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{event.has_snapshot && config?.semantic_search?.enabled && (
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (onOpenSimilarity) onOpenSimilarity(event);
|
navigate(`/explore?event_id=${event.id}`);
|
||||||
else
|
|
||||||
navigate(
|
|
||||||
`/explore?search_type=similarity&event_id=${event.id}`,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("itemMenu.findSimilar.label")}
|
{t("details.item.button.viewInExplore")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
<DropdownMenuItem asChild>
|
||||||
</DropdownMenuContent>
|
<a
|
||||||
</DropdownMenuPortal>
|
download
|
||||||
</DropdownMenu>
|
href={
|
||||||
|
event.has_snapshot
|
||||||
|
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
||||||
|
: `${apiHost}api/events/${event.id}/thumbnail.webp`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("itemMenu.downloadSnapshot.label")}
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{event.has_snapshot &&
|
||||||
|
event.plus_id == undefined &&
|
||||||
|
event.data.type == "object" &&
|
||||||
|
config?.plus?.enabled && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
onOpenUpload?.(event);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("itemMenu.submitToPlus.label")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{event.has_snapshot && config?.semantic_search?.enabled && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
if (onOpenSimilarity) onOpenSimilarity(event);
|
||||||
|
else
|
||||||
|
navigate(
|
||||||
|
`/explore?search_type=similarity&event_id=${event.id}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("itemMenu.findSimilar.label")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { fromUnixTime, intervalToDuration, formatDuration } from "date-fns";
|
import { fromUnixTime, intervalToDuration, formatDuration } from "date-fns";
|
||||||
import { Locale } from "date-fns/locale";
|
import { Locale } from "date-fns/locale";
|
||||||
import { formatInTimeZone } from "date-fns-tz";
|
import { formatInTimeZone } from "date-fns-tz";
|
||||||
|
import i18n from "@/utils/i18n";
|
||||||
export const longToDate = (long: number): Date => new Date(long * 1000);
|
export const longToDate = (long: number): Date => new Date(long * 1000);
|
||||||
export const epochToLong = (date: number): number => date / 1000;
|
export const epochToLong = (date: number): number => date / 1000;
|
||||||
export const dateToLong = (date: Date): number => epochToLong(date.getTime());
|
export const dateToLong = (date: Date): number => epochToLong(date.getTime());
|
||||||
@ -234,11 +235,13 @@ export const formatUnixTimestampToDateTime = (
|
|||||||
* If end time is not provided, it returns 'In Progress'
|
* If end time is not provided, it returns 'In Progress'
|
||||||
* @param start_time: number - Unix timestamp for start time
|
* @param start_time: number - Unix timestamp for start time
|
||||||
* @param end_time: number|null - Unix timestamp for end time
|
* @param end_time: number|null - Unix timestamp for end time
|
||||||
|
* @param abbreviated: boolean - Whether to use abbreviated forms (h, m, s) instead of full words
|
||||||
* @returns string - duration or 'In Progress' if end time is not provided
|
* @returns string - duration or 'In Progress' if end time is not provided
|
||||||
*/
|
*/
|
||||||
export const getDurationFromTimestamps = (
|
export const getDurationFromTimestamps = (
|
||||||
start_time: number,
|
start_time: number,
|
||||||
end_time: number | null,
|
end_time: number | null,
|
||||||
|
abbreviated: boolean = false,
|
||||||
): string => {
|
): string => {
|
||||||
if (isNaN(start_time)) {
|
if (isNaN(start_time)) {
|
||||||
return "Invalid start time";
|
return "Invalid start time";
|
||||||
@ -250,12 +253,39 @@ export const getDurationFromTimestamps = (
|
|||||||
}
|
}
|
||||||
const start = fromUnixTime(start_time);
|
const start = fromUnixTime(start_time);
|
||||||
const end = fromUnixTime(end_time);
|
const end = fromUnixTime(end_time);
|
||||||
duration = formatDuration(intervalToDuration({ start, end }), {
|
const durationObj = intervalToDuration({ start, end });
|
||||||
format: ["hours", "minutes", "seconds"],
|
|
||||||
})
|
// Build duration string using i18n keys or abbreviations
|
||||||
.replace("hours", "h")
|
const parts: string[] = [];
|
||||||
.replace("minutes", "m")
|
if (durationObj.hours) {
|
||||||
.replace("seconds", "s");
|
const count = durationObj.hours;
|
||||||
|
if (abbreviated) {
|
||||||
|
parts.push(`${count}h`);
|
||||||
|
} else {
|
||||||
|
const key = count === 1 ? "hour_one" : "hour_other";
|
||||||
|
parts.push(i18n.t(`time.${key}`, { time: count, ns: "common" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (durationObj.minutes) {
|
||||||
|
const count = durationObj.minutes;
|
||||||
|
if (abbreviated) {
|
||||||
|
parts.push(`${count}m`);
|
||||||
|
} else {
|
||||||
|
const key = count === 1 ? "minute_one" : "minute_other";
|
||||||
|
parts.push(i18n.t(`time.${key}`, { time: count, ns: "common" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (durationObj.seconds) {
|
||||||
|
const count = durationObj.seconds;
|
||||||
|
if (abbreviated) {
|
||||||
|
parts.push(`${count}s`);
|
||||||
|
} else {
|
||||||
|
const key = count === 1 ? "second_one" : "second_other";
|
||||||
|
parts.push(i18n.t(`time.${key}`, { time: count, ns: "common" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
duration = parts.join(" ");
|
||||||
}
|
}
|
||||||
return duration;
|
return duration;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -9,7 +9,9 @@ export function getLifecycleItemDescription(
|
|||||||
? lifecycleItem.data.sub_label[0]
|
? lifecycleItem.data.sub_label[0]
|
||||||
: lifecycleItem.data.sub_label || lifecycleItem.data.label;
|
: lifecycleItem.data.sub_label || lifecycleItem.data.label;
|
||||||
|
|
||||||
const label = getTranslatedLabel(rawLabel);
|
const label = lifecycleItem.data.sub_label
|
||||||
|
? rawLabel
|
||||||
|
: getTranslatedLabel(rawLabel);
|
||||||
|
|
||||||
switch (lifecycleItem.class_type) {
|
switch (lifecycleItem.class_type) {
|
||||||
case "visible":
|
case "visible":
|
||||||
@ -44,14 +46,18 @@ export function getLifecycleItemDescription(
|
|||||||
{
|
{
|
||||||
ns: "views/explore",
|
ns: "views/explore",
|
||||||
label,
|
label,
|
||||||
attribute: lifecycleItem.data.attribute.replaceAll("_", " "),
|
attribute: getTranslatedLabel(
|
||||||
|
lifecycleItem.data.attribute.replaceAll("_", " "),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
title = t("objectLifecycle.lifecycleItemDesc.attribute.other", {
|
title = t("objectLifecycle.lifecycleItemDesc.attribute.other", {
|
||||||
ns: "views/explore",
|
ns: "views/explore",
|
||||||
label: lifecycleItem.data.label,
|
label: lifecycleItem.data.label,
|
||||||
attribute: lifecycleItem.data.attribute.replaceAll("_", " "),
|
attribute: getTranslatedLabel(
|
||||||
|
lifecycleItem.data.attribute.replaceAll("_", " "),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return title;
|
return title;
|
||||||
|
|||||||
@ -696,7 +696,7 @@ export function RecordingView({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-full items-center",
|
"flex size-full items-center",
|
||||||
timelineType === "detail"
|
timelineType === "detail" && isDesktop
|
||||||
? "flex-col"
|
? "flex-col"
|
||||||
: mainCameraAspect == "tall"
|
: mainCameraAspect == "tall"
|
||||||
? "flex-row justify-evenly"
|
? "flex-row justify-evenly"
|
||||||
@ -782,7 +782,7 @@ export function RecordingView({
|
|||||||
? "h-full w-72 flex-col"
|
? "h-full w-72 flex-col"
|
||||||
: `h-28 w-full`,
|
: `h-28 w-full`,
|
||||||
previewRowOverflows ? "" : "items-center justify-center",
|
previewRowOverflows ? "" : "items-center justify-center",
|
||||||
timelineType == "detail" && "mt-4",
|
timelineType == "detail" && isDesktop && "mt-4",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="w-2" />
|
<div className="w-2" />
|
||||||
@ -847,6 +847,7 @@ export function RecordingView({
|
|||||||
setScrubbing={setScrubbing}
|
setScrubbing={setScrubbing}
|
||||||
setExportRange={setExportRange}
|
setExportRange={setExportRange}
|
||||||
onAnalysisOpen={onAnalysisOpen}
|
onAnalysisOpen={onAnalysisOpen}
|
||||||
|
isPlaying={mainControllerRef?.current?.isPlaying() ?? false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -864,6 +865,7 @@ type TimelineProps = {
|
|||||||
activeReviewItem?: ReviewSegment;
|
activeReviewItem?: ReviewSegment;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
exportRange?: TimeRange;
|
exportRange?: TimeRange;
|
||||||
|
isPlaying?: boolean;
|
||||||
setCurrentTime: React.Dispatch<React.SetStateAction<number>>;
|
setCurrentTime: React.Dispatch<React.SetStateAction<number>>;
|
||||||
manuallySetCurrentTime: (time: number, force: boolean) => void;
|
manuallySetCurrentTime: (time: number, force: boolean) => void;
|
||||||
setScrubbing: React.Dispatch<React.SetStateAction<boolean>>;
|
setScrubbing: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
@ -880,6 +882,7 @@ function Timeline({
|
|||||||
activeReviewItem,
|
activeReviewItem,
|
||||||
currentTime,
|
currentTime,
|
||||||
exportRange,
|
exportRange,
|
||||||
|
isPlaying,
|
||||||
setCurrentTime,
|
setCurrentTime,
|
||||||
manuallySetCurrentTime,
|
manuallySetCurrentTime,
|
||||||
setScrubbing,
|
setScrubbing,
|
||||||
@ -966,15 +969,19 @@ function Timeline({
|
|||||||
"relative",
|
"relative",
|
||||||
isDesktop
|
isDesktop
|
||||||
? `${timelineType == "timeline" ? "w-[100px]" : timelineType == "detail" ? "w-[30%]" : "w-60"} no-scrollbar overflow-y-auto`
|
? `${timelineType == "timeline" ? "w-[100px]" : timelineType == "detail" ? "w-[30%]" : "w-60"} no-scrollbar overflow-y-auto`
|
||||||
: `overflow-hidden portrait:flex-grow ${timelineType == "timeline" ? "landscape:w-[100px]" : timelineType == "detail" ? "flex-1" : "landscape:w-[175px]"} `,
|
: `overflow-hidden portrait:flex-grow ${timelineType == "timeline" ? "landscape:w-[100px]" : timelineType == "detail" && isDesktop ? "flex-1" : "landscape:w-[300px]"} `,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen} />
|
<GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 h-[30px] w-full bg-gradient-to-b from-secondary to-transparent"></div>
|
{timelineType != "detail" && (
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-[30px] w-full bg-gradient-to-t from-secondary to-transparent"></div>
|
<>
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 h-[30px] w-full bg-gradient-to-b from-secondary to-transparent"></div>
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-[30px] w-full bg-gradient-to-t from-secondary to-transparent"></div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{timelineType == "timeline" ? (
|
{timelineType == "timeline" ? (
|
||||||
!isLoading ? (
|
!isLoading ? (
|
||||||
<MotionReviewTimeline
|
<MotionReviewTimeline
|
||||||
@ -1009,6 +1016,7 @@ function Timeline({
|
|||||||
manuallySetCurrentTime(timestamp, play ?? true)
|
manuallySetCurrentTime(timestamp, play ?? true)
|
||||||
}
|
}
|
||||||
reviewItems={mainCameraReviewItems}
|
reviewItems={mainCameraReviewItems}
|
||||||
|
isPlaying={isPlaying}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="scrollbar-container h-full overflow-auto bg-secondary">
|
<div className="scrollbar-container h-full overflow-auto bg-secondary">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user