mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-12 10:07:36 +03:00
activity stream panel
This commit is contained in:
parent
b639ea766f
commit
cecbea0faf
181
web/src/components/timeline/ActivityStream.tsx
Normal file
181
web/src/components/timeline/ActivityStream.tsx
Normal file
@ -0,0 +1,181 @@
|
||||
import { useMemo, useEffect, useRef } from "react";
|
||||
import { ObjectLifecycleSequence } from "@/types/timeline";
|
||||
import { LifecycleIcon } from "@/components/overlay/detail/ObjectLifecycle";
|
||||
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||
import { useActivityStream } from "@/contexts/ActivityStreamContext";
|
||||
|
||||
type ActivityStreamProps = {
|
||||
timelineData: ObjectLifecycleSequence[];
|
||||
currentTime: number;
|
||||
onSeek: (timestamp: number) => void;
|
||||
};
|
||||
|
||||
export default function ActivityStream({
|
||||
timelineData,
|
||||
currentTime,
|
||||
onSeek,
|
||||
}: ActivityStreamProps) {
|
||||
const { selectedObjectId } = useActivityStream();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Group activities by timestamp (within 1 second resolution window)
|
||||
const groupedActivities = useMemo(() => {
|
||||
const groups: { [key: number]: ObjectLifecycleSequence[] } = {};
|
||||
|
||||
timelineData.forEach((activity) => {
|
||||
const groupKey = Math.floor(activity.timestamp);
|
||||
if (!groups[groupKey]) {
|
||||
groups[groupKey] = [];
|
||||
}
|
||||
groups[groupKey].push(activity);
|
||||
});
|
||||
|
||||
return Object.entries(groups)
|
||||
.map(([timestamp, activities]) => ({
|
||||
timestamp: parseInt(timestamp),
|
||||
activities: activities.sort((a, b) => a.timestamp - b.timestamp),
|
||||
}))
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [timelineData]);
|
||||
|
||||
// Filter activities if object is selected
|
||||
const filteredGroups = useMemo(() => {
|
||||
if (!selectedObjectId) {
|
||||
return groupedActivities;
|
||||
}
|
||||
return groupedActivities
|
||||
.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
|
||||
useEffect(() => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const currentGroupIndex = filteredGroups.findIndex(
|
||||
(group) => group.timestamp >= currentTime,
|
||||
);
|
||||
|
||||
if (currentGroupIndex !== -1) {
|
||||
const element = scrollRef.current.children[
|
||||
currentGroupIndex
|
||||
] as HTMLElement;
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [currentTime, filteredGroups]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="scrollbar-container h-full overflow-y-auto bg-secondary"
|
||||
>
|
||||
<div className="space-y-2 p-4">
|
||||
{filteredGroups.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
No activities found
|
||||
</div>
|
||||
) : (
|
||||
filteredGroups.map((group) => (
|
||||
<ActivityGroup
|
||||
key={group.timestamp}
|
||||
group={group}
|
||||
isCurrent={group.timestamp <= currentTime}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ActivityGroupProps = {
|
||||
group: {
|
||||
timestamp: number;
|
||||
activities: ObjectLifecycleSequence[];
|
||||
};
|
||||
isCurrent: boolean;
|
||||
onSeek: (timestamp: number) => void;
|
||||
};
|
||||
|
||||
function ActivityGroup({ group, isCurrent, onSeek }: ActivityGroupProps) {
|
||||
const shouldExpand = group.activities.length > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-colors ${
|
||||
isCurrent
|
||||
? "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">
|
||||
{new Date(group.timestamp * 1000).toLocaleTimeString()}
|
||||
</div>
|
||||
{shouldExpand && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{group.activities.length} activities
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
{group.activities.map((activity, index) => (
|
||||
<ActivityItem key={index} activity={activity} onSeek={onSeek} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ActivityItemProps = {
|
||||
activity: ObjectLifecycleSequence;
|
||||
onSeek: (timestamp: number) => void;
|
||||
};
|
||||
|
||||
function ActivityItem({ activity }: ActivityItemProps) {
|
||||
const { selectedObjectId, setSelectedObjectId } = useActivityStream();
|
||||
const handleObjectClick = (e: React.MouseEvent) => {
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
Object
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user