new activity stream panel in history view

This commit is contained in:
Josh Hawkins 2025-10-07 06:59:01 -05:00
parent ada15e1686
commit be0b0cfd72

View File

@ -7,6 +7,7 @@ import PreviewPlayer, {
import { DynamicVideoController } from "@/components/player/dynamic/DynamicVideoController"; import { DynamicVideoController } from "@/components/player/dynamic/DynamicVideoController";
import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer"; import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer";
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline"; import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
import ActivityStream from "@/components/timeline/ActivityStream";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useOverlayState } from "@/hooks/use-overlay-state"; import { useOverlayState } from "@/hooks/use-overlay-state";
@ -40,7 +41,11 @@ 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 { TimeRange, TimelineType } from "@/types/timeline"; import {
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";
@ -66,6 +71,7 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { ActivityStreamProvider } from "@/contexts/ActivityStreamContext";
import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip"; import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip";
const DATA_REFRESH_TIME = 600000; // 10 minutes const DATA_REFRESH_TIME = 600000; // 10 minutes
@ -159,6 +165,46 @@ export function RecordingView({
chunkedTimeRange[chunkedTimeRange.length - 1], chunkedTimeRange[chunkedTimeRange.length - 1],
[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>();
@ -521,202 +567,223 @@ export function RecordingView({
); );
return ( return (
<div ref={contentRef} className="flex size-full flex-col pt-2"> <ActivityStreamProvider
<Toaster closeButton={true} /> isActivityMode={timelineType === "activity"}
<div className="relative mb-2 flex h-11 w-full items-center justify-between px-2"> currentTime={currentTime}
{isMobile && ( camera={mainCamera}
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" /> >
)} <div ref={contentRef} className="flex size-full flex-col pt-2">
<div className={cn("flex items-center gap-2")}> <Toaster closeButton={true} />
<Button <div className="relative mb-2 flex h-11 w-full items-center justify-between px-2">
className="flex items-center gap-2.5 rounded-lg" {isMobile && (
aria-label={t("label.back", { ns: "common" })} <Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
size="sm" )}
onClick={() => navigate(-1)} <div className={cn("flex items-center gap-2")}>
> <Button
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" /> className="flex items-center gap-2.5 rounded-lg"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={() => navigate(-1)}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
<Button
className="flex items-center gap-2.5 rounded-lg"
aria-label="Go to the main camera live view"
size="sm"
onClick={() => {
navigate(`/#${mainCamera}`);
}}
>
<FaVideo className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("menu.live.title", { ns: "common" })}
</div>
)}
</Button>
</div>
<div className="flex items-center justify-end gap-2">
<MobileCameraDrawer
allCameras={effectiveCameras}
selected={mainCamera}
onSelectCamera={onSelectCamera}
/>
{isDesktop && ( {isDesktop && (
<div className="text-primary"> <ExportDialog
{t("button.back", { ns: "common" })} camera={mainCamera}
</div> currentTime={currentTime}
latestTime={timeRange.before}
mode={exportMode}
range={exportRange}
showPreview={showExportPreview}
setRange={(range) => {
setExportRange(range);
if (range != undefined) {
mainControllerRef.current?.pause();
}
}}
setMode={setExportMode}
setShowPreview={setShowExportPreview}
/>
)} )}
</Button>
<Button
className="flex items-center gap-2.5 rounded-lg"
aria-label="Go to the main camera live view"
size="sm"
onClick={() => {
navigate(`/#${mainCamera}`);
}}
>
<FaVideo className="size-5 text-secondary-foreground" />
{isDesktop && ( {isDesktop && (
<div className="text-primary"> <ReviewFilterGroup
{t("menu.live.title", { ns: "common" })} filters={["cameras", "date", "general"]}
</div> reviewSummary={reviewSummary}
recordingsSummary={recordingsSummary}
filter={filter}
motionOnly={false}
filterList={reviewFilterList}
showReviewed
setShowReviewed={() => {}}
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={() => {}}
/>
)} )}
</Button> {isDesktop ? (
</div> <ToggleGroup
<div className="flex items-center justify-end gap-2"> className="*:rounded-md *:px-3 *:py-4"
<MobileCameraDrawer type="single"
allCameras={effectiveCameras} size="sm"
selected={mainCamera} value={timelineType}
onSelectCamera={onSelectCamera} onValueChange={(value: TimelineType) =>
/> value ? setTimelineType(value, true) : null
{isDesktop && ( } // don't allow the severity to be unselected
<ExportDialog >
<ToggleGroupItem
className={`${timelineType == "timeline" ? "" : "text-muted-foreground"}`}
value="timeline"
aria-label={t("timeline.aria")}
>
<div className="">{t("timeline")}</div>
</ToggleGroupItem>
<ToggleGroupItem
className={`${timelineType == "events" ? "" : "text-muted-foreground"}`}
value="events"
aria-label={t("events.aria")}
>
<div className="">{t("events.label")}</div>
</ToggleGroupItem>
<ToggleGroupItem
className={`${timelineType == "activity" ? "" : "text-muted-foreground"}`}
value="activity"
aria-label="Activity Stream"
>
<div className="">Activity</div>
</ToggleGroupItem>
</ToggleGroup>
) : (
<MobileTimelineDrawer
selected={timelineType ?? "timeline"}
onSelect={setTimelineType}
/>
)}
<MobileReviewSettingsDrawer
camera={mainCamera} camera={mainCamera}
filter={filter}
currentTime={currentTime} currentTime={currentTime}
latestTime={timeRange.before} latestTime={timeRange.before}
recordingsSummary={recordingsSummary}
mode={exportMode} mode={exportMode}
range={exportRange} range={exportRange}
showPreview={showExportPreview} showExportPreview={showExportPreview}
setRange={(range) => { allLabels={reviewFilterList.labels}
setExportRange(range); allZones={reviewFilterList.zones}
onUpdateFilter={updateFilter}
if (range != undefined) { setRange={setExportRange}
mainControllerRef.current?.pause();
}
}}
setMode={setExportMode} setMode={setExportMode}
setShowPreview={setShowExportPreview} setShowExportPreview={setShowExportPreview}
/> />
)} </div>
{isDesktop && (
<ReviewFilterGroup
filters={["cameras", "date", "general"]}
reviewSummary={reviewSummary}
recordingsSummary={recordingsSummary}
filter={filter}
motionOnly={false}
filterList={reviewFilterList}
showReviewed
setShowReviewed={() => {}}
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 ? (
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={timelineType}
onValueChange={(value: TimelineType) =>
value ? setTimelineType(value, true) : null
} // don't allow the severity to be unselected
>
<ToggleGroupItem
className={`${timelineType == "timeline" ? "" : "text-muted-foreground"}`}
value="timeline"
aria-label={t("timeline.aria")}
>
<div className="">{t("timeline")}</div>
</ToggleGroupItem>
<ToggleGroupItem
className={`${timelineType == "events" ? "" : "text-muted-foreground"}`}
value="events"
aria-label={t("events.aria")}
>
<div className="">{t("events.label")}</div>
</ToggleGroupItem>
</ToggleGroup>
) : (
<MobileTimelineDrawer
selected={timelineType ?? "timeline"}
onSelect={setTimelineType}
/>
)}
<MobileReviewSettingsDrawer
camera={mainCamera}
filter={filter}
currentTime={currentTime}
latestTime={timeRange.before}
recordingsSummary={recordingsSummary}
mode={exportMode}
range={exportRange}
showExportPreview={showExportPreview}
allLabels={reviewFilterList.labels}
allZones={reviewFilterList.zones}
onUpdateFilter={updateFilter}
setRange={setExportRange}
setMode={setExportMode}
setShowExportPreview={setShowExportPreview}
/>
</div> </div>
</div>
<div
ref={mainLayoutRef}
className={cn(
"flex h-full justify-center overflow-hidden",
isDesktop ? "" : "flex-col gap-2 landscape:flex-row",
)}
>
<div <div
ref={cameraLayoutRef} ref={mainLayoutRef}
className={cn("flex flex-1 flex-wrap", isDesktop ? "w-[80%]" : "")} className={cn(
"flex h-full justify-center overflow-hidden",
isDesktop ? "" : "flex-col gap-2 landscape:flex-row",
)}
> >
<div <div
ref={cameraLayoutRef}
className={cn( className={cn(
"flex size-full items-center", "flex flex-1 flex-wrap",
mainCameraAspect == "tall" isDesktop
? "flex-row justify-evenly" ? timelineType === "activity"
: "flex-col justify-center gap-2", ? "w-full"
: "w-[80%]"
: "",
)} )}
> >
<div <div
key={mainCamera}
className={cn( className={cn(
"relative", "flex size-full items-center",
isDesktop timelineType === "activity"
? cn( ? "flex-col"
"flex justify-center px-4", : mainCameraAspect == "tall"
mainCameraAspect == "tall" ? "flex-row justify-evenly"
? "h-[50%] md:h-[60%] lg:h-[75%] xl:h-[90%]" : "flex-col justify-center gap-2",
: mainCameraAspect == "wide"
? "w-full"
: "",
)
: cn(
"pt-2 portrait:w-full",
isMobileOnly &&
(mainCameraAspect == "wide"
? "aspect-wide landscape:w-full"
: "aspect-video landscape:h-[94%] landscape:xl:h-[65%]"),
isTablet &&
(mainCameraAspect == "wide"
? "aspect-wide landscape:w-full"
: mainCameraAspect == "normal"
? "landscape:w-full"
: "aspect-video landscape:h-[100%]"),
),
)} )}
style={{
width: mainCameraStyle ? mainCameraStyle.width : undefined,
aspectRatio: isDesktop
? mainCameraAspect == "tall"
? getCameraAspect(mainCamera)
: undefined
: Math.max(1, getCameraAspect(mainCamera) ?? 0),
}}
> >
{isDesktop && ( <div
key={mainCamera}
className={cn(
"relative",
isDesktop
? cn(
"flex justify-center px-4",
mainCameraAspect == "tall"
? "h-[50%] md:h-[60%] lg:h-[75%] xl:h-[90%]"
: mainCameraAspect == "wide"
? "w-full"
: "",
)
: cn(
"pt-2 portrait:w-full",
isMobileOnly &&
(mainCameraAspect == "wide"
? "aspect-wide landscape:w-full"
: "aspect-video landscape:h-[94%] landscape:xl:h-[65%]"),
isTablet &&
(mainCameraAspect == "wide"
? "aspect-wide landscape:w-full"
: mainCameraAspect == "normal"
? "landscape:w-full"
: "aspect-video landscape:h-[100%]"),
),
)}
style={{
width: mainCameraStyle ? mainCameraStyle.width : undefined,
aspectRatio: isDesktop
? mainCameraAspect == "tall"
? getCameraAspect(mainCamera)
: undefined
: Math.max(1, getCameraAspect(mainCamera) ?? 0),
}}
>
{isDesktop && (
<GenAISummaryDialog <GenAISummaryDialog
review={activeReviewItem} review={activeReviewItem}
onOpen={onAnalysisOpen} onOpen={onAnalysisOpen}
@ -724,106 +791,112 @@ export function RecordingView({
)} )}
<DynamicVideoPlayer <DynamicVideoPlayer
className={grow} className={grow}
camera={mainCamera} camera={mainCamera}
timeRange={currentTimeRange} timeRange={currentTimeRange}
cameraPreviews={allPreviews ?? []} cameraPreviews={allPreviews ?? []}
startTimestamp={playbackStart} startTimestamp={playbackStart}
hotKeys={exportMode != "select"} hotKeys={exportMode != "select"}
fullscreen={fullscreen} fullscreen={fullscreen}
onTimestampUpdate={(timestamp) => { onTimestampUpdate={(timestamp) => {
setPlayerTime(timestamp); setPlayerTime(timestamp);
setCurrentTime(timestamp); setCurrentTime(timestamp);
Object.values(previewRefs.current ?? {}).forEach((prev) => Object.values(previewRefs.current ?? {}).forEach((prev) =>
prev.scrubToTimestamp(Math.floor(timestamp)), prev.scrubToTimestamp(Math.floor(timestamp)),
); );
}} }}
onClipEnded={onClipEnded} onClipEnded={onClipEnded}
onControllerReady={(controller) => { onSeekToTime={manuallySetCurrentTime}
mainControllerRef.current = controller; onControllerReady={(controller) => {
}} mainControllerRef.current = controller;
isScrubbing={scrubbing || exportMode == "timeline"} }}
supportsFullscreen={supportsFullScreen} isScrubbing={scrubbing || exportMode == "timeline"}
setFullResolution={setFullResolution} supportsFullscreen={supportsFullScreen}
toggleFullscreen={toggleFullscreen} setFullResolution={setFullResolution}
containerRef={mainLayoutRef} toggleFullscreen={toggleFullscreen}
/> containerRef={mainLayoutRef}
</div> />
{isDesktop && effectiveCameras.length > 1 && (
<div
ref={previewRowRef}
className={cn(
"scrollbar-container flex gap-2 overflow-auto",
mainCameraAspect == "tall"
? "h-full w-72 flex-col"
: `h-28 w-full`,
previewRowOverflows ? "" : "items-center justify-center",
)}
>
<div className="w-2" />
{effectiveCameras.map((cam) => {
if (cam == mainCamera || cam == "birdseye") {
return;
}
return (
<Tooltip key={cam}>
<TooltipTrigger asChild>
<div
className={
mainCameraAspect == "tall" ? "w-full" : "h-full"
}
style={{
aspectRatio: getCameraAspect(cam),
}}
>
<PreviewPlayer
previewRef={previewRef}
className="size-full"
camera={cam}
timeRange={currentTimeRange}
cameraPreviews={allPreviews ?? []}
startTime={startTime}
isScrubbing={scrubbing}
isVisible={visiblePreviews.includes(cam)}
onControllerReady={(controller) => {
previewRefs.current[cam] = controller;
controller.scrubToTimestamp(startTime);
}}
onClick={() => onSelectCamera(cam)}
/>
</div>
</TooltipTrigger>
<TooltipContent className="smart-capitalize">
<CameraNameLabel camera={cam} />
</TooltipContent>
</Tooltip>
);
})}
<div className="w-2" />
</div> </div>
)} {isDesktop &&
effectiveCameras.length > 1 &&
timelineType !== "activity" && (
<div
ref={previewRowRef}
className={cn(
"scrollbar-container flex gap-2 overflow-auto",
mainCameraAspect == "tall"
? "h-full w-72 flex-col"
: `h-28 w-full`,
previewRowOverflows ? "" : "items-center justify-center",
)}
>
<div className="w-2" />
{effectiveCameras.map((cam) => {
if (cam == mainCamera || cam == "birdseye") {
return;
}
return (
<Tooltip key={cam}>
<TooltipTrigger asChild>
<div
className={
mainCameraAspect == "tall" ? "w-full" : "h-full"
}
style={{
aspectRatio: getCameraAspect(cam),
}}
>
<PreviewPlayer
previewRef={previewRef}
className="size-full"
camera={cam}
timeRange={currentTimeRange}
cameraPreviews={allPreviews ?? []}
startTime={startTime}
isScrubbing={scrubbing}
isVisible={visiblePreviews.includes(cam)}
onControllerReady={(controller) => {
previewRefs.current[cam] = controller;
controller.scrubToTimestamp(startTime);
}}
onClick={() => onSelectCamera(cam)}
/>
</div>
</TooltipTrigger>
<TooltipContent className="smart-capitalize">
<CameraNameLabel camera={cam} />
</TooltipContent>
</Tooltip>
);
})}
<div className="w-2" />
</div>
)}
</div>
</div> </div>
</div> <Timeline
<Timeline contentRef={contentRef}
contentRef={contentRef} mainCamera={mainCamera}
mainCamera={mainCamera} timelineType={
timelineType={ (exportRange == undefined ? timelineType : "timeline") ??
(exportRange == undefined ? timelineType : "timeline") ?? "timeline" "timeline"
} }
timeRange={timeRange} timeRange={timeRange}
mainCameraReviewItems={mainCameraReviewItems} mainCameraReviewItems={mainCameraReviewItems}
activeReviewItem={activeReviewItem} activeReviewItem={activeReviewItem}
currentTime={currentTime} currentTime={currentTime}
exportRange={exportMode == "timeline" ? exportRange : undefined} exportRange={exportMode == "timeline" ? exportRange : undefined}
setCurrentTime={setCurrentTime} setCurrentTime={setCurrentTime}
manuallySetCurrentTime={manuallySetCurrentTime} manuallySetCurrentTime={manuallySetCurrentTime}
setScrubbing={setScrubbing} setScrubbing={setScrubbing}
setExportRange={setExportRange} setExportRange={setExportRange}
onAnalysisOpen={onAnalysisOpen} timelineData={timelineData}
onAnalysisOpen={onAnalysisOpen}
/> />
</div>
</div> </div>
</div> </ActivityStreamProvider>
); );
} }
@ -841,6 +914,7 @@ 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({
@ -857,6 +931,7 @@ function Timeline({
manuallySetCurrentTime, manuallySetCurrentTime,
setScrubbing, setScrubbing,
setExportRange, setExportRange,
timelineData,
onAnalysisOpen, onAnalysisOpen,
}: TimelineProps) { }: TimelineProps) {
const { t } = useTranslation(["views/events"]); const { t } = useTranslation(["views/events"]);
@ -938,9 +1013,9 @@ function Timeline({
className={cn( className={cn(
"relative", "relative",
isDesktop isDesktop
? `${timelineType == "timeline" ? "w-[100px]" : "w-60"} no-scrollbar overflow-y-auto` ? `${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]" : "landscape:w-[175px]"}`, : `overflow-hidden portrait:flex-grow ${timelineType == "timeline" ? "landscape:w-[100px]" : timelineType == "activity" ? "flex-1" : "landscape:w-[175px]"} `
)} } relative`}
> >
{isMobile && ( {isMobile && (
<GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen} /> <GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen} />
@ -975,6 +1050,12 @@ function Timeline({
) : ( ) : (
<Skeleton className="size-full" /> <Skeleton className="size-full" />
) )
) : timelineType == "activity" ? (
<ActivityStream
timelineData={timelineData ?? []}
currentTime={currentTime}
onSeek={(timestamp) => manuallySetCurrentTime(timestamp, true)}
/>
) : ( ) : (
<div className="scrollbar-container h-full overflow-auto bg-secondary"> <div className="scrollbar-container h-full overflow-auto bg-secondary">
<div <div