This commit is contained in:
Josh Hawkins 2025-10-16 07:08:01 -05:00
parent a134b06e03
commit 64aa709888
4 changed files with 539 additions and 210 deletions

View File

@ -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;
}
const mid = (start + end) / 2;
const diff = Math.abs(effectiveTime - mid);
if (!closest || diff < closest.diff) closest = { r, diff };
} }
groups[groupKey].push(activity);
});
return Object.entries(groups) if (!target && closest) target = closest.r;
.map(([_timestamp, activities]) => {
const sortedActivities = activities.sort( if (target) {
(a, b) => a.timestamp - b.timestamp, const start = (target.start_time ?? 0) - PAD;
setActiveReviewId(
`review-${target.id ?? target.start_time ?? Math.floor(start)}`,
); );
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 }, [reviewItems, activeReviewId, effectiveTime, PAD]);
.map((group) => ({
...group,
activities: group.activities.filter(
(activity) => activity.source_id === selectedObjectId,
),
}))
.filter((group) => group.activities.length > 0);
}, [groupedActivities, selectedObjectId]);
// 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
key={id}
id={id}
review={review}
config={config} config={config}
isCurrent={group.effectiveTimestamp <= currentTime}
onSeek={onSeek} 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;
return ( // derive start timestamp from the review
<div const start = (review.start_time ?? 0) - PAD;
data-timestamp={group.timestamp}
className={`cursor-pointer rounded-lg border p-3 transition-colors ${ // display time first in the header
isCurrent const displayTime = formatUnixTimestampToDateTime(start, {
? "border-primary/20 bg-primary/10"
: "border-border bg-background hover:bg-muted/50"
}`}
onClick={() => onSeek(group.timestamp)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="text-sm font-medium">
{formatUnixTimestampToDateTime(group.timestamp, {
timezone: config.ui.timezone, timezone: config.ui.timezone,
date_format: date_format:
config.ui.time_format == "24hour" config.ui.time_format == "24hour"
? t("time.formattedTimestamp.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 (
<div
data-review-id={id}
className={`cursor-pointer rounded-lg border bg-background p-3 outline outline-[3px] -outline-offset-[2.8px] ${
isActive
? "shadow-selected outline-selected"
: "outline-transparent duration-500"
}`}
>
<div
className="flex items-center justify-between"
onClick={() => {
onActivate?.();
onSeek(start);
}}
>
<div className="flex items-center gap-2">
<div className="flex flex-col">
<div className="text-sm font-medium">{displayTime}</div>
<div className="text-xs text-muted-foreground">
{fetchedEvents
? fetchedEvents.length
: (review.data.objects ?? []).length}{" "}
tracked objects
</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>
{isActive && (
<div className="mt-2 space-y-2">
{!fetchedEvents ? (
<ActivityIndicator />
) : (
fetchedEvents.map((event) => {
return (
<EventCollapsible
key={event.id}
event={event}
effectiveTime={effectiveTime}
onSeek={onSeek}
onOpenUpload={onOpenUpload}
/>
);
})
)}
</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", ns: "common",
}) })
: t("time.formattedTimestamp.12hour", { : t("time.formattedTimestampHourMinuteSecond.12hour", {
ns: "common", ns: "common",
}), }),
time_style: "medium", time_style: "medium",
date_style: "medium", date_style: "medium",
})} })
</div> : "";
{shouldExpand && (
<div className="text-xs text-muted-foreground">
{t("activity.activitiesCount", {
count: group.activities.length,
})}
</div>
)}
</div>
</div>
<div className="mt-2 space-y-1"> const formattedEnd = config
{group.activities.map((activity, index) => ( ? formatUnixTimestampToDateTime(event.end_time ?? 0, {
<ActivityItem key={index} activity={activity} onSeek={onSeek} /> timezone: config.ui.timezone,
))} date_format:
</div> config.ui.time_format == "24hour"
</div> ? t("time.formattedTimestampHourMinuteSecond.24hour", {
); ns: "common",
} })
: t("time.formattedTimestampHourMinuteSecond.12hour", {
ns: "common",
}),
time_style: "medium",
date_style: "medium",
})
: "";
type ActivityItemProps = { // Clear selectedObjectId when effectiveTime has passed this event's end_time
activity: ObjectLifecycleSequence; useEffect(() => {
onSeek: (timestamp: number) => void; if (selectedObjectId === event.id && effectiveTime && event.end_time) {
}; if (effectiveTime > event.end_time) {
function ActivityItem({ activity }: ActivityItemProps) {
const { t } = useTranslation("views/events");
const { selectedObjectId, setSelectedObjectId } = useActivityStream();
const handleObjectClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (selectedObjectId === activity.source_id) {
setSelectedObjectId(undefined); setSelectedObjectId(undefined);
} else {
setSelectedObjectId(activity.source_id);
} }
}; }
}, [
selectedObjectId,
event.id,
event.end_time,
effectiveTime,
setSelectedObjectId,
]);
return ( return (
<div className="flex items-center gap-2 text-sm"> <Collapsible open={open} onOpenChange={(o) => setOpen(o)}>
<div className="flex h-4 w-4 items-center justify-center rounded bg-muted"> <div
<LifecycleIcon lifecycleItem={activity} className="h-3 w-3" /> className={cn(
</div> "rounded-md bg-secondary p-2 outline outline-[3px] -outline-offset-[2.8px]",
<div className="flex-1">{getLifecycleItemDescription(activity)}</div> event.id == selectedObjectId
{activity.source_id && ( ? "shadow-selected outline-selected"
<button : "outline-transparent duration-500",
onClick={handleObjectClick} event.id != selectedObjectId &&
className={`rounded px-2 py-1 text-xs ${ (effectiveTime ?? 0) >= (event.start_time ?? 0) &&
selectedObjectId === activity.source_id (effectiveTime ?? 0) <= (event.end_time ?? event.start_time ?? 0) &&
? "bg-primary text-primary-foreground" "bg-secondary-highlight/80 outline-[1px] -outline-offset-[0.8px] outline-primary/40",
: "bg-muted hover:bg-muted/80"
}`}
>
{t("activity.object")}
</button>
)} )}
>
<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> </div>
); );
} }

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

View File

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

View File

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