From 64aa7098883971e32d34821e270b24535e0a8923 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 16 Oct 2025 07:08:01 -0500 Subject: [PATCH] refactor --- .../components/timeline/ActivityStream.tsx | 588 +++++++++++++----- web/src/components/timeline/EventMenu.tsx | 87 +++ web/src/contexts/ActivityStreamContext.tsx | 23 +- web/src/views/recording/RecordingView.tsx | 51 +- 4 files changed, 539 insertions(+), 210 deletions(-) create mode 100644 web/src/components/timeline/EventMenu.tsx diff --git a/web/src/components/timeline/ActivityStream.tsx b/web/src/components/timeline/ActivityStream.tsx index 369ec8eb9..cf7eda3cc 100644 --- a/web/src/components/timeline/ActivityStream.tsx +++ b/web/src/components/timeline/ActivityStream.tsx @@ -1,4 +1,4 @@ -import { useMemo, useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { ObjectLifecycleSequence } from "@/types/timeline"; import { LifecycleIcon } from "@/components/overlay/detail/ObjectLifecycle"; import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; @@ -11,88 +11,112 @@ import AnnotationOffsetSlider from "@/components/overlay/detail/AnnotationOffset import { FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; 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 = { - timelineData: ObjectLifecycleSequence[]; + reviewItems?: ReviewSegment[]; currentTime: number; onSeek: (timestamp: number) => void; }; export default function ActivityStream({ - timelineData, + reviewItems, currentTime, onSeek, }: ActivityStreamProps) { const { data: config } = useSWR("config"); const { t } = useTranslation("views/events"); - const { selectedObjectId, annotationOffset } = useActivityStream(); + const { annotationOffset } = useActivityStream(); const scrollRef = useRef(null); - const effectiveTime = currentTime + annotationOffset / 1000; - - // Track user interaction and adjust scrolling behavior + const [activeReviewId, setActiveReviewId] = useState( + undefined, + ); const { userInteracting, setProgrammaticScroll } = useUserInteraction({ elementRef: scrollRef, }); - // group activities by timestamp (within 1 second resolution window) - const groupedActivities = useMemo(() => { - const groups: { [key: number]: ObjectLifecycleSequence[] } = {}; + const effectiveTime = currentTime + annotationOffset / 1000; + const PAD = 0; // REVIEW_PADDING ?? 2; + const [upload, setUpload] = useState(undefined); - timelineData.forEach((activity) => { - const groupKey = Math.floor(activity.timestamp); - if (!groups[groupKey]) { - groups[groupKey] = []; + // Ensure we initialize the active review when reviewItems first arrive. + // This helps when the component mounts while the video is already + // playing — it guarantees the matching review is highlighted right + // 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); - }); - - 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; + const mid = (start + end) / 2; + const diff = Math.abs(effectiveTime - mid); + if (!closest || diff < closest.diff) closest = { r, diff }; } - return groupedActivities - .map((group) => ({ - ...group, - activities: group.activities.filter( - (activity) => activity.source_id === selectedObjectId, - ), - })) - .filter((group) => group.activities.length > 0); - }, [groupedActivities, selectedObjectId]); + + if (!target && closest) target = closest.r; + + if (target) { + const start = (target.start_time ?? 0) - PAD; + setActiveReviewId( + `review-${target.id ?? target.start_time ?? Math.floor(start)}`, + ); + } + }, [reviewItems, activeReviewId, effectiveTime, PAD]); // Auto-scroll to current time useEffect(() => { 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 currentGroupIndex = -1; - for (let i = filteredGroups.length - 1; i >= 0; i--) { - if (filteredGroups[i].effectiveTimestamp <= effectiveTime) { - currentGroupIndex = i; + let target: ReviewSegment | undefined; + let closest: { r: ReviewSegment; diff: number } | undefined; + + for (const r of items) { + 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 }; } - 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( - `[data-timestamp="${filteredGroups[currentGroupIndex].timestamp}"]`, + `[data-review-id="${id}"]`, ) as HTMLElement; if (element) { setProgrammaticScroll(); @@ -103,38 +127,67 @@ export default function ActivityStream({ } } }, [ - filteredGroups, + reviewItems, effectiveTime, annotationOffset, userInteracting, 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) { return ; } return (
+ setUpload(undefined)} + onEventUploaded={() => setUpload(undefined)} + /> +
- {filteredGroups.length === 0 ? ( + {reviewItems?.length === 0 ? (
{t("activity.noActivitiesFound")}
) : ( - filteredGroups.map((group) => ( - - )) + reviewItems?.map((review: ReviewSegment) => { + const id = `review-${review.id ?? review.start_time ?? Math.floor((review.start_time ?? 0) - PAD)}`; + return ( + setActiveReviewId(id)} + onOpenUpload={(e) => setUpload(e)} + /> + ); + }) )}
@@ -144,107 +197,352 @@ export default function ActivityStream({ ); } -type ActivityGroupProps = { - group: { - timestamp: number; - effectiveTimestamp: number; - activities: ObjectLifecycleSequence[]; - }; +type ReviewGroupProps = { + review: ReviewSegment; + id: string; config: FrigateConfig; - isCurrent: boolean; onSeek: (timestamp: number) => void; + isActive?: boolean; + onActivate?: () => void; + onOpenUpload?: (e: Event) => void; + effectiveTime?: number; }; -function ActivityGroup({ - group, +function ReviewGroup({ + review, + id, config, - isCurrent, onSeek, -}: ActivityGroupProps) { + isActive = false, + onActivate, + onOpenUpload, + effectiveTime, +}: ReviewGroupProps) { 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( + 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(); + const iconLabels: string[] = []; + for (const lbl of rawIconLabels) { + if (!seen.has(lbl)) { + seen.add(lbl); + iconLabels.push(lbl); + if (iconLabels.length >= 5) break; + } + } return (
onSeek(group.timestamp)} > -
+
{ + onActivate?.(); + onSeek(start); + }} + >
-
- {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 && ( +
+
{displayTime}
- {t("activity.activitiesCount", { - count: group.activities.length, - })} + {fetchedEvents + ? fetchedEvents.length + : (review.data.objects ?? []).length}{" "} + tracked objects
- )} +
+
+
+ {iconLabels.slice(0, 5).map((lbl, idx) => ( + + {getIconForLabel(lbl, "size-4 text-primary dark:text-white")} + + ))}
-
- {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 && ( - + {isActive && ( +
+ {!fetchedEvents ? ( + + ) : ( + fetchedEvents.map((event) => { + return ( + + ); + }) + )} +
)}
); } + +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("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 ( + setOpen(o)}> +
= (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", + )} + > +
+
{ + 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", + )} +
+ {getTranslatedLabel(event.label)} + + {formattedStart ?? ""} - {formattedEnd ?? ""} + +
+
+
+ onOpenUpload?.(e)} + /> +
+ +
+ + + +
+
+ +
+ { + onSeek(ts); + }} + effectiveTime={effectiveTime} + /> +
+
+
+
+ ); +} + +type LifecycleItemProps = { + event: ObjectLifecycleSequence; + onSeek: (timestamp: number) => void; + isActive?: boolean; +}; + +function LifecycleItem({ event, isActive }: LifecycleItemProps) { + const { t } = useTranslation("views/events"); + const { data: config } = useSWR("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 ( +
+
+ +
+
+
{getLifecycleItemDescription(event)}
+
{formattedEventTimestamp}
+
+
+ ); +} + +// 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([ + "timeline", + { + source_id: eventId, + }, + ]); + + if ((!timeline || timeline.length === 0) && isValidating) { + return ; + } + + if (!timeline || timeline.length === 0) { + return ( +
+ No timeline entries +
+ ); + } + + return ( +
+ {timeline.map((event, idx) => { + const isActive = + Math.abs((effectiveTime ?? 0) - (event.timestamp ?? 0)) <= 0.5; + return ( +
{ + onSeek(event.timestamp); + }} + > + +
+ ); + })} +
+ ); +} diff --git a/web/src/components/timeline/EventMenu.tsx b/web/src/components/timeline/EventMenu.tsx new file mode 100644 index 000000000..85218895e --- /dev/null +++ b/web/src/components/timeline/EventMenu.tsx @@ -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 ( + + + + + + + + + {t("button.download", { ns: "common" })} + + + + {event.has_snapshot && + event.plus_id == undefined && + event.data.type == "object" && + config?.plus?.enabled && ( + { + onOpenUpload?.(event); + }} + > + {t("itemMenu.submitToPlus.label")} + + )} + + {event.has_snapshot && config?.semantic_search?.enabled && ( + { + if (onOpenSimilarity) onOpenSimilarity(event); + else + navigate( + `/explore?search_type=similarity&event_id=${event.id}`, + ); + }} + > + {t("itemMenu.findSimilar.label")} + + )} + + + + ); +} diff --git a/web/src/contexts/ActivityStreamContext.tsx b/web/src/contexts/ActivityStreamContext.tsx index ebdd0aef7..910211229 100644 --- a/web/src/contexts/ActivityStreamContext.tsx +++ b/web/src/contexts/ActivityStreamContext.tsx @@ -1,17 +1,11 @@ -import React, { - createContext, - useContext, - useState, - useMemo, - useEffect, -} from "react"; -import { ObjectLifecycleSequence } from "@/types/timeline"; +import React, { createContext, useContext, useState, useEffect } from "react"; import { FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; +import { ObjectLifecycleSequence } from "@/types/timeline"; interface ActivityStreamContextType { selectedObjectId: string | undefined; - selectedObjectTimeline: ObjectLifecycleSequence[] | undefined; + selectedObjectTimeline?: ObjectLifecycleSequence[]; currentTime: number; camera: string; annotationOffset: number; // milliseconds @@ -29,7 +23,6 @@ interface ActivityStreamProviderProps { isActivityMode: boolean; currentTime: number; camera: string; - timelineData: ObjectLifecycleSequence[]; } export function ActivityStreamProvider({ @@ -37,12 +30,15 @@ export function ActivityStreamProvider({ isActivityMode, currentTime, camera, - timelineData, }: ActivityStreamProviderProps) { const [selectedObjectId, setSelectedObjectId] = useState< string | undefined >(); + const { data: selectedObjectTimeline } = useSWR( + selectedObjectId ? ["timeline", { source_id: selectedObjectId }] : null, + ); + const { data: config } = useSWR("config"); const [annotationOffset, setAnnotationOffset] = useState(() => { @@ -56,11 +52,6 @@ export function ActivityStreamProvider({ setAnnotationOffset(cfgOffset); }, [config, camera]); - const selectedObjectTimeline = useMemo(() => { - if (!selectedObjectId || !timelineData) return undefined; - return timelineData.filter((item) => item.source_id === selectedObjectId); - }, [timelineData, selectedObjectId]); - const value: ActivityStreamContextType = { selectedObjectId, selectedObjectTimeline, diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 6fb1a1fac..13ecccac2 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -41,11 +41,7 @@ import { IoMdArrowRoundBack } from "react-icons/io"; import { useNavigate } from "react-router-dom"; import { Toaster } from "@/components/ui/sonner"; import useSWR from "swr"; -import { - TimeRange, - TimelineType, - ObjectLifecycleSequence, -} from "@/types/timeline"; +import { TimeRange, TimelineType } from "@/types/timeline"; import MobileCameraDrawer from "@/components/overlay/MobileCameraDrawer"; import MobileTimelineDrawer from "@/components/overlay/MobileTimelineDrawer"; import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSettingsDrawer"; @@ -166,45 +162,6 @@ export function RecordingView({ [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 uniqueLabels = new Set(); @@ -571,7 +528,6 @@ export function RecordingView({ isActivityMode={timelineType === "activity"} currentTime={currentTime} camera={mainCamera} - timelineData={timelineData} >
@@ -892,7 +848,6 @@ export function RecordingView({ manuallySetCurrentTime={manuallySetCurrentTime} setScrubbing={setScrubbing} setExportRange={setExportRange} - timelineData={timelineData} onAnalysisOpen={onAnalysisOpen} />
@@ -915,7 +870,6 @@ type TimelineProps = { manuallySetCurrentTime: (time: number, force: boolean) => void; setScrubbing: React.Dispatch>; setExportRange: (range: TimeRange) => void; - timelineData?: ObjectLifecycleSequence[]; onAnalysisOpen: (open: boolean) => void; }; function Timeline({ @@ -932,7 +886,6 @@ function Timeline({ manuallySetCurrentTime, setScrubbing, setExportRange, - timelineData, onAnalysisOpen, }: TimelineProps) { const { t } = useTranslation(["views/events"]); @@ -1053,9 +1006,9 @@ function Timeline({ ) ) : timelineType == "activity" ? ( manuallySetCurrentTime(timestamp, true)} + reviewItems={mainCameraReviewItems} /> ) : (