frigate/web/src/views/motion-search/MotionSearchView.tsx
Josh Hawkins 2babfd2ec9
Improve motion review and add motion search (#22253)
* implement motion search and motion previews

* tweaks

* fix merge issue

* fix copilot instructions
2026-03-05 17:53:48 -06:00

1492 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import axios from "axios";
import { isDesktop, isMobile } from "react-device-detect";
import Logo from "@/components/Logo";
import { FrigateConfig } from "@/types/frigateConfig";
import { TimeRange } from "@/types/timeline";
import { RecordingsSummary } from "@/types/review";
import { ExportMode } from "@/types/filter";
import {
MotionSearchRequest,
MotionSearchStartResponse,
MotionSearchStatusResponse,
MotionSearchResult,
MotionSearchMetrics,
} from "@/types/motionSearch";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Progress } from "@/components/ui/progress";
import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer";
import { DynamicVideoController } from "@/components/player/dynamic/DynamicVideoController";
import { DetailStreamProvider } from "@/context/detail-stream-context";
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
import CalendarFilterButton from "@/components/filter/CalendarFilterButton";
import ExportDialog from "@/components/overlay/ExportDialog";
import SaveExportOverlay from "@/components/overlay/SaveExportOverlay";
import ReviewActivityCalendar from "@/components/overlay/ReviewActivityCalendar";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import { SelectSeparator } from "@/components/ui/select";
import { useResizeObserver } from "@/hooks/resize-observer";
import { useFullscreen } from "@/hooks/use-fullscreen";
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { getChunkedTimeDay } from "@/utils/timelineUtil";
import { MotionData, ZoomLevel } from "@/types/review";
import {
ASPECT_VERTICAL_LAYOUT,
ASPECT_WIDE_LAYOUT,
Recording,
RecordingSegment,
} from "@/types/record";
import { VideoResolutionType } from "@/types/live";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import MotionSearchROICanvas from "./MotionSearchROICanvas";
import MotionSearchDialog from "./MotionSearchDialog";
import { IoMdArrowRoundBack } from "react-icons/io";
import { FaArrowDown, FaCalendarAlt, FaCog, FaFire } from "react-icons/fa";
import { useNavigate } from "react-router-dom";
import { LuSearch } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
type MotionSearchViewProps = {
config: FrigateConfig;
cameras: string[];
selectedCamera: string | null;
onCameraSelect: (camera: string) => void;
cameraLocked?: boolean;
selectedDay: Date | undefined;
onDaySelect: (day: Date | undefined) => void;
timeRange: TimeRange;
timezone: string | undefined;
onBack?: () => void;
};
const DEFAULT_EXPORT_WINDOW_SECONDS = 60;
export default function MotionSearchView({
config,
cameras,
selectedCamera,
onCameraSelect,
cameraLocked = false,
selectedDay,
onDaySelect,
timeRange,
timezone,
onBack,
}: MotionSearchViewProps) {
const { t } = useTranslation([
"views/motionSearch",
"common",
"views/recording",
]);
const navigate = useNavigate();
const resultTimestampFormat = useMemo(
() =>
config.ui?.time_format === "24hour"
? t("time.formattedTimestamp.24hour", { ns: "common" })
: t("time.formattedTimestamp.12hour", { ns: "common" }),
[config.ui?.time_format, t],
);
// Refs
const contentRef = useRef<HTMLDivElement | null>(null);
const mainLayoutRef = useRef<HTMLDivElement | null>(null);
const timelineRef = useRef<HTMLDivElement | null>(null);
const mainControllerRef = useRef<DynamicVideoController | null>(null);
const jobIdRef = useRef<string | null>(null);
const jobCameraRef = useRef<string | null>(null);
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(true);
const [isMobileSettingsOpen, setIsMobileSettingsOpen] = useState(false);
const [mobileSettingsMode, setMobileSettingsMode] = useState<
"actions" | "calendar"
>("actions");
// Recordings summary for calendar defer until dialog is closed
// so the preview image in the dialog loads without competing requests
const { data: recordingsSummary } = useSWR<RecordingsSummary>(
selectedCamera && !isSearchDialogOpen
? [
"recordings/summary",
{
timezone: timezone,
cameras: selectedCamera,
},
]
: null,
);
// Camera previews defer until dialog is closed
const allPreviews = useCameraPreviews(
isSearchDialogOpen ? { after: 0, before: 0 } : timeRange,
{
camera: selectedCamera ?? undefined,
},
);
// ROI state
const [polygonPoints, setPolygonPoints] = useState<number[][]>([]);
const [isDrawingROI, setIsDrawingROI] = useState(true);
// Search settings
const [parallelMode, setParallelMode] = useState(false);
const [threshold, setThreshold] = useState(30);
const [minArea, setMinArea] = useState(20);
const [frameSkip, setFrameSkip] = useState(10);
const [maxResults, setMaxResults] = useState(25);
// Job state
const [jobId, setJobId] = useState<string | null>(null);
const [jobCamera, setJobCamera] = useState<string | null>(null);
// Job polling with SWR
const { data: jobStatus } = useSWR<MotionSearchStatusResponse>(
jobId && jobCamera ? [`${jobCamera}/search/motion/${jobId}`] : null,
{ refreshInterval: 1000 },
);
// Search state
const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState<MotionSearchResult[]>([]);
const [showSegmentHeatmap, setShowSegmentHeatmap] = useState(false);
const [searchMetrics, setSearchMetrics] =
useState<MotionSearchMetrics | null>(null);
const [hasSearched, setHasSearched] = useState(false);
const [searchRange, setSearchRange] = useState<TimeRange | undefined>(
undefined,
);
const [pendingSeekTime, setPendingSeekTime] = useState<number | null>(null);
// Export state
const [exportMode, setExportMode] = useState<ExportMode>("none");
const [exportRange, setExportRange] = useState<TimeRange>();
const [showExportPreview, setShowExportPreview] = useState(false);
// Timeline state
const initialStartTime = timeRange.before - 60;
const [scrubbing, setScrubbing] = useState(false);
const [currentTime, setCurrentTime] = useState<number>(initialStartTime);
const [playerTime, setPlayerTime] = useState<number>(initialStartTime);
const [playbackStart, setPlaybackStart] = useState(initialStartTime);
const chunkedTimeRange = useMemo(
() => getChunkedTimeDay(timeRange),
[timeRange],
);
const [selectedRangeIdx, setSelectedRangeIdx] = useState(() => {
const ranges = getChunkedTimeDay(timeRange);
const index = ranges.findIndex(
(chunk) =>
chunk.after <= initialStartTime && chunk.before >= initialStartTime,
);
return index === -1 ? ranges.length - 1 : index;
});
const currentTimeRange = useMemo<TimeRange>(
() =>
chunkedTimeRange[selectedRangeIdx] ??
chunkedTimeRange[chunkedTimeRange.length - 1],
[selectedRangeIdx, chunkedTimeRange],
);
const clampExportTime = useCallback(
(value: number) =>
Math.min(timeRange.before, Math.max(timeRange.after, value)),
[timeRange.after, timeRange.before],
);
const buildDefaultExportRange = useCallback(
(anchorTime: number): TimeRange => {
const halfWindow = DEFAULT_EXPORT_WINDOW_SECONDS / 2;
let after = clampExportTime(anchorTime - halfWindow);
let before = clampExportTime(anchorTime + halfWindow);
if (before <= after) {
before = clampExportTime(timeRange.before);
after = clampExportTime(before - DEFAULT_EXPORT_WINDOW_SECONDS);
}
return { after, before };
},
[clampExportTime, timeRange.before],
);
const setExportStartTime = useCallback<
React.Dispatch<React.SetStateAction<number>>
>(
(value) => {
setExportRange((prev) => {
const resolvedValue =
typeof value === "function"
? value(prev?.after ?? currentTime)
: value;
const after = clampExportTime(resolvedValue);
const before = Math.max(
after,
clampExportTime(
prev?.before ?? after + DEFAULT_EXPORT_WINDOW_SECONDS,
),
);
return { after, before };
});
},
[clampExportTime, currentTime],
);
const setExportEndTime = useCallback<
React.Dispatch<React.SetStateAction<number>>
>(
(value) => {
setExportRange((prev) => {
const resolvedValue =
typeof value === "function"
? value(prev?.before ?? currentTime)
: value;
const before = clampExportTime(resolvedValue);
const after = Math.min(
before,
clampExportTime(
prev?.after ?? before - DEFAULT_EXPORT_WINDOW_SECONDS,
),
);
return { after, before };
});
},
[clampExportTime, currentTime],
);
useEffect(() => {
if (exportMode !== "timeline" || exportRange) {
return;
}
setExportRange(buildDefaultExportRange(currentTime));
}, [exportMode, exportRange, buildDefaultExportRange, currentTime]);
const handleExportPreview = useCallback(() => {
if (!exportRange) {
toast.error(
t("export.toast.error.noVaildTimeSelected", {
ns: "components/dialog",
}),
{
position: "top-center",
},
);
return;
}
setShowExportPreview(true);
}, [exportRange, setShowExportPreview, t]);
const handleExportCancel = useCallback(() => {
setShowExportPreview(false);
setExportRange(undefined);
setExportMode("none");
}, [setExportMode, setExportRange, setShowExportPreview]);
const setExportRangeWithPause = useCallback(
(range: TimeRange | undefined) => {
setExportRange(range);
if (range != undefined) {
mainControllerRef.current?.pause();
}
},
[setExportRange],
);
const openMobileExport = useCallback(() => {
const now = new Date(timeRange.before * 1000);
now.setHours(now.getHours() - 1);
setExportRangeWithPause({
before: timeRange.before,
after: now.getTime() / 1000,
});
setExportMode("select");
setIsMobileSettingsOpen(false);
setMobileSettingsMode("actions");
}, [setExportRangeWithPause, timeRange.before]);
const handleExportSave = useCallback(() => {
if (!exportRange || !selectedCamera) {
toast.error(
t("export.toast.error.noVaildTimeSelected", {
ns: "components/dialog",
}),
{
position: "top-center",
},
);
return;
}
if (exportRange.before < exportRange.after) {
toast.error(
t("export.toast.error.endTimeMustAfterStartTime", {
ns: "components/dialog",
}),
{ position: "top-center" },
);
return;
}
axios
.post(
`export/${selectedCamera}/start/${Math.round(exportRange.after)}/end/${Math.round(exportRange.before)}`,
{
playback: "realtime",
},
)
.then((response) => {
if (response.status == 200) {
toast.success(
t("export.toast.success", { ns: "components/dialog" }),
{
position: "top-center",
action: (
<a href="/export" target="_blank" rel="noopener noreferrer">
<Button>
{t("export.toast.view", { ns: "components/dialog" })}
</Button>
</a>
),
},
);
setShowExportPreview(false);
setExportRange(undefined);
setExportMode("none");
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("export.toast.error.failed", {
ns: "components/dialog",
error: errorMessage,
}),
{ position: "top-center" },
);
});
}, [
exportRange,
selectedCamera,
setExportMode,
setExportRange,
setShowExportPreview,
t,
]);
useEffect(() => {
if (!searchRange) {
setSearchRange(timeRange);
}
}, [searchRange, timeRange]);
// Video player state
const [fullResolution, setFullResolution] = useState<VideoResolutionType>({
width: 0,
height: 0,
});
// Fullscreen
const { fullscreen, toggleFullscreen, supportsFullScreen } =
useFullscreen(mainLayoutRef);
// Timeline zoom settings
const [zoomSettings, setZoomSettings] = useState({
segmentDuration: 30,
timestampSpread: 15,
});
const possibleZoomLevels: ZoomLevel[] = useMemo(
() => [
{ segmentDuration: 30, timestampSpread: 15 },
{ segmentDuration: 15, timestampSpread: 5 },
{ segmentDuration: 5, timestampSpread: 1 },
],
[],
);
const handleZoomChange = useCallback(
(newZoomLevel: number) => {
setZoomSettings(possibleZoomLevels[newZoomLevel]);
},
[possibleZoomLevels],
);
const currentZoomLevel = useMemo(
() =>
possibleZoomLevels.findIndex(
(level) => level.segmentDuration === zoomSettings.segmentDuration,
),
[possibleZoomLevels, zoomSettings.segmentDuration],
);
const { isZooming, zoomDirection } = useTimelineZoom({
zoomSettings,
zoomLevels: possibleZoomLevels,
onZoomChange: handleZoomChange,
timelineRef: timelineRef,
timelineDuration: timeRange.after - timeRange.before,
});
// Motion data for timeline
const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils(
{ segmentDuration: zoomSettings.segmentDuration },
);
const alignedAfter = alignStartDateToTimeline(timeRange.after);
const alignedBefore = alignEndDateToTimeline(timeRange.before);
const { data: motionData, isLoading: isMotionLoading } = useSWR<MotionData[]>(
selectedCamera && !isSearchDialogOpen
? [
"review/activity/motion",
{
before: alignedBefore,
after: alignedAfter,
scale: Math.round(zoomSettings.segmentDuration / 2),
cameras: selectedCamera,
},
]
: null,
);
const { data: noRecordings } = useSWR<RecordingSegment[]>(
selectedCamera && !isSearchDialogOpen
? [
"recordings/unavailable",
{
before: alignedBefore,
after: alignedAfter,
scale: Math.round(zoomSettings.segmentDuration),
cameras: selectedCamera,
},
]
: null,
);
const recordingParams = useMemo(
() => ({
before: currentTimeRange.before,
after: currentTimeRange.after,
}),
[currentTimeRange],
);
const { data: playbackRecordings } = useSWR<Recording[]>(
selectedCamera && !isSearchDialogOpen
? [`${selectedCamera}/recordings`, recordingParams]
: null,
{ revalidateOnFocus: false },
);
const activeSegmentHeatmap = useMemo(() => {
if (!showSegmentHeatmap || !playbackRecordings?.length) {
return null;
}
const activeSegment = playbackRecordings.find(
(recording) =>
recording.start_time <= currentTime &&
recording.end_time >= currentTime,
);
return activeSegment?.motion_heatmap ?? null;
}, [currentTime, playbackRecordings, showSegmentHeatmap]);
// Camera aspect ratio
const getCameraAspect = useCallback(
(cam: string) => {
if (!config) return undefined;
if (
cam === selectedCamera &&
fullResolution.width &&
fullResolution.height
) {
return fullResolution.width / fullResolution.height;
}
const camera = config.cameras[cam];
if (!camera) return undefined;
return camera.detect.width / camera.detect.height;
},
[config, fullResolution, selectedCamera],
);
const mainCameraAspect = useMemo(() => {
if (!selectedCamera) return "normal";
const aspectRatio = getCameraAspect(selectedCamera);
if (!aspectRatio) return "normal";
if (aspectRatio > ASPECT_WIDE_LAYOUT) return "wide";
if (aspectRatio < ASPECT_VERTICAL_LAYOUT) return "tall";
return "normal";
}, [getCameraAspect, selectedCamera]);
const grow = useMemo(() => {
if (mainCameraAspect === "wide") return "w-full aspect-wide";
if (mainCameraAspect === "tall") {
return isDesktop
? "size-full aspect-tall flex flex-col justify-center"
: "size-full";
}
return "w-full aspect-video";
}, [mainCameraAspect]);
// Container resize observer
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(mainLayoutRef);
const useHeightBased = useMemo(() => {
if (!containerWidth || !containerHeight || !selectedCamera) return false;
const cameraAspectRatio = getCameraAspect(selectedCamera);
if (!cameraAspectRatio) return false;
const availableAspectRatio = containerWidth / containerHeight;
return availableAspectRatio >= cameraAspectRatio;
}, [containerWidth, containerHeight, getCameraAspect, selectedCamera]);
const onClipEnded = useCallback(() => {
if (!mainControllerRef.current) {
return;
}
if (selectedRangeIdx < chunkedTimeRange.length - 1) {
setSelectedRangeIdx(selectedRangeIdx + 1);
}
}, [selectedRangeIdx, chunkedTimeRange]);
const updateSelectedSegment = useCallback(
(nextTime: number, updateStartTime: boolean) => {
const index = chunkedTimeRange.findIndex(
(segment) => segment.after <= nextTime && segment.before >= nextTime,
);
if (index != -1) {
if (updateStartTime) {
setPlaybackStart(nextTime);
}
setSelectedRangeIdx(index);
}
},
[chunkedTimeRange],
);
// Handle scrubbing
useEffect(() => {
if (scrubbing || exportRange) {
if (
currentTime > currentTimeRange.before + 60 ||
currentTime < currentTimeRange.after - 60
) {
updateSelectedSegment(currentTime, false);
return;
}
mainControllerRef.current?.scrubToTimestamp(currentTime);
}
// we only want to seek when current time updates
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
currentTime,
scrubbing,
timeRange,
currentTimeRange,
updateSelectedSegment,
]);
useEffect(() => {
if (pendingSeekTime != null) {
return;
}
const nextTime = timeRange.before - 60;
const index = chunkedTimeRange.findIndex(
(segment) => segment.after <= nextTime && segment.before >= nextTime,
);
setCurrentTime(nextTime);
setPlayerTime(nextTime);
setPlaybackStart(nextTime);
setSelectedRangeIdx(index === -1 ? chunkedTimeRange.length - 1 : index);
mainControllerRef.current?.seekToTimestamp(nextTime, true);
}, [pendingSeekTime, timeRange, chunkedTimeRange]);
useEffect(() => {
if (!scrubbing) {
if (Math.abs(currentTime - playerTime) > 10) {
if (
currentTimeRange.after <= currentTime &&
currentTimeRange.before >= currentTime
) {
mainControllerRef.current?.seekToTimestamp(currentTime, true);
} else {
updateSelectedSegment(currentTime, true);
}
} else if (playerTime != currentTime) {
mainControllerRef.current?.play();
}
}
// we only want to seek when current time doesn't match the player update time
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTime, scrubbing, playerTime]);
// Manually seek to timestamp
const manuallySetCurrentTime = useCallback(
(time: number, play: boolean = false) => {
if (!currentTimeRange) {
return;
}
setCurrentTime(time);
if (currentTimeRange.after <= time && currentTimeRange.before >= time) {
mainControllerRef.current?.seekToTimestamp(time, play);
} else {
updateSelectedSegment(time, true);
}
},
[currentTimeRange, updateSelectedSegment],
);
const canStartSearch = Boolean(
selectedCamera &&
searchRange &&
searchRange.before >= searchRange.after &&
polygonPoints.length >= 3 &&
!isDrawingROI,
);
const cancelMotionSearchJob = useCallback(
async (jobIdToCancel: string | null, cameraToCancel: string | null) => {
if (!jobIdToCancel || !cameraToCancel) {
return;
}
try {
await axios.post(
`${cameraToCancel}/search/motion/${jobIdToCancel}/cancel`,
);
} catch {
// Best effort cancellation.
}
},
[],
);
const cancelMotionSearchJobViaBeacon = useCallback(
(jobIdToCancel: string | null, cameraToCancel: string | null) => {
if (!jobIdToCancel || !cameraToCancel) {
return;
}
const url = `${window.location.origin}/api/${cameraToCancel}/search/motion/${jobIdToCancel}/cancel`;
const xhr = new XMLHttpRequest();
try {
xhr.open("POST", url, false);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.setRequestHeader("X-CSRF-TOKEN", "1");
xhr.setRequestHeader("X-CACHE-BYPASS", "1");
xhr.withCredentials = true;
xhr.send("{}");
} catch {
// Best effort cancellation during unload.
}
},
[],
);
useEffect(() => {
jobIdRef.current = jobId;
}, [jobId]);
useEffect(() => {
jobCameraRef.current = jobCamera;
}, [jobCamera]);
useEffect(() => {
return () => {
cancelMotionSearchJobViaBeacon(jobIdRef.current, jobCameraRef.current);
void cancelMotionSearchJob(jobIdRef.current, jobCameraRef.current);
};
}, [cancelMotionSearchJob, cancelMotionSearchJobViaBeacon]);
useEffect(() => {
const handleBeforeUnload = () => {
cancelMotionSearchJobViaBeacon(jobIdRef.current, jobCameraRef.current);
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [cancelMotionSearchJobViaBeacon]);
const handleNewSearch = useCallback(() => {
if (jobId && jobCamera) {
void cancelMotionSearchJob(jobId, jobCamera);
if (isSearching) {
toast.message(t("searchCancelled"));
}
}
setSearchResults([]);
setSearchMetrics(null);
setIsSearching(false);
setJobId(null);
setJobCamera(null);
setHasSearched(false);
setPendingSeekTime(null);
setSearchRange(timeRange);
setIsSearchDialogOpen(true);
}, [cancelMotionSearchJob, isSearching, jobCamera, jobId, t, timeRange]);
// Perform motion search
const performSearch = useCallback(async () => {
if (!selectedCamera) {
toast.error(t("errors.noCamera"));
return;
}
if (polygonPoints.length < 3) {
toast.error(t("errors.polygonTooSmall"));
return;
}
if (!searchRange) {
toast.error(t("errors.noTimeRange"));
return;
}
if (searchRange.before < searchRange.after) {
toast.error(t("errors.invalidTimeRange"));
return;
}
setIsSearching(true);
setSearchResults([]);
setHasSearched(true);
try {
const request: MotionSearchRequest = {
start_time: searchRange.after,
end_time: searchRange.before,
polygon_points: polygonPoints,
parallel: parallelMode,
threshold,
min_area: minArea,
frame_skip: frameSkip,
max_results: maxResults,
};
const response = await axios.post<MotionSearchStartResponse>(
`${selectedCamera}/search/motion`,
request,
);
if (response.data.success) {
setJobId(response.data.job_id);
setJobCamera(selectedCamera);
setIsSearchDialogOpen(false);
toast.success(t("searchStarted"));
} else {
toast.error(
t("errors.searchFailed", { message: response.data.message }),
);
setIsSearching(false);
}
} catch (error) {
let errorMessage = t("errors.unknown");
if (axios.isAxiosError<{ message?: string; detail?: string }>(error)) {
const responseData = error.response?.data as
| {
message?: unknown;
detail?: unknown;
error?: unknown;
errors?: unknown;
}
| string
| undefined;
if (typeof responseData === "string") {
errorMessage = responseData;
} else if (responseData) {
const apiMessage =
responseData.message ??
responseData.detail ??
responseData.error ??
responseData.errors;
if (Array.isArray(apiMessage)) {
errorMessage = apiMessage.join(", ");
} else if (typeof apiMessage === "string") {
errorMessage = apiMessage;
} else if (apiMessage) {
errorMessage = JSON.stringify(apiMessage);
} else {
errorMessage = error.message || errorMessage;
}
} else {
errorMessage = error.message || errorMessage;
}
} else if (error instanceof Error) {
errorMessage = error.message;
}
toast.error(t("errors.searchFailed", { message: errorMessage }));
setIsSearching(false);
}
}, [
selectedCamera,
polygonPoints,
searchRange,
parallelMode,
threshold,
minArea,
frameSkip,
maxResults,
t,
]);
// Monitor job status and update UI when complete
useEffect(() => {
if (!jobStatus) {
return;
}
if (jobStatus.status === "success") {
setSearchResults(jobStatus.results ?? []);
setSearchMetrics(jobStatus.metrics ?? null);
setIsSearching(false);
setJobId(null);
setJobCamera(null);
toast.success(
t("changesFound", { count: jobStatus.results?.length ?? 0 }),
);
} else if (
jobStatus.status === "queued" ||
jobStatus.status === "running"
) {
setSearchMetrics(jobStatus.metrics ?? null);
// Stream partial results as they arrive
if (jobStatus.results && jobStatus.results.length > 0) {
setSearchResults(jobStatus.results);
}
} else if (jobStatus.status === "failed") {
setIsSearching(false);
setJobId(null);
setJobCamera(null);
toast.error(
t("errors.searchFailed", {
message: jobStatus.error_message || jobStatus.message,
}),
);
} else if (jobStatus.status === "cancelled") {
setIsSearching(false);
setJobId(null);
setJobCamera(null);
toast.message(t("searchCancelled"));
}
}, [jobStatus, t]);
// Handle result click
const handleResultClick = useCallback(
(result: MotionSearchResult) => {
if (
result.timestamp < timeRange.after ||
result.timestamp > timeRange.before
) {
setPendingSeekTime(result.timestamp);
onDaySelect(new Date(result.timestamp * 1000));
return;
}
manuallySetCurrentTime(result.timestamp, true);
},
[manuallySetCurrentTime, onDaySelect, timeRange],
);
useEffect(() => {
if (pendingSeekTime == null) {
return;
}
if (
pendingSeekTime >= timeRange.after &&
pendingSeekTime <= timeRange.before
) {
manuallySetCurrentTime(pendingSeekTime, true);
setPendingSeekTime(null);
}
}, [pendingSeekTime, timeRange, manuallySetCurrentTime]);
if (!selectedCamera) {
return (
<div className="flex size-full items-center justify-center">
<p className="text-muted-foreground">{t("selectCamera")}</p>
</div>
);
}
const timelinePanel = (
<>
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 h-[30px] w-full bg-gradient-to-b from-secondary to-transparent" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-[30px] w-full bg-gradient-to-t from-secondary to-transparent" />
<SaveExportOverlay
className="pointer-events-none absolute inset-x-0 top-0 z-30"
show={exportMode === "timeline" && Boolean(exportRange)}
onPreview={handleExportPreview}
onSave={handleExportSave}
onCancel={handleExportCancel}
/>
{!isMotionLoading ? (
<MotionReviewTimeline
timelineRef={timelineRef}
segmentDuration={zoomSettings.segmentDuration}
timestampSpread={zoomSettings.timestampSpread}
timelineStart={timeRange.before}
timelineEnd={timeRange.after}
showHandlebar={true}
handlebarTime={currentTime}
setHandlebarTime={setCurrentTime}
events={[]}
motion_events={motionData ?? []}
noRecordingRanges={noRecordings ?? []}
contentRef={contentRef}
onHandlebarDraggingChange={(dragging) => setScrubbing(dragging)}
showExportHandles={exportMode === "timeline" && Boolean(exportRange)}
exportStartTime={exportRange?.after}
exportEndTime={exportRange?.before}
setExportStartTime={setExportStartTime}
setExportEndTime={setExportEndTime}
isZooming={isZooming}
zoomDirection={zoomDirection}
onZoomChange={handleZoomChange}
possibleZoomLevels={possibleZoomLevels}
currentZoomLevel={currentZoomLevel}
/>
) : (
<Skeleton className="size-full" />
)}
</>
);
const progressMetrics = jobStatus?.metrics ?? searchMetrics;
const progressValue =
progressMetrics && progressMetrics.segments_scanned > 0
? Math.min(
100,
(progressMetrics.segments_processed /
progressMetrics.segments_scanned) *
100,
)
: 0;
const resultsPanel = (
<>
<div className="p-2">
<h3 className="font-medium">{t("results")}</h3>
</div>
<ScrollArea className="flex-1">
{isSearching && (
<div className="flex flex-col gap-2 border-b p-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col gap-1 text-wrap">
<ActivityIndicator className="mr-2 size-4" />
<div>{t("searching")}</div>
</div>
<Button
variant="destructive"
className="text-white"
size="sm"
onClick={() => {
void cancelMotionSearchJob(jobId, jobCamera);
setIsSearching(false);
setJobId(null);
setJobCamera(null);
toast.success(t("searchCancelled"));
}}
>
{t("cancelSearch")}
</Button>
</div>
<Progress className="h-1" value={progressValue} />
</div>
)}
{searchMetrics && searchResults.length > 0 && (
<div className="mx-2 rounded-lg border bg-secondary p-2">
<div className="space-y-0.5 text-xs text-muted-foreground">
<div className="flex justify-between">
<span>{t("metrics.segmentsScanned")}</span>
<span className="text-primary-variant">
{searchMetrics.segments_scanned}
</span>
</div>
{searchMetrics.segments_processed > 0 && (
<div className="flex justify-between font-medium">
<span>{t("metrics.segmentsProcessed")}</span>
<span className="text-primary-variant">
{searchMetrics.segments_processed}
</span>
</div>
)}
{searchMetrics.metadata_inactive_segments > 0 && (
<div className="flex justify-between">
<span>{t("metrics.segmentsSkippedInactive")}</span>
<span className="text-primary-variant">
{searchMetrics.metadata_inactive_segments}
</span>
</div>
)}
{searchMetrics.heatmap_roi_skip_segments > 0 && (
<div className="flex justify-between">
<span>{t("metrics.segmentsSkippedHeatmap")}</span>
<span className="text-primary-variant">
{searchMetrics.heatmap_roi_skip_segments}
</span>
</div>
)}
{searchMetrics.fallback_full_range_segments > 0 && (
<div className="flex justify-between">
<span>{t("metrics.fallbackFullRange")}</span>
<span className="text-primary-variant">
{searchMetrics.fallback_full_range_segments}
</span>
</div>
)}
<div className="flex justify-between">
<span>{t("metrics.framesDecoded")}</span>
<span className="text-primary-variant">
{searchMetrics.frames_decoded}
</span>
</div>
<div className="flex justify-between">
<span>{t("metrics.wallTime")}</span>
<span className="text-primary-variant">
{t("metrics.seconds", {
seconds: searchMetrics.wall_time_seconds.toFixed(1),
})}
</span>
</div>
{searchMetrics.segments_with_errors > 0 && (
<div className="flex justify-between text-destructive">
<span>{t("metrics.segmentErrors")}</span>
<span className="text-primary-variant">
{searchMetrics.segments_with_errors}
</span>
</div>
)}
</div>
</div>
)}
{searchResults.length === 0 && !isSearching ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{hasSearched ? t("noChangesFound") : t("noResultsYet")}
</div>
) : searchResults.length > 0 ? (
<div className="flex flex-col gap-1 p-2">
{searchResults.map((result, index) => (
<SearchResultItem
key={index}
result={result}
timezone={timezone}
timestampFormat={resultTimestampFormat}
onClick={() => handleResultClick(result)}
/>
))}
</div>
) : null}
</ScrollArea>
</>
);
return (
<DetailStreamProvider
isDetailMode={false}
currentTime={currentTime}
camera={selectedCamera ?? ""}
>
<div ref={contentRef} className="flex size-full flex-col pt-2">
<Toaster closeButton={true} position="top-center" />
<MotionSearchDialog
open={isSearchDialogOpen}
onOpenChange={setIsSearchDialogOpen}
config={config}
cameras={cameras}
selectedCamera={selectedCamera}
onCameraSelect={onCameraSelect}
cameraLocked={cameraLocked}
polygonPoints={polygonPoints}
setPolygonPoints={setPolygonPoints}
isDrawingROI={isDrawingROI}
setIsDrawingROI={setIsDrawingROI}
parallelMode={parallelMode}
setParallelMode={setParallelMode}
threshold={threshold}
setThreshold={setThreshold}
minArea={minArea}
setMinArea={setMinArea}
frameSkip={frameSkip}
setFrameSkip={setFrameSkip}
maxResults={maxResults}
setMaxResults={setMaxResults}
searchRange={searchRange}
setSearchRange={setSearchRange}
defaultRange={timeRange}
isSearching={isSearching}
canStartSearch={canStartSearch}
onStartSearch={performSearch}
timezone={timezone}
/>
{/* Header */}
<div className="relative mb-2 flex h-11 w-full items-center justify-between px-2">
{isMobile && (
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
)}
{(cameraLocked || onBack) && (
<div className="flex items-center gap-2">
<Button
className="flex items-center gap-2.5 rounded-lg"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={() => (onBack ? onBack() : navigate(-1))}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
</div>
)}
<div className="flex w-full items-center justify-end gap-2">
<div className="hidden h-9 cursor-pointer items-center justify-start rounded-md bg-secondary p-2 text-sm hover:bg-secondary/80 md:flex">
<Switch
id="heatmap-toggle"
checked={showSegmentHeatmap}
onCheckedChange={setShowSegmentHeatmap}
/>
<label
htmlFor="heatmap-toggle"
className="ml-2 cursor-pointer text-sm text-primary"
>
{t("motionHeatmapLabel")}
</label>
</div>
<Button
className="rounded-lg md:hidden"
variant={showSegmentHeatmap ? "select" : "default"}
size="sm"
aria-label={t("motionHeatmapLabel")}
onClick={() => setShowSegmentHeatmap((prev) => !prev)}
>
<FaFire className="size-4" />
</Button>
{isDesktop ? (
<>
<ExportDialog
camera={selectedCamera}
currentTime={currentTime}
latestTime={timeRange.before}
mode={exportMode}
range={exportRange}
showPreview={showExportPreview}
setRange={setExportRangeWithPause}
setMode={setExportMode}
setShowPreview={setShowExportPreview}
/>
<CalendarFilterButton
recordingsSummary={recordingsSummary}
day={selectedDay}
updateSelectedDay={onDaySelect}
/>
</>
) : (
<Drawer
open={isMobileSettingsOpen}
onOpenChange={(open) => {
setIsMobileSettingsOpen(open);
if (!open) {
setMobileSettingsMode("actions");
}
}}
>
<DrawerTrigger asChild>
<Button
className="rounded-lg"
size="sm"
aria-label={t("filters", { ns: "views/recording" })}
onClick={() => setMobileSettingsMode("actions")}
>
<FaCog className="size-5 text-secondary-foreground" />
</Button>
</DrawerTrigger>
<DrawerContent className="mx-1 max-h-[75dvh] overflow-hidden rounded-t-2xl px-4 pb-4">
{mobileSettingsMode == "actions" ? (
<div className="flex w-full flex-col gap-2 p-4">
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("menu.export", { ns: "common" })}
onClick={openMobileExport}
>
<FaArrowDown className="rounded-md bg-secondary-foreground fill-secondary p-1" />
{t("menu.export", { ns: "common" })}
</Button>
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("calendar", { ns: "views/recording" })}
variant={selectedDay ? "select" : "default"}
onClick={() => setMobileSettingsMode("calendar")}
>
<FaCalendarAlt
className={
selectedDay
? "text-selected-foreground"
: "text-secondary-foreground"
}
/>
{t("calendar", { ns: "views/recording" })}
</Button>
</div>
) : (
<div className="flex w-full flex-col">
<div className="relative h-8 w-full">
<div
className="absolute left-0 text-selected"
onClick={() => setMobileSettingsMode("actions")}
>
{t("button.back", { ns: "common" })}
</div>
<div className="absolute left-1/2 -translate-x-1/2 text-muted-foreground">
{t("calendar", { ns: "views/recording" })}
</div>
</div>
<div className="flex w-full flex-row justify-center">
<ReviewActivityCalendar
recordingsSummary={recordingsSummary}
selectedDay={selectedDay}
onSelect={(day) => {
onDaySelect(day);
setIsMobileSettingsOpen(false);
setMobileSettingsMode("actions");
}}
/>
</div>
<SelectSeparator />
<div className="flex items-center justify-center p-2">
<Button
aria-label={t("button.reset", { ns: "common" })}
onClick={() => {
onDaySelect(undefined);
setIsMobileSettingsOpen(false);
setMobileSettingsMode("actions");
}}
>
{t("button.reset", { ns: "common" })}
</Button>
</div>
</div>
)}
</DrawerContent>
</Drawer>
)}
<Button
variant="select"
size="sm"
aria-label={t("newSearch")}
onClick={handleNewSearch}
>
{isDesktop ? t("newSearch") : <LuSearch className="size-5" />}
</Button>
</div>
</div>
{!isDesktop && (
<div className="hidden">
<ExportDialog
camera={selectedCamera}
currentTime={currentTime}
latestTime={timeRange.before}
mode={exportMode}
range={exportRange}
showPreview={showExportPreview}
setRange={setExportRangeWithPause}
setMode={setExportMode}
setShowPreview={setShowExportPreview}
/>
</div>
)}
{/* Main Content */}
<div
className={cn(
"flex flex-1 overflow-hidden",
isDesktop ? "flex-row" : "flex-col gap-2 landscape:flex-row",
)}
>
{/* Video Player with ROI Canvas */}
<div
ref={mainLayoutRef}
className={cn(
"flex flex-col overflow-hidden",
isDesktop
? mainCameraAspect === "tall"
? "mr-2 h-full min-h-0 min-w-0 flex-1 items-center"
: "mr-2 h-full min-h-0 min-w-0 flex-1"
: mainCameraAspect === "tall"
? "flex-1 portrait:h-[40dvh] portrait:max-h-[40dvh] portrait:flex-shrink-0 portrait:flex-grow-0 portrait:basis-auto portrait:items-center portrait:justify-center"
: "flex-1 portrait:max-h-[40dvh] portrait:flex-shrink-0 portrait:flex-grow-0 portrait:basis-auto landscape:items-center landscape:justify-center",
)}
>
<div
className={cn(
"relative flex max-h-full min-h-0 min-w-0 max-w-full items-center justify-center",
isDesktop
? mainCameraAspect === "tall" || useHeightBased
? "h-full"
: "w-full"
: mainCameraAspect == "tall"
? "aspect-tall h-full w-auto max-w-full flex-shrink-0 landscape:h-full"
: cn(
"flex-shrink-0 portrait:w-full landscape:h-full",
mainCameraAspect == "wide"
? "aspect-wide"
: "aspect-video",
),
)}
style={{
aspectRatio: getCameraAspect(selectedCamera),
}}
>
{/* Video Player */}
<DynamicVideoPlayer
className={grow}
camera={selectedCamera}
timeRange={currentTimeRange}
cameraPreviews={allPreviews ?? []}
startTimestamp={playbackStart}
hotKeys={exportMode != "select"}
fullscreen={fullscreen}
onTimestampUpdate={(timestamp) => {
setPlayerTime(timestamp);
setCurrentTime(timestamp);
}}
onClipEnded={onClipEnded}
onSeekToTime={manuallySetCurrentTime}
onControllerReady={(controller) => {
mainControllerRef.current = controller;
}}
isScrubbing={scrubbing || exportMode == "timeline"}
supportsFullscreen={supportsFullScreen}
setFullResolution={setFullResolution}
toggleFullscreen={toggleFullscreen}
containerRef={mainLayoutRef}
transformedOverlay={
<MotionSearchROICanvas
camera={selectedCamera}
width={config.cameras[selectedCamera]?.detect.width ?? 1920}
height={
config.cameras[selectedCamera]?.detect.height ?? 1080
}
polygonPoints={polygonPoints}
setPolygonPoints={setPolygonPoints}
isDrawing={isDrawingROI}
setIsDrawing={setIsDrawingROI}
isInteractive={false}
motionHeatmap={activeSegmentHeatmap}
showMotionHeatmap={showSegmentHeatmap}
/>
}
/>
</div>
</div>
{isDesktop ? (
<>
<div className="relative w-[100px] flex-shrink-0 overflow-hidden">
{timelinePanel}
</div>
<div className="flex w-64 flex-col border-l">{resultsPanel}</div>
</>
) : (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden landscape:flex-row">
<div className="relative min-h-0 basis-1/2 overflow-hidden landscape:w-[100px] landscape:flex-shrink-0 landscape:basis-auto">
{timelinePanel}
</div>
<div className="flex min-h-0 basis-1/2 flex-col border-t landscape:flex-1 landscape:border-l landscape:border-t-0">
{resultsPanel}
</div>
</div>
)}
</div>
</div>
</DetailStreamProvider>
);
}
type SearchResultItemProps = {
result: MotionSearchResult;
timezone: string | undefined;
timestampFormat: string;
onClick: () => void;
};
function SearchResultItem({
result,
timezone,
timestampFormat,
onClick,
}: SearchResultItemProps) {
const { t } = useTranslation(["views/motionSearch"]);
const formattedTime = useFormattedTimestamp(
result.timestamp,
timestampFormat,
timezone,
);
return (
<button
className="flex w-full flex-col rounded-md p-2 text-left hover:bg-accent"
onClick={onClick}
title={t("jumpToTime")}
>
<span className="text-sm font-medium">{formattedTime}</span>
<span className="text-xs text-muted-foreground">
{t("changePercentage", {
percentage: result.change_percentage.toFixed(1),
})}
</span>
</button>
);
}