Improve motion review and add motion search (#22253)

* implement motion search and motion previews

* tweaks

* fix merge issue

* fix copilot instructions
This commit is contained in:
Josh Hawkins
2026-03-05 17:53:48 -06:00
committed by GitHub
parent 229436c94a
commit 2babfd2ec9
28 changed files with 6089 additions and 384 deletions
+601 -224
View File
@@ -1,11 +1,21 @@
import Logo from "@/components/Logo";
import NewReviewData from "@/components/dynamic/NewReviewData";
import CalendarFilterButton from "@/components/filter/CalendarFilterButton";
import ReviewActionGroup from "@/components/filter/ReviewActionGroup";
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { VolumeSlider } from "@/components/ui/slider";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { useScrollLockout } from "@/hooks/use-mouse-listener";
import { FrigateConfig } from "@/types/frigateConfig";
@@ -22,6 +32,7 @@ import {
ZoomLevel,
} from "@/types/review";
import { getChunkedTimeRange } from "@/utils/timelineUtil";
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
import axios from "axios";
import {
MutableRefObject,
@@ -34,9 +45,18 @@ import {
import { isDesktop, isMobile, isMobileOnly } from "react-device-detect";
import { LuFolderCheck, LuFolderX } from "react-icons/lu";
import { MdCircle } from "react-icons/md";
import { FiMoreVertical } from "react-icons/fi";
import { IoMdArrowRoundBack } from "react-icons/io";
import useSWR from "swr";
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
import { Button } from "@/components/ui/button";
import BlurredIconButton from "@/components/button/BlurredIconButton";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import PreviewPlayer, {
PreviewController,
} from "@/components/player/PreviewPlayer";
@@ -44,7 +64,10 @@ import SummaryTimeline from "@/components/timeline/SummaryTimeline";
import { RecordingStartingPoint } from "@/types/record";
import VideoControls from "@/components/player/VideoControls";
import { TimeRange } from "@/types/timeline";
import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity";
import {
useCameraMotionNextTimestamp,
useCameraMotionOnlyRanges,
} from "@/hooks/use-camera-activity";
import useOptimisticState from "@/hooks/use-optimistic-state";
import { Skeleton } from "@/components/ui/skeleton";
import scrollIntoView from "scroll-into-view-if-needed";
@@ -56,6 +79,10 @@ import { GiSoundWaves } from "react-icons/gi";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
import { useTranslation } from "react-i18next";
import { FaCog } from "react-icons/fa";
import ReviewActivityCalendar from "@/components/overlay/ReviewActivityCalendar";
import PlatformAwareDialog from "@/components/overlay/dialog/PlatformAwareDialog";
import MotionPreviewsPane from "./MotionPreviewsPane";
import { EmptyCard } from "@/components/card/EmptyCard";
import { EmptyCardData } from "@/types/card";
@@ -75,6 +102,9 @@ type EventViewProps = {
markItemAsReviewed: (review: ReviewSegment) => void;
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
onOpenRecording: (recordingInfo: RecordingStartingPoint) => void;
motionPreviewsCamera: string | null;
setMotionPreviewsCamera: (camera: string | null) => void;
setMotionSearchCamera: (camera: string) => void;
pullLatestData: () => void;
updateFilter: (filter: ReviewFilter) => void;
};
@@ -94,6 +124,9 @@ export default function EventView({
markItemAsReviewed,
markAllItemsAsReviewed,
onOpenRecording,
motionPreviewsCamera,
setMotionPreviewsCamera,
setMotionSearchCamera,
pullLatestData,
updateFilter,
}: EventViewProps) {
@@ -274,6 +307,15 @@ export default function EventView({
100,
);
const motionPreviewsOpen =
severity === "significant_motion" && motionPreviewsCamera != null;
useEffect(() => {
if (severity !== "significant_motion") {
setMotionPreviewsCamera(null);
}
}, [setMotionPreviewsCamera, severity]);
// review filter info
const reviewFilterList = useMemo<FilterList>(() => {
@@ -301,124 +343,136 @@ export default function EventView({
return (
<div className="flex size-full flex-col pt-2 md:py-2">
<Toaster closeButton={true} />
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:pl-3">
{isMobile && (
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
)}
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={severityToggle}
onValueChange={(value: ReviewSeverity) =>
value ? setSeverityToggle(value) : null
} // don't allow the severity to be unselected
>
<ToggleGroupItem
className={cn(severityToggle != "alert" && "text-muted-foreground")}
value="alert"
aria-label={t("alerts")}
{!motionPreviewsOpen && (
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:pl-3">
{isMobile && (
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
)}
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={severityToggle}
onValueChange={(value: ReviewSeverity) =>
value ? setSeverityToggle(value) : null
} // don't allow the severity to be unselected
>
<div
<ToggleGroupItem
className={cn(
"flex size-6 items-center justify-center rounded text-severity_alert sm:hidden",
severityToggle == "alert" ? "font-semibold" : "font-medium",
severityToggle != "alert" && "text-muted-foreground",
)}
value="alert"
aria-label={t("alerts")}
>
{reviewCounts.alert > -1 ? (
reviewCounts.alert
) : (
<ActivityIndicator className="size-4" />
)}
</div>
<div className="hidden items-center sm:flex">
<MdCircle className="size-2 text-severity_alert md:mr-[10px]" />
<div className="hidden md:flex md:flex-row md:items-center">
{t("alerts")}
<div
className={cn(
"flex size-6 items-center justify-center rounded text-severity_alert sm:hidden",
severityToggle == "alert" ? "font-semibold" : "font-medium",
)}
>
{reviewCounts.alert > -1 ? (
`${reviewCounts.alert}`
reviewCounts.alert
) : (
<ActivityIndicator className="ml-2 size-4" />
<ActivityIndicator className="size-4" />
)}
</div>
</div>
</ToggleGroupItem>
<ToggleGroupItem
className={cn(
severityToggle != "detection" && "text-muted-foreground",
)}
value="detection"
aria-label={t("detections")}
>
<div
<div className="hidden items-center sm:flex">
<MdCircle className="size-2 text-severity_alert md:mr-[10px]" />
<div className="hidden md:flex md:flex-row md:items-center">
{t("alerts")}
{reviewCounts.alert > -1 ? (
`${reviewCounts.alert}`
) : (
<ActivityIndicator className="ml-2 size-4" />
)}
</div>
</div>
</ToggleGroupItem>
<ToggleGroupItem
className={cn(
"flex size-6 items-center justify-center rounded text-severity_detection sm:hidden",
severityToggle == "detection" ? "font-semibold" : "font-medium",
severityToggle != "detection" && "text-muted-foreground",
)}
value="detection"
aria-label={t("detections")}
>
{reviewCounts.detection > -1 ? (
reviewCounts.detection
) : (
<ActivityIndicator className="size-4" />
)}
</div>
<div className="hidden items-center sm:flex">
<MdCircle className="size-2 text-severity_detection md:mr-[10px]" />
<div className="hidden md:flex md:flex-row md:items-center">
{t("detections")}
<div
className={cn(
"flex size-6 items-center justify-center rounded text-severity_detection sm:hidden",
severityToggle == "detection"
? "font-semibold"
: "font-medium",
)}
>
{reviewCounts.detection > -1 ? (
`${reviewCounts.detection}`
reviewCounts.detection
) : (
<ActivityIndicator className="ml-2 size-4" />
<ActivityIndicator className="size-4" />
)}
</div>
</div>
</ToggleGroupItem>
<ToggleGroupItem
className={cn(
"rounded-lg px-3 py-4",
severityToggle != "significant_motion" && "text-muted-foreground",
)}
value="significant_motion"
aria-label={t("motion.label")}
>
<GiSoundWaves className="size-6 rotate-90 text-severity_significant_motion sm:hidden" />
<div className="hidden items-center sm:flex">
<MdCircle className="size-2 text-severity_significant_motion md:mr-[10px]" />
<div className="hidden md:block">{t("motion.label")}</div>
</div>
</ToggleGroupItem>
</ToggleGroup>
<div className="hidden items-center sm:flex">
<MdCircle className="size-2 text-severity_detection md:mr-[10px]" />
<div className="hidden md:flex md:flex-row md:items-center">
{t("detections")}
{reviewCounts.detection > -1 ? (
`${reviewCounts.detection}`
) : (
<ActivityIndicator className="ml-2 size-4" />
)}
</div>
</div>
</ToggleGroupItem>
<ToggleGroupItem
className={cn(
"rounded-lg px-3 py-4",
severityToggle != "significant_motion" &&
"text-muted-foreground",
)}
value="significant_motion"
aria-label={t("motion.label")}
>
<GiSoundWaves className="size-6 rotate-90 text-severity_significant_motion sm:hidden" />
<div className="hidden items-center sm:flex">
<MdCircle className="size-2 text-severity_significant_motion md:mr-[10px]" />
<div className="hidden md:block">{t("motion.label")}</div>
</div>
</ToggleGroupItem>
</ToggleGroup>
{selectedReviews.length <= 0 ? (
<ReviewFilterGroup
filters={
severity == "significant_motion"
? ["cameras", "date", "motionOnly"]
: ["cameras", "reviewed", "date", "general"]
}
currentSeverity={severityToggle}
reviewSummary={reviewSummary}
recordingsSummary={recordingsSummary}
filter={filter}
motionOnly={motionOnly}
filterList={reviewFilterList}
showReviewed={showReviewed}
setShowReviewed={setShowReviewed}
onUpdateFilter={updateFilter}
setMotionOnly={setMotionOnly}
/>
) : (
<ReviewActionGroup
selectedReviews={selectedReviews}
setSelectedReviews={setSelectedReviews}
onExport={exportReview}
pullLatestData={pullLatestData}
/>
{selectedReviews.length <= 0 ? (
<ReviewFilterGroup
filters={
severity == "significant_motion"
? ["cameras", "date", "motionOnly"]
: ["cameras", "reviewed", "date", "general"]
}
currentSeverity={severityToggle}
reviewSummary={reviewSummary}
recordingsSummary={recordingsSummary}
filter={filter}
motionOnly={motionOnly}
filterList={reviewFilterList}
showReviewed={showReviewed}
setShowReviewed={setShowReviewed}
onUpdateFilter={updateFilter}
setMotionOnly={setMotionOnly}
/>
) : (
<ReviewActionGroup
selectedReviews={selectedReviews}
setSelectedReviews={setSelectedReviews}
onExport={exportReview}
pullLatestData={pullLatestData}
/>
)}
</div>
)}
<div
className={cn(
"h-full min-h-0 overflow-hidden",
motionPreviewsOpen ? "flex flex-col" : "flex",
)}
</div>
<div className="flex h-full overflow-hidden">
>
{severity != "significant_motion" && (
<DetectionReview
contentRef={contentRef}
@@ -447,10 +501,16 @@ export default function EventView({
contentRef={contentRef}
reviewItems={reviewItems}
relevantPreviews={relevantPreviews}
reviewSummary={reviewSummary}
recordingsSummary={recordingsSummary}
timeRange={timeRange}
startTime={startTime}
filter={filter}
motionOnly={motionOnly}
updateFilter={updateFilter}
motionPreviewsCamera={motionPreviewsCamera}
setMotionPreviewsCamera={setMotionPreviewsCamera}
setMotionSearchCamera={setMotionSearchCamera}
emptyCardData={emptyCardData}
onOpenRecording={onOpenRecording}
/>
@@ -898,10 +958,16 @@ type MotionReviewProps = {
significant_motion: ReviewSegment[];
};
relevantPreviews?: Preview[];
reviewSummary?: ReviewSummary;
recordingsSummary?: RecordingsSummary;
timeRange: TimeRange;
startTime?: number;
filter?: ReviewFilter;
motionOnly?: boolean;
updateFilter: (filter: ReviewFilter) => void;
motionPreviewsCamera: string | null;
setMotionPreviewsCamera: (camera: string | null) => void;
setMotionSearchCamera: (camera: string) => void;
emptyCardData: EmptyCardData;
onOpenRecording: (data: RecordingStartingPoint) => void;
};
@@ -909,13 +975,20 @@ function MotionReview({
contentRef,
reviewItems,
relevantPreviews,
reviewSummary,
recordingsSummary,
timeRange,
startTime,
filter,
motionOnly = false,
updateFilter,
motionPreviewsCamera,
setMotionPreviewsCamera,
setMotionSearchCamera,
emptyCardData,
onOpenRecording,
}: MotionReviewProps) {
const { t } = useTranslation(["views/events", "common"]);
const segmentDuration = 30;
const { data: config } = useSWR<FrigateConfig>("config");
@@ -961,6 +1034,15 @@ function MotionReview({
},
]);
const { data: overlapReviewSegments } = useSWR<ReviewSegment[]>([
"review",
{
before: alignedBefore,
after: alignedAfter,
cameras: filter?.cameras?.join(",") ?? null,
},
]);
// timeline time
const timeRangeSegments = useMemo(
@@ -973,19 +1055,29 @@ function MotionReview({
return timeRangeSegments.ranges.length - 1;
}
return timeRangeSegments.ranges.findIndex(
const index = timeRangeSegments.ranges.findIndex(
(seg) => seg.after <= startTime && seg.before >= startTime,
);
if (index === -1) {
return timeRangeSegments.ranges.length - 1;
}
return index;
// only render once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [selectedRangeIdx, setSelectedRangeIdx] = useState(initialIndex);
const [currentTime, setCurrentTime] = useState<number>(
startTime ?? timeRangeSegments.ranges[selectedRangeIdx]?.before,
startTime ??
timeRangeSegments.ranges[selectedRangeIdx]?.before ??
timeRangeSegments.end,
);
const currentTimeRange = useMemo(
() => timeRangeSegments.ranges[selectedRangeIdx],
() =>
timeRangeSegments.ranges[selectedRangeIdx] ??
timeRangeSegments.ranges[timeRangeSegments.ranges.length - 1],
[selectedRangeIdx, timeRangeSegments],
);
@@ -1023,18 +1115,86 @@ function MotionReview({
const [playbackRate, setPlaybackRate] = useState(8);
const [controlsOpen, setControlsOpen] = useState(false);
const [dimStrength, setDimStrength] = useState(82);
const [isPreviewSettingsOpen, setIsPreviewSettingsOpen] = useState(false);
const objectReviewItems = useMemo(
() =>
(overlapReviewSegments ?? []).filter(
(item) =>
item.severity === "alert" ||
item.severity === "detection" ||
(item.data.detections?.length ?? 0) > 0 ||
(item.data.objects?.length ?? 0) > 0,
),
[overlapReviewSegments],
);
const nextTimestamp = useCameraMotionNextTimestamp(
timeRangeSegments.end,
segmentDuration,
motionOnly,
reviewItems?.all ?? [],
objectReviewItems,
motionData ?? [],
currentTime,
);
const timeoutIdRef = useRef<NodeJS.Timeout | null>(null);
const selectedMotionPreviewCamera = useMemo(
() =>
reviewCameras.find((camera) => camera.name === motionPreviewsCamera) ??
null,
[motionPreviewsCamera, reviewCameras],
);
const onUpdateSelectedDay = useCallback(
(day?: Date) => {
updateFilter({
...filter,
after: day == undefined ? undefined : day.getTime() / 1000,
before: day == undefined ? undefined : getEndOfDayTimestamp(day),
});
},
[filter, updateFilter],
);
const selectedCameraMotionData = useMemo(() => {
if (!motionPreviewsCamera) {
return [];
}
return (motionData ?? []).filter((item) => {
const cameras = item.camera.split(",").map((camera) => camera.trim());
return cameras.includes(motionPreviewsCamera);
});
}, [motionData, motionPreviewsCamera]);
const selectedCameraReviewItems = useMemo(() => {
if (!motionPreviewsCamera) {
return [];
}
return objectReviewItems.filter(
(item) => item.camera === motionPreviewsCamera,
);
}, [motionPreviewsCamera, objectReviewItems]);
const motionPreviewRanges = useCameraMotionOnlyRanges(
segmentDuration,
selectedCameraReviewItems,
selectedCameraMotionData,
);
useEffect(() => {
if (
motionPreviewsCamera &&
!reviewCameras.some((camera) => camera.name === motionPreviewsCamera)
) {
setMotionPreviewsCamera(null);
}
}, [motionPreviewsCamera, reviewCameras, setMotionPreviewsCamera]);
useEffect(() => {
if (nextTimestamp) {
if (!playing && timeoutIdRef.current != null) {
@@ -1124,132 +1284,349 @@ function MotionReview({
return (
<>
<div className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4">
<div
ref={contentRef}
className={cn(
"no-scrollbar grid w-full grid-cols-1",
isMobile && "landscape:grid-cols-2",
reviewCameras.length > 3 &&
isMobile &&
"portrait:md:grid-cols-2 landscape:md:grid-cols-3",
isDesktop && "grid-cols-2 lg:grid-cols-3",
"gap-2 overflow-auto px-1 md:mx-2 md:gap-4 xl:grid-cols-3 3xl:grid-cols-4",
)}
>
{reviewCameras.map((camera) => {
let grow;
let spans;
const aspectRatio = camera.detect.width / camera.detect.height;
if (aspectRatio > 2) {
grow = "aspect-wide";
spans = "sm:col-span-2";
} else if (aspectRatio < 1) {
grow = "h-full aspect-tall";
spans = "md:row-span-2";
} else {
grow = "aspect-video";
}
const detectionType = getDetectionType(camera.name);
return (
<div key={camera.name} className={`relative ${spans}`}>
{motionData ? (
<>
<PreviewPlayer
className={`rounded-lg md:rounded-2xl ${spans} ${grow}`}
camera={camera.name}
timeRange={currentTimeRange}
startTime={previewStart}
cameraPreviews={relevantPreviews}
isScrubbing={scrubbing}
onControllerReady={(controller) => {
videoPlayersRef.current[camera.name] = controller;
}}
onClick={() =>
onOpenRecording({
camera: camera.name,
startTime: Math.min(
currentTime,
Date.now() / 1000 - 30,
),
severity: "significant_motion",
})
}
/>
<div
className={`review-item-ring pointer-events-none absolute inset-0 z-20 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${detectionType ? `outline-severity_${detectionType} shadow-severity_${detectionType}` : "outline-transparent duration-500"}`}
/>
</>
) : (
<Skeleton
className={`size-full rounded-lg md:rounded-2xl ${spans} ${grow}`}
/>
{motionPreviewsCamera && selectedMotionPreviewCamera ? (
<>
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:px-3">
<Button
className="flex items-center gap-2.5 rounded-lg"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={() => setMotionPreviewsCamera(null)}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
<div className="flex items-center gap-2">
{isDesktop && (
<CalendarFilterButton
reviewSummary={reviewSummary}
recordingsSummary={recordingsSummary}
day={
filter?.after == undefined
? undefined
: new Date(filter.after * 1000)
}
updateSelectedDay={onUpdateSelectedDay}
/>
)}
<PlatformAwareDialog
trigger={
<Button
className={cn(
isDesktop ? "flex items-center gap-2" : "rounded-lg",
)}
size="sm"
aria-label={
isDesktop
? t("motionPreviews.mobileSettingsTitle")
: t("filters", { ns: "views/recording" })
}
>
<FaCog className="text-secondary-foreground" />
{isDesktop && t("motionPreviews.mobileSettingsTitle")}
</Button>
}
content={
<div className="space-y-4 py-2">
{!isDesktop && (
<div className="space-y-1">
<div className="text-md">
{t("motionPreviews.mobileSettingsTitle")}
</div>
<div className="text-xs text-muted-foreground">
{t("motionPreviews.mobileSettingsDesc")}
</div>
</div>
)}
<div className="space-y-3">
<div className="space-y-0.5">
<div className="text-md">
{t("motionPreviews.speed")}
</div>
<div className="text-xs text-muted-foreground">
{t("motionPreviews.speedDesc")}
</div>
</div>
<Select
value={String(playbackRate)}
onValueChange={(value) =>
setPlaybackRate(Number(value))
}
>
<SelectTrigger
className="h-10 w-full"
aria-label={t("motionPreviews.speedAria")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[4, 8, 12, 16].map((speed) => (
<SelectItem key={speed} value={String(speed)}>
{speed}x
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="space-y-0.5">
<div className="text-md">{t("motionPreviews.dim")}</div>
<div className="text-xs text-muted-foreground">
{t("motionPreviews.dimDesc")}
</div>
</div>
<div className="px-1 py-2">
<VolumeSlider
className="w-full"
min={25}
max={95}
step={1}
value={[dimStrength]}
aria-label={t("motionPreviews.dimAria")}
onValueChange={(values) => {
const nextValue = values[0];
if (nextValue == undefined) {
return;
}
setDimStrength(nextValue);
}}
/>
</div>
</div>
{!isDesktop && (
<>
<SelectSeparator />
<div className="flex w-full flex-row justify-center">
<ReviewActivityCalendar
recordingsSummary={recordingsSummary}
selectedDay={
filter?.after == undefined
? undefined
: new Date(filter.after * 1000)
}
onSelect={(day) => {
onUpdateSelectedDay(day);
setIsPreviewSettingsOpen(false);
}}
/>
</div>
<div className="flex items-center justify-center p-2">
<Button
aria-label={t("button.reset", { ns: "common" })}
onClick={() => {
onUpdateSelectedDay(undefined);
setIsPreviewSettingsOpen(false);
}}
>
{t("button.reset", { ns: "common" })}
</Button>
</div>
</>
)}
</div>
}
contentClassName={cn(
isDesktop
? "w-80"
: "scrollbar-container max-h-[75dvh] overflow-y-auto overflow-x-hidden px-4",
)}
</div>
);
})}
</div>
</div>
<div className="no-scrollbar w-[55px] overflow-y-auto md:w-[100px]">
{motionData ? (
<MotionReviewTimeline
segmentDuration={segmentDuration}
timestampSpread={15}
timelineStart={timeRangeSegments.end}
timelineEnd={timeRangeSegments.start}
motionOnly={motionOnly}
showHandlebar
handlebarTime={currentTime}
setHandlebarTime={setCurrentTime}
events={reviewItems?.all ?? []}
motion_events={motionData ?? []}
open={isPreviewSettingsOpen}
onOpenChange={setIsPreviewSettingsOpen}
/>
</div>
</div>
<MotionPreviewsPane
camera={selectedMotionPreviewCamera}
contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => {
if (playing && scrubbing) {
setPlaying(false);
}
setScrubbing(scrubbing);
cameraPreviews={relevantPreviews}
motionRanges={motionPreviewRanges}
isLoadingMotionRanges={
motionData == undefined || overlapReviewSegments == undefined
}
playbackRate={playbackRate}
nonMotionAlpha={dimStrength / 100}
onSeek={(timestamp) => {
onOpenRecording({
camera: selectedMotionPreviewCamera.name,
startTime: timestamp,
severity: "significant_motion",
});
}}
dense={isMobileOnly}
isZooming={false}
zoomDirection={null}
alwaysShowMotionLine={true}
/>
) : (
<Skeleton className="size-full" />
)}
</div>
</>
) : (
<div className="no-scrollbar flex min-w-0 flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4">
<div
ref={contentRef}
className={cn(
"no-scrollbar grid w-full grid-cols-1",
isMobile && "landscape:grid-cols-2",
reviewCameras.length > 3 &&
isMobile &&
"portrait:md:grid-cols-2 landscape:md:grid-cols-3",
isDesktop && "grid-cols-2 lg:grid-cols-3",
"gap-2 overflow-auto px-1 md:mx-2 md:gap-4 xl:grid-cols-3 3xl:grid-cols-4",
)}
>
{reviewCameras.map((camera) => {
let grow;
let spans;
const aspectRatio = camera.detect.width / camera.detect.height;
if (aspectRatio > 2) {
grow = "aspect-wide";
spans = "sm:col-span-2";
} else if (aspectRatio < 1) {
grow = "h-full aspect-tall";
spans = "md:row-span-2";
} else {
grow = "aspect-video";
}
const detectionType = getDetectionType(camera.name);
return (
<div key={camera.name} className={`relative ${spans}`}>
{motionData ? (
<>
<PreviewPlayer
className={`rounded-lg md:rounded-2xl ${spans} ${grow}`}
camera={camera.name}
timeRange={currentTimeRange}
startTime={previewStart}
cameraPreviews={relevantPreviews}
isScrubbing={scrubbing}
onControllerReady={(controller) => {
videoPlayersRef.current[camera.name] = controller;
}}
onClick={() =>
onOpenRecording({
camera: camera.name,
startTime: Math.min(
currentTime,
Date.now() / 1000 - 30,
),
severity: "significant_motion",
})
}
/>
<div
className={`review-item-ring pointer-events-none absolute inset-0 z-20 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${detectionType ? `outline-severity_${detectionType} shadow-severity_${detectionType}` : "outline-transparent duration-500"}`}
/>
<div className="absolute bottom-2 right-2 z-30">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<BlurredIconButton
aria-label={t("motionSearch.openMenu")}
onClick={(e) => e.stopPropagation()}
>
<FiMoreVertical className="size-5" />
</BlurredIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setMotionPreviewsCamera(camera.name);
}}
>
{t("motionPreviews.menuItem")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setMotionSearchCamera(camera.name);
}}
>
{t("motionSearch.menuItem")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
) : (
<Skeleton
className={`size-full rounded-lg md:rounded-2xl ${spans} ${grow}`}
/>
)}
</div>
);
})}
</div>
</div>
)}
{!selectedMotionPreviewCamera && (
<div className="no-scrollbar w-[55px] overflow-y-auto md:w-[100px]">
{motionData ? (
<MotionReviewTimeline
segmentDuration={segmentDuration}
timestampSpread={15}
timelineStart={timeRangeSegments.end}
timelineEnd={timeRangeSegments.start}
motionOnly={motionOnly}
showHandlebar
handlebarTime={currentTime}
setHandlebarTime={setCurrentTime}
events={reviewItems?.all ?? []}
motion_events={motionData ?? []}
contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => {
if (playing && scrubbing) {
setPlaying(false);
}
<VideoControls
className="absolute bottom-16 left-1/2 -translate-x-1/2 bg-secondary"
features={{
volume: false,
seek: true,
playbackRate: true,
fullscreen: false,
}}
isPlaying={playing}
show={!scrubbing || controlsOpen}
playbackRates={[4, 8, 12, 16]}
playbackRate={playbackRate}
setControlsOpen={setControlsOpen}
onPlayPause={setPlaying}
onSeek={(diff) => {
const wasPlaying = playing;
setScrubbing(scrubbing);
}}
dense={isMobileOnly}
isZooming={false}
zoomDirection={null}
alwaysShowMotionLine={true}
/>
) : (
<Skeleton className="size-full" />
)}
</div>
)}
if (wasPlaying) {
setPlaying(false);
}
{!selectedMotionPreviewCamera && (
<VideoControls
className="absolute bottom-16 left-1/2 -translate-x-1/2 bg-secondary"
features={{
volume: false,
seek: true,
playbackRate: true,
fullscreen: false,
}}
isPlaying={playing}
show={!scrubbing || controlsOpen}
playbackRates={[4, 8, 12, 16]}
playbackRate={playbackRate}
setControlsOpen={setControlsOpen}
onPlayPause={setPlaying}
onSeek={(diff) => {
const wasPlaying = playing;
setCurrentTime(currentTime + diff);
if (wasPlaying) {
setPlaying(false);
}
if (wasPlaying) {
setTimeout(() => setPlaying(true), 100);
}
}}
onSetPlaybackRate={setPlaybackRate}
/>
setCurrentTime(currentTime + diff);
if (wasPlaying) {
setTimeout(() => setPlaying(true), 100);
}
}}
onSetPlaybackRate={setPlaybackRate}
/>
)}
</>
);
}