-
+
+
+ {enabledCameras.length + disabledCameras.length > 0 && (
+
+ )}
+
{enabledCameras.length > 0 && (
setShowWizard(false)}
/>
+ setShowDeleteDialog(false)}
+ onDeleted={() => {
+ setShowDeleteDialog(false);
+ updateConfig();
+ }}
+ />
setRestartDialogOpen(false)}
From 9cbd80d981f7b9bd5f3dc3d0452ffbd6894fd826 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 9 Mar 2026 15:14:13 -0500
Subject: [PATCH 3/3] Add motion previews filter (#22347)
* add ability to filter motion previews via heatmap grid
* i18n
* use dialog on mobile
---
web/public/locales/en/views/events.json | 5 +-
.../filter/MotionRegionFilterGrid.tsx | 150 ++++++++++++++++++
web/src/views/events/EventView.tsx | 82 +++++++++-
web/src/views/events/MotionPreviewsPane.tsx | 26 ++-
4 files changed, 259 insertions(+), 4 deletions(-)
create mode 100644 web/src/components/filter/MotionRegionFilterGrid.tsx
diff --git a/web/public/locales/en/views/events.json b/web/public/locales/en/views/events.json
index ec0b29116..2efbd2652 100644
--- a/web/public/locales/en/views/events.json
+++ b/web/public/locales/en/views/events.json
@@ -80,6 +80,9 @@
"back": "Back",
"empty": "No previews available",
"noPreview": "Preview unavailable",
- "seekAria": "Seek {{camera}} player to {{time}}"
+ "seekAria": "Seek {{camera}} player to {{time}}",
+ "filter": "Filter",
+ "filterDesc": "Select areas to only show clips with motion in those regions.",
+ "filterClear": "Clear"
}
}
diff --git a/web/src/components/filter/MotionRegionFilterGrid.tsx b/web/src/components/filter/MotionRegionFilterGrid.tsx
new file mode 100644
index 000000000..86457d07b
--- /dev/null
+++ b/web/src/components/filter/MotionRegionFilterGrid.tsx
@@ -0,0 +1,150 @@
+import { baseUrl } from "@/api/baseUrl";
+import { useCallback, useRef } from "react";
+
+const GRID_SIZE = 16;
+
+type MotionRegionFilterGridProps = {
+ cameraName: string;
+ selectedCells: Set;
+ onCellsChange: (cells: Set) => void;
+};
+
+export default function MotionRegionFilterGrid({
+ cameraName,
+ selectedCells,
+ onCellsChange,
+}: MotionRegionFilterGridProps) {
+ const paintingRef = useRef<{ active: boolean; adding: boolean }>({
+ active: false,
+ adding: true,
+ });
+ const lastCellRef = useRef(-1);
+ const gridRef = useRef(null);
+
+ const toggleCell = useCallback(
+ (index: number, forceAdd?: boolean) => {
+ const next = new Set(selectedCells);
+
+ if (forceAdd !== undefined) {
+ if (forceAdd) {
+ next.add(index);
+ } else {
+ next.delete(index);
+ }
+ } else if (next.has(index)) {
+ next.delete(index);
+ } else {
+ next.add(index);
+ }
+
+ onCellsChange(next);
+ },
+ [selectedCells, onCellsChange],
+ );
+
+ const getCellFromPoint = useCallback(
+ (clientX: number, clientY: number): number | null => {
+ const grid = gridRef.current;
+
+ if (!grid) {
+ return null;
+ }
+
+ const rect = grid.getBoundingClientRect();
+ const x = clientX - rect.left;
+ const y = clientY - rect.top;
+
+ if (x < 0 || y < 0 || x >= rect.width || y >= rect.height) {
+ return null;
+ }
+
+ const col = Math.floor((x / rect.width) * GRID_SIZE);
+ const row = Math.floor((y / rect.height) * GRID_SIZE);
+
+ return row * GRID_SIZE + col;
+ },
+ [],
+ );
+
+ const handlePointerDown = useCallback(
+ (e: React.PointerEvent) => {
+ e.preventDefault();
+ const index = getCellFromPoint(e.clientX, e.clientY);
+
+ if (index === null) {
+ return;
+ }
+
+ const adding = !selectedCells.has(index);
+ paintingRef.current = { active: true, adding };
+ lastCellRef.current = index;
+ toggleCell(index, adding);
+ },
+ [selectedCells, toggleCell, getCellFromPoint],
+ );
+
+ const handlePointerMove = useCallback(
+ (e: React.PointerEvent) => {
+ if (!paintingRef.current.active) {
+ return;
+ }
+
+ const index = getCellFromPoint(e.clientX, e.clientY);
+
+ if (index === null || index === lastCellRef.current) {
+ return;
+ }
+
+ lastCellRef.current = index;
+ toggleCell(index, paintingRef.current.adding);
+ },
+ [toggleCell, getCellFromPoint],
+ );
+
+ const handlePointerUp = useCallback(() => {
+ paintingRef.current.active = false;
+ lastCellRef.current = -1;
+ }, []);
+
+ return (
+
+
+

+
+ {Array.from({ length: GRID_SIZE * GRID_SIZE }, (_, index) => {
+ const isSelected = selectedCells.has(index);
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx
index f66f45858..442fb91d4 100644
--- a/web/src/views/events/EventView.tsx
+++ b/web/src/views/events/EventView.tsx
@@ -79,7 +79,17 @@ 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 { FaCog, FaFilter } from "react-icons/fa";
+import MotionRegionFilterGrid from "@/components/filter/MotionRegionFilterGrid";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
import ReviewActivityCalendar from "@/components/overlay/ReviewActivityCalendar";
import PlatformAwareDialog from "@/components/overlay/dialog/PlatformAwareDialog";
import MotionPreviewsPane from "./MotionPreviewsPane";
@@ -1117,6 +1127,13 @@ function MotionReview({
const [controlsOpen, setControlsOpen] = useState(false);
const [dimStrength, setDimStrength] = useState(82);
const [isPreviewSettingsOpen, setIsPreviewSettingsOpen] = useState(false);
+ const [motionFilterCells, setMotionFilterCells] = useState>(
+ new Set(),
+ );
+ const [pendingFilterCells, setPendingFilterCells] = useState>(
+ new Set(),
+ );
+ const [isRegionFilterOpen, setIsRegionFilterOpen] = useState(false);
const objectReviewItems = useMemo(
() =>
@@ -1314,6 +1331,68 @@ function MotionReview({
updateSelectedDay={onUpdateSelectedDay}
/>
)}
+
{
onOpenRecording({
camera: selectedMotionPreviewCamera.name,
diff --git a/web/src/views/events/MotionPreviewsPane.tsx b/web/src/views/events/MotionPreviewsPane.tsx
index fc080c956..7f126f107 100644
--- a/web/src/views/events/MotionPreviewsPane.tsx
+++ b/web/src/views/events/MotionPreviewsPane.tsx
@@ -589,6 +589,7 @@ type MotionPreviewsPaneProps = {
isLoadingMotionRanges?: boolean;
playbackRate: number;
nonMotionAlpha: number;
+ motionFilterCells?: Set;
onSeek: (timestamp: number) => void;
};
@@ -600,6 +601,7 @@ export default function MotionPreviewsPane({
isLoadingMotionRanges = false,
playbackRate,
nonMotionAlpha,
+ motionFilterCells,
onSeek,
}: MotionPreviewsPaneProps) {
const { t } = useTranslation(["views/events"]);
@@ -879,6 +881,26 @@ export default function MotionPreviewsPane({
],
);
+ const filteredClipData = useMemo(() => {
+ if (!motionFilterCells || motionFilterCells.size === 0) {
+ return clipData;
+ }
+
+ return clipData.filter(({ motionHeatmap }) => {
+ if (!motionHeatmap) {
+ return false;
+ }
+
+ for (const cellIndex of motionFilterCells) {
+ if ((motionHeatmap[cellIndex.toString()] ?? 0) > 0) {
+ return true;
+ }
+ }
+
+ return false;
+ });
+ }, [clipData, motionFilterCells]);
+
const hasCurrentHourRanges = useMemo(
() => motionRanges.some((range) => isCurrentHour(range.end_time)),
[motionRanges],
@@ -901,13 +923,13 @@ export default function MotionPreviewsPane({
ref={setContentNode}
className="no-scrollbar min-h-0 flex-1 overflow-y-auto"
>
- {clipData.length === 0 ? (
+ {filteredClipData.length === 0 ? (
{t("motionPreviews.empty")}
) : (
- {clipData.map(
+ {filteredClipData.map(
({ range, preview, fallbackFrameTimes, motionHeatmap }, idx) => {
const clipId = `${camera.name}-${range.start_time}-${range.end_time}-${idx}`;
const isMounted = mountedClips.has(clipId);