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"; import scrollIntoView from "scroll-into-view-if-needed"; import useUserInteraction from "@/hooks/use-user-interaction"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { useTranslation } from "react-i18next"; import AnnotationOffsetSlider from "@/components/overlay/detail/AnnotationOffsetSlider"; import { FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; import ActivityIndicator from "../indicators/activity-indicator"; type ActivityStreamProps = { timelineData: ObjectLifecycleSequence[]; currentTime: number; onSeek: (timestamp: number) => void; }; export default function ActivityStream({ timelineData, currentTime, onSeek, }: ActivityStreamProps) { const { data: config } = useSWR("config"); const { t } = useTranslation("views/events"); const { selectedObjectId, annotationOffset } = useActivityStream(); const scrollRef = useRef(null); const effectiveTime = currentTime + annotationOffset / 1000; // Track user interaction and adjust scrolling behavior const { userInteracting, setProgrammaticScroll } = useUserInteraction({ elementRef: scrollRef, }); // 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]) => { 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) => ({ ...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 || userInteracting) return; // Find the last group where effectiveTimestamp <= currentTime + annotationOffset let currentGroupIndex = -1; for (let i = filteredGroups.length - 1; i >= 0; i--) { if (filteredGroups[i].effectiveTimestamp <= effectiveTime) { currentGroupIndex = i; break; } } if (currentGroupIndex !== -1) { const element = scrollRef.current.querySelector( `[data-timestamp="${filteredGroups[currentGroupIndex].timestamp}"]`, ) as HTMLElement; if (element) { setProgrammaticScroll(); scrollIntoView(element, { scrollMode: "if-needed", behavior: "smooth", }); } } }, [ filteredGroups, effectiveTime, annotationOffset, userInteracting, setProgrammaticScroll, ]); if (!config) { return ; } return (
{filteredGroups.length === 0 ? (
{t("activity.noActivitiesFound")}
) : ( filteredGroups.map((group) => ( )) )}
); } type ActivityGroupProps = { group: { timestamp: number; effectiveTimestamp: number; activities: ObjectLifecycleSequence[]; }; config: FrigateConfig; isCurrent: boolean; onSeek: (timestamp: number) => void; }; function ActivityGroup({ group, config, isCurrent, onSeek, }: ActivityGroupProps) { const { t } = useTranslation("views/events"); const shouldExpand = group.activities.length > 1; return (
onSeek(group.timestamp)} >
{formatUnixTimestampToDateTime(group.timestamp, { 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", })}
{shouldExpand && (
{t("activity.activitiesCount", { count: group.activities.length, })}
)}
{group.activities.map((activity, index) => ( ))}
); } type ActivityItemProps = { activity: ObjectLifecycleSequence; onSeek: (timestamp: number) => void; }; 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); } else { setSelectedObjectId(activity.source_id); } }; return (
{getLifecycleItemDescription(activity)}
{activity.source_id && ( )}
); }