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(null); const mainLayoutRef = useRef(null); const timelineRef = useRef(null); const mainControllerRef = useRef(null); const jobIdRef = useRef(null); const jobCameraRef = useRef(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( selectedCamera && !isSearchDialogOpen ? [ "recordings/summary", { timezone: timezone, cameras: selectedCamera, }, ] : null, ); // Camera previews – defer until dialog is closed const allPreviews = useCameraPreviews(timeRange, { camera: selectedCamera ?? undefined, fetchPreviews: !isSearchDialogOpen, }); // ROI state const [polygonPoints, setPolygonPoints] = useState([]); 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(null); const [jobCamera, setJobCamera] = useState(null); // Job polling with SWR const { data: jobStatus } = useSWR( jobId && jobCamera ? [`${jobCamera}/search/motion/${jobId}`] : null, { refreshInterval: 1000 }, ); // Search state const [isSearching, setIsSearching] = useState(false); const [searchResults, setSearchResults] = useState([]); const [showSegmentHeatmap, setShowSegmentHeatmap] = useState(false); const [searchMetrics, setSearchMetrics] = useState(null); const [hasSearched, setHasSearched] = useState(false); const [searchRange, setSearchRange] = useState( undefined, ); const [pendingSeekTime, setPendingSeekTime] = useState(null); // Export state const [exportMode, setExportMode] = useState("none"); const [exportRange, setExportRange] = useState(); const [showExportPreview, setShowExportPreview] = useState(false); // Timeline state const initialStartTime = timeRange.before - 60; const [scrubbing, setScrubbing] = useState(false); const [currentTime, setCurrentTime] = useState(initialStartTime); const [playerTime, setPlayerTime] = useState(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( () => 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> >( (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> >( (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: ( ), }, ); 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({ 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( selectedCamera && !isSearchDialogOpen ? [ "review/activity/motion", { before: alignedBefore, after: alignedAfter, scale: Math.round(zoomSettings.segmentDuration / 2), cameras: selectedCamera, }, ] : null, ); const { data: noRecordings } = useSWR( 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( 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( `${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 (

{t("selectCamera")}

); } const timelinePanel = ( <>
{!isMotionLoading ? ( 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} /> ) : ( )} ); 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 = ( <>

{t("results")}

{isSearching && (
{t("searching")}
)} {searchMetrics && searchResults.length > 0 && (
{t("metrics.segmentsScanned")} {searchMetrics.segments_scanned}
{searchMetrics.segments_processed > 0 && (
{t("metrics.segmentsProcessed")} {searchMetrics.segments_processed}
)} {searchMetrics.metadata_inactive_segments > 0 && (
{t("metrics.segmentsSkippedInactive")} {searchMetrics.metadata_inactive_segments}
)} {searchMetrics.heatmap_roi_skip_segments > 0 && (
{t("metrics.segmentsSkippedHeatmap")} {searchMetrics.heatmap_roi_skip_segments}
)} {searchMetrics.fallback_full_range_segments > 0 && (
{t("metrics.fallbackFullRange")} {searchMetrics.fallback_full_range_segments}
)}
{t("metrics.framesDecoded")} {searchMetrics.frames_decoded}
{t("metrics.wallTime")} {t("metrics.seconds", { seconds: searchMetrics.wall_time_seconds.toFixed(1), })}
{searchMetrics.segments_with_errors > 0 && (
{t("metrics.segmentErrors")} {searchMetrics.segments_with_errors}
)}
)} {searchResults.length === 0 && !isSearching ? (
{hasSearched ? t("noChangesFound") : t("noResultsYet")}
) : searchResults.length > 0 ? (
{searchResults.map((result, index) => ( handleResultClick(result)} /> ))}
) : null}
); return (
{/* Header */}
{isMobile && ( )} {(cameraLocked || onBack) && (
)}
{isDesktop ? ( <> ) : ( { setIsMobileSettingsOpen(open); if (!open) { setMobileSettingsMode("actions"); } }} > {mobileSettingsMode == "actions" ? (
) : (
setMobileSettingsMode("actions")} > {t("button.back", { ns: "common" })}
{t("calendar", { ns: "views/recording" })}
{ onDaySelect(day); setIsMobileSettingsOpen(false); setMobileSettingsMode("actions"); }} />
)}
)}
{!isDesktop && (
)} {/* Main Content */}
{/* Video Player with ROI Canvas */}
{/* Video Player */} { 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={ } />
{isDesktop ? ( <>
{timelinePanel}
{resultsPanel}
) : (
{timelinePanel}
{resultsPanel}
)}
); } 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 ( ); }