diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 776adb4f2..6b2afacad 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -7,6 +7,7 @@ import PreviewPlayer, { import { DynamicVideoController } from "@/components/player/dynamic/DynamicVideoController"; import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer"; import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline"; +import ActivityStream from "@/components/timeline/ActivityStream"; import { Button } from "@/components/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { useOverlayState } from "@/hooks/use-overlay-state"; @@ -40,7 +41,11 @@ 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 } from "@/types/timeline"; +import { + TimeRange, + TimelineType, + ObjectLifecycleSequence, +} from "@/types/timeline"; import MobileCameraDrawer from "@/components/overlay/MobileCameraDrawer"; import MobileTimelineDrawer from "@/components/overlay/MobileTimelineDrawer"; import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSettingsDrawer"; @@ -66,6 +71,7 @@ import { } from "@/components/ui/tooltip"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; +import { ActivityStreamProvider } from "@/contexts/ActivityStreamContext"; import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip"; const DATA_REFRESH_TIME = 600000; // 10 minutes @@ -159,6 +165,46 @@ export function RecordingView({ chunkedTimeRange[chunkedTimeRange.length - 1], [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(); @@ -521,202 +567,223 @@ export function RecordingView({ ); return ( -
- -
- {isMobile && ( - - )} -
- + +
+
+ {isDesktop && ( -
- {t("button.back", { ns: "common" })} -
+ { + setExportRange(range); + + if (range != undefined) { + mainControllerRef.current?.pause(); + } + }} + setMode={setExportMode} + setShowPreview={setShowExportPreview} + /> )} - - -
-
- - {isDesktop && ( - + value ? setTimelineType(value, true) : null + } // don't allow the severity to be unselected + > + +
{t("timeline")}
+
+ +
{t("events.label")}
+
+ +
Activity
+
+ + ) : ( + + )} + { - setExportRange(range); - - if (range != undefined) { - mainControllerRef.current?.pause(); - } - }} + showExportPreview={showExportPreview} + allLabels={reviewFilterList.labels} + allZones={reviewFilterList.zones} + onUpdateFilter={updateFilter} + setRange={setExportRange} setMode={setExportMode} - setShowPreview={setShowExportPreview} + setShowExportPreview={setShowExportPreview} /> - )} - {isDesktop && ( - {}} - mainCamera={mainCamera} - onUpdateFilter={(newFilter: ReviewFilter) => { - const updatedCameras = - newFilter.cameras === undefined - ? undefined // Respect undefined as "all cameras" - : newFilter.cameras - ? Array.from( - new Set([mainCamera, ...(newFilter.cameras || [])]), - ) // Include mainCamera if specific cameras are selected - : [mainCamera]; - const adjustedFilter: ReviewFilter = { - ...newFilter, - cameras: updatedCameras, - }; - updateFilter(adjustedFilter); - }} - setMotionOnly={() => {}} - /> - )} - {isDesktop ? ( - - value ? setTimelineType(value, true) : null - } // don't allow the severity to be unselected - > - -
{t("timeline")}
-
- -
{t("events.label")}
-
-
- ) : ( - - )} - +
-
-
- {isDesktop && ( +
+ {isDesktop && ( { - setPlayerTime(timestamp); - setCurrentTime(timestamp); - Object.values(previewRefs.current ?? {}).forEach((prev) => - prev.scrubToTimestamp(Math.floor(timestamp)), - ); - }} - onClipEnded={onClipEnded} - onControllerReady={(controller) => { - mainControllerRef.current = controller; - }} - isScrubbing={scrubbing || exportMode == "timeline"} - supportsFullscreen={supportsFullScreen} - setFullResolution={setFullResolution} - toggleFullscreen={toggleFullscreen} - containerRef={mainLayoutRef} - /> -
- {isDesktop && effectiveCameras.length > 1 && ( -
-
- {effectiveCameras.map((cam) => { - if (cam == mainCamera || cam == "birdseye") { - return; - } - - return ( - - -
- { - previewRefs.current[cam] = controller; - controller.scrubToTimestamp(startTime); - }} - onClick={() => onSelectCamera(cam)} - /> -
-
- - - -
- ); - })} -
+ className={grow} + camera={mainCamera} + timeRange={currentTimeRange} + cameraPreviews={allPreviews ?? []} + startTimestamp={playbackStart} + hotKeys={exportMode != "select"} + fullscreen={fullscreen} + onTimestampUpdate={(timestamp) => { + setPlayerTime(timestamp); + setCurrentTime(timestamp); + Object.values(previewRefs.current ?? {}).forEach((prev) => + prev.scrubToTimestamp(Math.floor(timestamp)), + ); + }} + onClipEnded={onClipEnded} + onSeekToTime={manuallySetCurrentTime} + onControllerReady={(controller) => { + mainControllerRef.current = controller; + }} + isScrubbing={scrubbing || exportMode == "timeline"} + supportsFullscreen={supportsFullScreen} + setFullResolution={setFullResolution} + toggleFullscreen={toggleFullscreen} + containerRef={mainLayoutRef} + />
- )} + {isDesktop && + effectiveCameras.length > 1 && + timelineType !== "activity" && ( +
+
+ {effectiveCameras.map((cam) => { + if (cam == mainCamera || cam == "birdseye") { + return; + } + + return ( + + +
+ { + previewRefs.current[cam] = controller; + controller.scrubToTimestamp(startTime); + }} + onClick={() => onSelectCamera(cam)} + /> +
+
+ + + +
+ ); + })} +
+
+ )} +
-
- +
-
+ ); } @@ -841,6 +914,7 @@ type TimelineProps = { manuallySetCurrentTime: (time: number, force: boolean) => void; setScrubbing: React.Dispatch>; setExportRange: (range: TimeRange) => void; + timelineData?: ObjectLifecycleSequence[]; onAnalysisOpen: (open: boolean) => void; }; function Timeline({ @@ -857,6 +931,7 @@ function Timeline({ manuallySetCurrentTime, setScrubbing, setExportRange, + timelineData, onAnalysisOpen, }: TimelineProps) { const { t } = useTranslation(["views/events"]); @@ -938,9 +1013,9 @@ function Timeline({ className={cn( "relative", isDesktop - ? `${timelineType == "timeline" ? "w-[100px]" : "w-60"} no-scrollbar overflow-y-auto` - : `overflow-hidden portrait:flex-grow ${timelineType == "timeline" ? "landscape:w-[100px]" : "landscape:w-[175px]"}`, - )} + ? `${timelineType == "timeline" ? "w-[100px]" : timelineType == "activity" ? "w-[30%]" : "w-60"} no-scrollbar overflow-y-auto` + : `overflow-hidden portrait:flex-grow ${timelineType == "timeline" ? "landscape:w-[100px]" : timelineType == "activity" ? "flex-1" : "landscape:w-[175px]"} ` + } relative`} > {isMobile && ( @@ -975,6 +1050,12 @@ function Timeline({ ) : ( ) + ) : timelineType == "activity" ? ( + manuallySetCurrentTime(timestamp, true)} + /> ) : (