mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-14 02:52:11 +03:00
refactor
This commit is contained in:
parent
a134b06e03
commit
64aa709888
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ObjectLifecycleSequence } from "@/types/timeline";
|
import { ObjectLifecycleSequence } from "@/types/timeline";
|
||||||
import { LifecycleIcon } from "@/components/overlay/detail/ObjectLifecycle";
|
import { LifecycleIcon } from "@/components/overlay/detail/ObjectLifecycle";
|
||||||
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||||
@ -11,88 +11,112 @@ import AnnotationOffsetSlider from "@/components/overlay/detail/AnnotationOffset
|
|||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import ActivityIndicator from "../indicators/activity-indicator";
|
import ActivityIndicator from "../indicators/activity-indicator";
|
||||||
|
import { Event } from "@/types/event";
|
||||||
|
import { getIconForLabel } from "@/utils/iconUtil";
|
||||||
|
import { ReviewSegment, REVIEW_PADDING } from "@/types/review";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
CollapsibleContent,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import { LuChevronUp, LuChevronDown } from "react-icons/lu";
|
||||||
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
|
import EventMenu from "@/components/timeline/EventMenu";
|
||||||
|
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type ActivityStreamProps = {
|
type ActivityStreamProps = {
|
||||||
timelineData: ObjectLifecycleSequence[];
|
reviewItems?: ReviewSegment[];
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
onSeek: (timestamp: number) => void;
|
onSeek: (timestamp: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ActivityStream({
|
export default function ActivityStream({
|
||||||
timelineData,
|
reviewItems,
|
||||||
currentTime,
|
currentTime,
|
||||||
onSeek,
|
onSeek,
|
||||||
}: ActivityStreamProps) {
|
}: ActivityStreamProps) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const { t } = useTranslation("views/events");
|
const { t } = useTranslation("views/events");
|
||||||
const { selectedObjectId, annotationOffset } = useActivityStream();
|
const { annotationOffset } = useActivityStream();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const effectiveTime = currentTime + annotationOffset / 1000;
|
const [activeReviewId, setActiveReviewId] = useState<string | undefined>(
|
||||||
|
undefined,
|
||||||
// Track user interaction and adjust scrolling behavior
|
);
|
||||||
const { userInteracting, setProgrammaticScroll } = useUserInteraction({
|
const { userInteracting, setProgrammaticScroll } = useUserInteraction({
|
||||||
elementRef: scrollRef,
|
elementRef: scrollRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
// group activities by timestamp (within 1 second resolution window)
|
const effectiveTime = currentTime + annotationOffset / 1000;
|
||||||
const groupedActivities = useMemo(() => {
|
const PAD = 0; // REVIEW_PADDING ?? 2;
|
||||||
const groups: { [key: number]: ObjectLifecycleSequence[] } = {};
|
const [upload, setUpload] = useState<Event | undefined>(undefined);
|
||||||
|
|
||||||
timelineData.forEach((activity) => {
|
// Ensure we initialize the active review when reviewItems first arrive.
|
||||||
const groupKey = Math.floor(activity.timestamp);
|
// This helps when the component mounts while the video is already
|
||||||
if (!groups[groupKey]) {
|
// playing — it guarantees the matching review is highlighted right
|
||||||
groups[groupKey] = [];
|
// away instead of waiting for a future effectiveTime change.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!reviewItems || reviewItems.length === 0) return;
|
||||||
|
if (activeReviewId) return;
|
||||||
|
|
||||||
|
let target: ReviewSegment | undefined;
|
||||||
|
let closest: { r: ReviewSegment; diff: number } | undefined;
|
||||||
|
|
||||||
|
for (const r of reviewItems) {
|
||||||
|
const start = (r.start_time ?? 0) - PAD;
|
||||||
|
const end = (r.end_time ?? r.start_time ?? start) + PAD;
|
||||||
|
if (effectiveTime >= start && effectiveTime <= end) {
|
||||||
|
target = r;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
groups[groupKey].push(activity);
|
const mid = (start + end) / 2;
|
||||||
});
|
const diff = Math.abs(effectiveTime - mid);
|
||||||
|
if (!closest || diff < closest.diff) closest = { r, diff };
|
||||||
return Object.entries(groups)
|
|
||||||
.map(([_timestamp, activities]) => {
|
|
||||||
const sortedActivities = activities.sort(
|
|
||||||
(a, b) => a.timestamp - b.timestamp,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
timestamp: sortedActivities[0].timestamp, // Original timestamp for display
|
|
||||||
effectiveTimestamp:
|
|
||||||
sortedActivities[0].timestamp + annotationOffset / 1000,
|
|
||||||
activities: sortedActivities,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) => a.timestamp - b.timestamp);
|
|
||||||
}, [timelineData, annotationOffset]);
|
|
||||||
|
|
||||||
// Filter activities if object is selected
|
|
||||||
const filteredGroups = useMemo(() => {
|
|
||||||
if (!selectedObjectId) {
|
|
||||||
return groupedActivities;
|
|
||||||
}
|
}
|
||||||
return groupedActivities
|
|
||||||
.map((group) => ({
|
if (!target && closest) target = closest.r;
|
||||||
...group,
|
|
||||||
activities: group.activities.filter(
|
if (target) {
|
||||||
(activity) => activity.source_id === selectedObjectId,
|
const start = (target.start_time ?? 0) - PAD;
|
||||||
),
|
setActiveReviewId(
|
||||||
}))
|
`review-${target.id ?? target.start_time ?? Math.floor(start)}`,
|
||||||
.filter((group) => group.activities.length > 0);
|
);
|
||||||
}, [groupedActivities, selectedObjectId]);
|
}
|
||||||
|
}, [reviewItems, activeReviewId, effectiveTime, PAD]);
|
||||||
|
|
||||||
// Auto-scroll to current time
|
// Auto-scroll to current time
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scrollRef.current || userInteracting) return;
|
if (!scrollRef.current || userInteracting) return;
|
||||||
|
// Prefer the review whose range contains the effectiveTime. If none
|
||||||
|
// contains it, pick the nearest review (by mid-point distance). This is
|
||||||
|
// robust to unordered reviewItems and avoids always picking the last
|
||||||
|
// element.
|
||||||
|
const items = reviewItems ?? [];
|
||||||
|
if (items.length === 0) return;
|
||||||
|
|
||||||
// Find the last group where effectiveTimestamp <= currentTime + annotationOffset
|
let target: ReviewSegment | undefined;
|
||||||
let currentGroupIndex = -1;
|
let closest: { r: ReviewSegment; diff: number } | undefined;
|
||||||
for (let i = filteredGroups.length - 1; i >= 0; i--) {
|
|
||||||
if (filteredGroups[i].effectiveTimestamp <= effectiveTime) {
|
for (const r of items) {
|
||||||
currentGroupIndex = i;
|
const start = (r.start_time ?? 0) - PAD;
|
||||||
|
const end = (r.end_time ?? r.start_time ?? start) + PAD;
|
||||||
|
if (effectiveTime >= start && effectiveTime <= end) {
|
||||||
|
target = r;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
const mid = (start + end) / 2;
|
||||||
|
const diff = Math.abs(effectiveTime - mid);
|
||||||
|
if (!closest || diff < closest.diff) closest = { r, diff };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentGroupIndex !== -1) {
|
if (!target && closest) target = closest.r;
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
const start = (target.start_time ?? 0) - PAD;
|
||||||
|
const id = `review-${target.id ?? target.start_time ?? Math.floor(start)}`;
|
||||||
const element = scrollRef.current.querySelector(
|
const element = scrollRef.current.querySelector(
|
||||||
`[data-timestamp="${filteredGroups[currentGroupIndex].timestamp}"]`,
|
`[data-review-id="${id}"]`,
|
||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
if (element) {
|
if (element) {
|
||||||
setProgrammaticScroll();
|
setProgrammaticScroll();
|
||||||
@ -103,38 +127,67 @@ export default function ActivityStream({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
filteredGroups,
|
reviewItems,
|
||||||
effectiveTime,
|
effectiveTime,
|
||||||
annotationOffset,
|
annotationOffset,
|
||||||
userInteracting,
|
userInteracting,
|
||||||
setProgrammaticScroll,
|
setProgrammaticScroll,
|
||||||
|
PAD,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Auto-select active review based on effectiveTime (if inside a review range)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!reviewItems || reviewItems.length === 0) return;
|
||||||
|
for (const r of reviewItems) {
|
||||||
|
const start = (r.start_time ?? 0) - PAD;
|
||||||
|
const end = (r.end_time ?? r.start_time ?? start) + PAD;
|
||||||
|
if (effectiveTime >= start && effectiveTime <= end) {
|
||||||
|
setActiveReviewId(
|
||||||
|
`review-${r.id ?? r.start_time ?? Math.floor(start)}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [effectiveTime, reviewItems, PAD]);
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return <ActivityIndicator />;
|
return <ActivityIndicator />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
<FrigatePlusDialog
|
||||||
|
upload={upload}
|
||||||
|
onClose={() => setUpload(undefined)}
|
||||||
|
onEventUploaded={() => setUpload(undefined)}
|
||||||
|
/>
|
||||||
|
|
||||||
<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 bg-secondary"
|
||||||
>
|
>
|
||||||
<div className="space-y-2 p-4">
|
<div className="space-y-2 p-4">
|
||||||
{filteredGroups.length === 0 ? (
|
{reviewItems?.length === 0 ? (
|
||||||
<div className="py-8 text-center text-muted-foreground">
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
{t("activity.noActivitiesFound")}
|
{t("activity.noActivitiesFound")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredGroups.map((group) => (
|
reviewItems?.map((review: ReviewSegment) => {
|
||||||
<ActivityGroup
|
const id = `review-${review.id ?? review.start_time ?? Math.floor((review.start_time ?? 0) - PAD)}`;
|
||||||
key={group.timestamp}
|
return (
|
||||||
group={group}
|
<ReviewGroup
|
||||||
config={config}
|
key={id}
|
||||||
isCurrent={group.effectiveTimestamp <= currentTime}
|
id={id}
|
||||||
onSeek={onSeek}
|
review={review}
|
||||||
/>
|
config={config}
|
||||||
))
|
onSeek={onSeek}
|
||||||
|
effectiveTime={effectiveTime}
|
||||||
|
isActive={activeReviewId == id}
|
||||||
|
onActivate={() => setActiveReviewId(id)}
|
||||||
|
onOpenUpload={(e) => setUpload(e)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -144,107 +197,352 @@ export default function ActivityStream({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActivityGroupProps = {
|
type ReviewGroupProps = {
|
||||||
group: {
|
review: ReviewSegment;
|
||||||
timestamp: number;
|
id: string;
|
||||||
effectiveTimestamp: number;
|
|
||||||
activities: ObjectLifecycleSequence[];
|
|
||||||
};
|
|
||||||
config: FrigateConfig;
|
config: FrigateConfig;
|
||||||
isCurrent: boolean;
|
|
||||||
onSeek: (timestamp: number) => void;
|
onSeek: (timestamp: number) => void;
|
||||||
|
isActive?: boolean;
|
||||||
|
onActivate?: () => void;
|
||||||
|
onOpenUpload?: (e: Event) => void;
|
||||||
|
effectiveTime?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ActivityGroup({
|
function ReviewGroup({
|
||||||
group,
|
review,
|
||||||
|
id,
|
||||||
config,
|
config,
|
||||||
isCurrent,
|
|
||||||
onSeek,
|
onSeek,
|
||||||
}: ActivityGroupProps) {
|
isActive = false,
|
||||||
|
onActivate,
|
||||||
|
onOpenUpload,
|
||||||
|
effectiveTime,
|
||||||
|
}: ReviewGroupProps) {
|
||||||
const { t } = useTranslation("views/events");
|
const { t } = useTranslation("views/events");
|
||||||
const shouldExpand = group.activities.length > 1;
|
const PAD = REVIEW_PADDING ?? 2;
|
||||||
|
|
||||||
|
// derive start timestamp from the review
|
||||||
|
const start = (review.start_time ?? 0) - PAD;
|
||||||
|
|
||||||
|
// display time first in the header
|
||||||
|
const displayTime = formatUnixTimestampToDateTime(start, {
|
||||||
|
timezone: config.ui.timezone,
|
||||||
|
date_format:
|
||||||
|
config.ui.time_format == "24hour"
|
||||||
|
? t("time.formattedTimestamp.24hour", { ns: "common" })
|
||||||
|
: t("time.formattedTimestamp.12hour", { ns: "common" }),
|
||||||
|
time_style: "medium",
|
||||||
|
date_style: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: fetchedEvents } = useSWR<Event[]>(
|
||||||
|
review?.data?.detections?.length
|
||||||
|
? ["event_ids", { ids: review.data.detections.join(",") }]
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawIconLabels: string[] = fetchedEvents
|
||||||
|
? fetchedEvents.map((e) => e.label)
|
||||||
|
: (review.data?.objects ?? []);
|
||||||
|
|
||||||
|
// limit to 5 icons
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const iconLabels: string[] = [];
|
||||||
|
for (const lbl of rawIconLabels) {
|
||||||
|
if (!seen.has(lbl)) {
|
||||||
|
seen.add(lbl);
|
||||||
|
iconLabels.push(lbl);
|
||||||
|
if (iconLabels.length >= 5) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-timestamp={group.timestamp}
|
data-review-id={id}
|
||||||
className={`cursor-pointer rounded-lg border p-3 transition-colors ${
|
className={`cursor-pointer rounded-lg border bg-background p-3 outline outline-[3px] -outline-offset-[2.8px] ${
|
||||||
isCurrent
|
isActive
|
||||||
? "border-primary/20 bg-primary/10"
|
? "shadow-selected outline-selected"
|
||||||
: "border-border bg-background hover:bg-muted/50"
|
: "outline-transparent duration-500"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onSeek(group.timestamp)}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
onClick={() => {
|
||||||
|
onActivate?.();
|
||||||
|
onSeek(start);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-sm font-medium">
|
<div className="flex flex-col">
|
||||||
{formatUnixTimestampToDateTime(group.timestamp, {
|
<div className="text-sm font-medium">{displayTime}</div>
|
||||||
timezone: config.ui.timezone,
|
|
||||||
date_format:
|
|
||||||
config.ui.time_format == "24hour"
|
|
||||||
? t("time.formattedTimestamp.24hour", {
|
|
||||||
ns: "common",
|
|
||||||
})
|
|
||||||
: t("time.formattedTimestamp.12hour", {
|
|
||||||
ns: "common",
|
|
||||||
}),
|
|
||||||
time_style: "medium",
|
|
||||||
date_style: "medium",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{shouldExpand && (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{t("activity.activitiesCount", {
|
{fetchedEvents
|
||||||
count: group.activities.length,
|
? fetchedEvents.length
|
||||||
})}
|
: (review.data.objects ?? []).length}{" "}
|
||||||
|
tracked objects
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{iconLabels.slice(0, 5).map((lbl, idx) => (
|
||||||
|
<span key={`${lbl}-${idx}`}>
|
||||||
|
{getIconForLabel(lbl, "size-4 text-primary dark:text-white")}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 space-y-1">
|
{isActive && (
|
||||||
{group.activities.map((activity, index) => (
|
<div className="mt-2 space-y-2">
|
||||||
<ActivityItem key={index} activity={activity} onSeek={onSeek} />
|
{!fetchedEvents ? (
|
||||||
))}
|
<ActivityIndicator />
|
||||||
</div>
|
) : (
|
||||||
</div>
|
fetchedEvents.map((event) => {
|
||||||
);
|
return (
|
||||||
}
|
<EventCollapsible
|
||||||
|
key={event.id}
|
||||||
type ActivityItemProps = {
|
event={event}
|
||||||
activity: ObjectLifecycleSequence;
|
effectiveTime={effectiveTime}
|
||||||
onSeek: (timestamp: number) => void;
|
onSeek={onSeek}
|
||||||
};
|
onOpenUpload={onOpenUpload}
|
||||||
|
/>
|
||||||
function ActivityItem({ activity }: ActivityItemProps) {
|
);
|
||||||
const { t } = useTranslation("views/events");
|
})
|
||||||
const { selectedObjectId, setSelectedObjectId } = useActivityStream();
|
)}
|
||||||
const handleObjectClick = (e: React.MouseEvent) => {
|
</div>
|
||||||
e.stopPropagation();
|
|
||||||
if (selectedObjectId === activity.source_id) {
|
|
||||||
setSelectedObjectId(undefined);
|
|
||||||
} else {
|
|
||||||
setSelectedObjectId(activity.source_id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<div className="flex h-4 w-4 items-center justify-center rounded bg-muted">
|
|
||||||
<LifecycleIcon lifecycleItem={activity} className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">{getLifecycleItemDescription(activity)}</div>
|
|
||||||
{activity.source_id && (
|
|
||||||
<button
|
|
||||||
onClick={handleObjectClick}
|
|
||||||
className={`rounded px-2 py-1 text-xs ${
|
|
||||||
selectedObjectId === activity.source_id
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-muted hover:bg-muted/80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t("activity.object")}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EventCollapsibleProps = {
|
||||||
|
event: Event;
|
||||||
|
effectiveTime?: number;
|
||||||
|
onSeek: (ts: number) => void;
|
||||||
|
onOpenUpload?: (e: Event) => void;
|
||||||
|
};
|
||||||
|
function EventCollapsible({
|
||||||
|
event,
|
||||||
|
effectiveTime,
|
||||||
|
onSeek,
|
||||||
|
onOpenUpload,
|
||||||
|
}: EventCollapsibleProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { t } = useTranslation("views/events");
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
const { selectedObjectId, setSelectedObjectId } = useActivityStream();
|
||||||
|
|
||||||
|
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",
|
||||||
|
})
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// Clear selectedObjectId when effectiveTime has passed this event's end_time
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedObjectId === event.id && effectiveTime && event.end_time) {
|
||||||
|
if (effectiveTime > event.end_time) {
|
||||||
|
setSelectedObjectId(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
selectedObjectId,
|
||||||
|
event.id,
|
||||||
|
event.end_time,
|
||||||
|
effectiveTime,
|
||||||
|
setSelectedObjectId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={(o) => setOpen(o)}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-md bg-secondary p-2 outline outline-[3px] -outline-offset-[2.8px]",
|
||||||
|
event.id == selectedObjectId
|
||||||
|
? "shadow-selected outline-selected"
|
||||||
|
: "outline-transparent duration-500",
|
||||||
|
event.id != selectedObjectId &&
|
||||||
|
(effectiveTime ?? 0) >= (event.start_time ?? 0) &&
|
||||||
|
(effectiveTime ?? 0) <= (event.end_time ?? event.start_time ?? 0) &&
|
||||||
|
"bg-secondary-highlight/80 outline-[1px] -outline-offset-[0.8px] outline-primary/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 text-sm font-medium"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSeek(event.start_time ?? 0);
|
||||||
|
if (event.id) setSelectedObjectId(event.id);
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
{getIconForLabel(
|
||||||
|
event.label,
|
||||||
|
"size-4 text-primary dark:text-white",
|
||||||
|
)}
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<span>{getTranslatedLabel(event.label)}</span>
|
||||||
|
<span className="text-xs text-secondary-foreground">
|
||||||
|
{formattedStart ?? ""} - {formattedEnd ?? ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-row justify-end">
|
||||||
|
<EventMenu
|
||||||
|
event={event}
|
||||||
|
config={config}
|
||||||
|
onOpenUpload={(e) => onOpenUpload?.(e)}
|
||||||
|
/>
|
||||||
|
</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("activity.details")}
|
||||||
|
>
|
||||||
|
{open ? (
|
||||||
|
<LuChevronUp className="size-3" />
|
||||||
|
) : (
|
||||||
|
<LuChevronDown className="size-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="mt-2">
|
||||||
|
<ObjectTimeline
|
||||||
|
eventId={event.id}
|
||||||
|
onSeek={(ts) => {
|
||||||
|
onSeek(ts);
|
||||||
|
}}
|
||||||
|
effectiveTime={effectiveTime}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LifecycleItemProps = {
|
||||||
|
event: ObjectLifecycleSequence;
|
||||||
|
onSeek: (timestamp: number) => void;
|
||||||
|
isActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function LifecycleItem({ event, isActive }: LifecycleItemProps) {
|
||||||
|
const { t } = useTranslation("views/events");
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
const formattedEventTimestamp = config
|
||||||
|
? formatUnixTimestampToDateTime(event.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",
|
||||||
|
})
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm text-primary-variant",
|
||||||
|
isActive ? "text-white" : "duration-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex size-4 items-center justify-center">
|
||||||
|
<LifecycleIcon lifecycleItem={event} className="size-3" />
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-row justify-between">
|
||||||
|
<div>{getLifecycleItemDescription(event)}</div>
|
||||||
|
<div className={cn("p-1 text-xs")}>{formattedEventTimestamp}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch and render timeline entries for a single event id on demand.
|
||||||
|
function ObjectTimeline({
|
||||||
|
eventId,
|
||||||
|
onSeek,
|
||||||
|
effectiveTime,
|
||||||
|
}: {
|
||||||
|
eventId: string;
|
||||||
|
onSeek: (ts: number) => void;
|
||||||
|
effectiveTime?: number;
|
||||||
|
}) {
|
||||||
|
const { data: timeline, isValidating } = useSWR<ObjectLifecycleSequence[]>([
|
||||||
|
"timeline",
|
||||||
|
{
|
||||||
|
source_id: eventId,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ((!timeline || timeline.length === 0) && isValidating) {
|
||||||
|
return <ActivityIndicator className="h-2 w-2" size={2} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timeline || timeline.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-2 text-sm text-muted-foreground">
|
||||||
|
No timeline entries
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-2 mt-4 space-y-2">
|
||||||
|
{timeline.map((event, idx) => {
|
||||||
|
const isActive =
|
||||||
|
Math.abs((effectiveTime ?? 0) - (event.timestamp ?? 0)) <= 0.5;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${event.timestamp}-${event.source_id ?? idx}`}
|
||||||
|
onClick={() => {
|
||||||
|
onSeek(event.timestamp);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LifecycleItem event={event} onSeek={onSeek} isActive={isActive} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
87
web/src/components/timeline/EventMenu.tsx
Normal file
87
web/src/components/timeline/EventMenu.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { HiDotsHorizontal } from "react-icons/hi";
|
||||||
|
import { useApiHost } from "@/api";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { Event } from "@/types/event";
|
||||||
|
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
|
||||||
|
type EventMenuProps = {
|
||||||
|
event: Event;
|
||||||
|
config?: FrigateConfig;
|
||||||
|
onOpenUpload?: (e: Event) => void;
|
||||||
|
onOpenSimilarity?: (e: Event) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EventMenu({
|
||||||
|
event,
|
||||||
|
config,
|
||||||
|
onOpenUpload,
|
||||||
|
onOpenSimilarity,
|
||||||
|
}: EventMenuProps) {
|
||||||
|
const apiHost = useApiHost();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation("views/explore");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<button
|
||||||
|
className="mr-2 rounded p-1"
|
||||||
|
aria-label={t("itemMenu.openMenu", { ns: "common" })}
|
||||||
|
>
|
||||||
|
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<a
|
||||||
|
download
|
||||||
|
href={
|
||||||
|
event.has_snapshot
|
||||||
|
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
||||||
|
: `${apiHost}api/events/${event.id}/thumbnail.webp`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("button.download", { ns: "common" })}
|
||||||
|
</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,17 +1,11 @@
|
|||||||
import React, {
|
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||||
createContext,
|
|
||||||
useContext,
|
|
||||||
useState,
|
|
||||||
useMemo,
|
|
||||||
useEffect,
|
|
||||||
} from "react";
|
|
||||||
import { ObjectLifecycleSequence } from "@/types/timeline";
|
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import { ObjectLifecycleSequence } from "@/types/timeline";
|
||||||
|
|
||||||
interface ActivityStreamContextType {
|
interface ActivityStreamContextType {
|
||||||
selectedObjectId: string | undefined;
|
selectedObjectId: string | undefined;
|
||||||
selectedObjectTimeline: ObjectLifecycleSequence[] | undefined;
|
selectedObjectTimeline?: ObjectLifecycleSequence[];
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
camera: string;
|
camera: string;
|
||||||
annotationOffset: number; // milliseconds
|
annotationOffset: number; // milliseconds
|
||||||
@ -29,7 +23,6 @@ interface ActivityStreamProviderProps {
|
|||||||
isActivityMode: boolean;
|
isActivityMode: boolean;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
camera: string;
|
camera: string;
|
||||||
timelineData: ObjectLifecycleSequence[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActivityStreamProvider({
|
export function ActivityStreamProvider({
|
||||||
@ -37,12 +30,15 @@ export function ActivityStreamProvider({
|
|||||||
isActivityMode,
|
isActivityMode,
|
||||||
currentTime,
|
currentTime,
|
||||||
camera,
|
camera,
|
||||||
timelineData,
|
|
||||||
}: ActivityStreamProviderProps) {
|
}: ActivityStreamProviderProps) {
|
||||||
const [selectedObjectId, setSelectedObjectId] = useState<
|
const [selectedObjectId, setSelectedObjectId] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>();
|
>();
|
||||||
|
|
||||||
|
const { data: selectedObjectTimeline } = useSWR<ObjectLifecycleSequence[]>(
|
||||||
|
selectedObjectId ? ["timeline", { source_id: selectedObjectId }] : null,
|
||||||
|
);
|
||||||
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
const [annotationOffset, setAnnotationOffset] = useState<number>(() => {
|
const [annotationOffset, setAnnotationOffset] = useState<number>(() => {
|
||||||
@ -56,11 +52,6 @@ export function ActivityStreamProvider({
|
|||||||
setAnnotationOffset(cfgOffset);
|
setAnnotationOffset(cfgOffset);
|
||||||
}, [config, camera]);
|
}, [config, camera]);
|
||||||
|
|
||||||
const selectedObjectTimeline = useMemo(() => {
|
|
||||||
if (!selectedObjectId || !timelineData) return undefined;
|
|
||||||
return timelineData.filter((item) => item.source_id === selectedObjectId);
|
|
||||||
}, [timelineData, selectedObjectId]);
|
|
||||||
|
|
||||||
const value: ActivityStreamContextType = {
|
const value: ActivityStreamContextType = {
|
||||||
selectedObjectId,
|
selectedObjectId,
|
||||||
selectedObjectTimeline,
|
selectedObjectTimeline,
|
||||||
|
|||||||
@ -41,11 +41,7 @@ import { IoMdArrowRoundBack } from "react-icons/io";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import {
|
import { TimeRange, TimelineType } from "@/types/timeline";
|
||||||
TimeRange,
|
|
||||||
TimelineType,
|
|
||||||
ObjectLifecycleSequence,
|
|
||||||
} from "@/types/timeline";
|
|
||||||
import MobileCameraDrawer from "@/components/overlay/MobileCameraDrawer";
|
import MobileCameraDrawer from "@/components/overlay/MobileCameraDrawer";
|
||||||
import MobileTimelineDrawer from "@/components/overlay/MobileTimelineDrawer";
|
import MobileTimelineDrawer from "@/components/overlay/MobileTimelineDrawer";
|
||||||
import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSettingsDrawer";
|
import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSettingsDrawer";
|
||||||
@ -166,45 +162,6 @@ export function RecordingView({
|
|||||||
[selectedRangeIdx, chunkedTimeRange],
|
[selectedRangeIdx, chunkedTimeRange],
|
||||||
);
|
);
|
||||||
|
|
||||||
// timeline data for activity stream
|
|
||||||
const { data: timelineResponse } = useSWR<{
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
count: number;
|
|
||||||
hours: { [key: string]: ObjectLifecycleSequence[] };
|
|
||||||
}>([
|
|
||||||
"timeline/hourly",
|
|
||||||
{
|
|
||||||
cameras: mainCamera,
|
|
||||||
before: timeRange.before,
|
|
||||||
after: timeRange.after,
|
|
||||||
limit: 1000,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const timelineData = useMemo(() => {
|
|
||||||
if (!timelineResponse?.hours) return [];
|
|
||||||
let data = Object.values(timelineResponse.hours).flat();
|
|
||||||
|
|
||||||
// Filter by review filter
|
|
||||||
if (filter?.labels && filter.labels.length > 0) {
|
|
||||||
data = data.filter((item) =>
|
|
||||||
filter.labels!.includes(item.data.label.replace("-verified", "")),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter?.zones && filter.zones.length > 0) {
|
|
||||||
data = data.filter(
|
|
||||||
(item) =>
|
|
||||||
item.data.zones &&
|
|
||||||
Array.isArray(item.data.zones) &&
|
|
||||||
item.data.zones.some((zone) => filter.zones!.includes(zone)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}, [timelineResponse, filter]);
|
|
||||||
|
|
||||||
const reviewFilterList = useMemo(() => {
|
const reviewFilterList = useMemo(() => {
|
||||||
const uniqueLabels = new Set<string>();
|
const uniqueLabels = new Set<string>();
|
||||||
|
|
||||||
@ -571,7 +528,6 @@ export function RecordingView({
|
|||||||
isActivityMode={timelineType === "activity"}
|
isActivityMode={timelineType === "activity"}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
camera={mainCamera}
|
camera={mainCamera}
|
||||||
timelineData={timelineData}
|
|
||||||
>
|
>
|
||||||
<div ref={contentRef} className="flex size-full flex-col pt-2">
|
<div ref={contentRef} className="flex size-full flex-col pt-2">
|
||||||
<Toaster closeButton={true} />
|
<Toaster closeButton={true} />
|
||||||
@ -892,7 +848,6 @@ export function RecordingView({
|
|||||||
manuallySetCurrentTime={manuallySetCurrentTime}
|
manuallySetCurrentTime={manuallySetCurrentTime}
|
||||||
setScrubbing={setScrubbing}
|
setScrubbing={setScrubbing}
|
||||||
setExportRange={setExportRange}
|
setExportRange={setExportRange}
|
||||||
timelineData={timelineData}
|
|
||||||
onAnalysisOpen={onAnalysisOpen}
|
onAnalysisOpen={onAnalysisOpen}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -915,7 +870,6 @@ type TimelineProps = {
|
|||||||
manuallySetCurrentTime: (time: number, force: boolean) => void;
|
manuallySetCurrentTime: (time: number, force: boolean) => void;
|
||||||
setScrubbing: React.Dispatch<React.SetStateAction<boolean>>;
|
setScrubbing: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setExportRange: (range: TimeRange) => void;
|
setExportRange: (range: TimeRange) => void;
|
||||||
timelineData?: ObjectLifecycleSequence[];
|
|
||||||
onAnalysisOpen: (open: boolean) => void;
|
onAnalysisOpen: (open: boolean) => void;
|
||||||
};
|
};
|
||||||
function Timeline({
|
function Timeline({
|
||||||
@ -932,7 +886,6 @@ function Timeline({
|
|||||||
manuallySetCurrentTime,
|
manuallySetCurrentTime,
|
||||||
setScrubbing,
|
setScrubbing,
|
||||||
setExportRange,
|
setExportRange,
|
||||||
timelineData,
|
|
||||||
onAnalysisOpen,
|
onAnalysisOpen,
|
||||||
}: TimelineProps) {
|
}: TimelineProps) {
|
||||||
const { t } = useTranslation(["views/events"]);
|
const { t } = useTranslation(["views/events"]);
|
||||||
@ -1053,9 +1006,9 @@ function Timeline({
|
|||||||
)
|
)
|
||||||
) : timelineType == "activity" ? (
|
) : timelineType == "activity" ? (
|
||||||
<ActivityStream
|
<ActivityStream
|
||||||
timelineData={timelineData ?? []}
|
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
onSeek={(timestamp) => manuallySetCurrentTime(timestamp, true)}
|
onSeek={(timestamp) => manuallySetCurrentTime(timestamp, true)}
|
||||||
|
reviewItems={mainCameraReviewItems}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<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