import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { isDesktop, isIOS, isMobile } from "react-device-detect"; import { FaArrowRight, FaCalendarAlt, FaCheckCircle } from "react-icons/fa"; import { MdOutlineRestartAlt, MdUndo } from "react-icons/md"; import { FrigateConfig } from "@/types/frigateConfig"; import { TimeRange } from "@/types/timeline"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Drawer, DrawerContent } from "@/components/ui/drawer"; import { Label } from "@/components/ui/label"; import { Slider } from "@/components/ui/slider"; import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { SelectSeparator } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { TimezoneAwareCalendar } from "@/components/overlay/ReviewActivityCalendar"; import { useApiHost } from "@/api"; import { useResizeObserver } from "@/hooks/resize-observer"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import { getUTCOffset } from "@/utils/dateUtil"; import { cn } from "@/lib/utils"; import MotionSearchROICanvas from "./MotionSearchROICanvas"; import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; type MotionSearchDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; config: FrigateConfig; cameras: string[]; selectedCamera: string | null; onCameraSelect: (camera: string) => void; cameraLocked?: boolean; polygonPoints: number[][]; setPolygonPoints: React.Dispatch>; isDrawingROI: boolean; setIsDrawingROI: React.Dispatch>; parallelMode: boolean; setParallelMode: React.Dispatch>; threshold: number; setThreshold: React.Dispatch>; minArea: number; setMinArea: React.Dispatch>; frameSkip: number; setFrameSkip: React.Dispatch>; maxResults: number; setMaxResults: React.Dispatch>; searchRange?: TimeRange; setSearchRange: React.Dispatch>; defaultRange: TimeRange; isSearching: boolean; canStartSearch: boolean; onStartSearch: () => void; timezone?: string; }; export default function MotionSearchDialog({ open, onOpenChange, config, cameras, selectedCamera, onCameraSelect, cameraLocked = false, polygonPoints, setPolygonPoints, isDrawingROI, setIsDrawingROI, parallelMode, setParallelMode, threshold, setThreshold, minArea, setMinArea, frameSkip, setFrameSkip, maxResults, setMaxResults, searchRange, setSearchRange, defaultRange, isSearching, canStartSearch, onStartSearch, timezone, }: MotionSearchDialogProps) { const { t } = useTranslation(["views/motionSearch", "common"]); const apiHost = useApiHost(); const containerRef = useRef(null); const [{ width: containerWidth, height: containerHeight }] = useResizeObserver(containerRef); const [imageLoaded, setImageLoaded] = useState(false); const cameraConfig = useMemo(() => { if (!selectedCamera) return undefined; return config.cameras[selectedCamera]; }, [config, selectedCamera]); const polygonClosed = useMemo( () => !isDrawingROI && polygonPoints.length >= 3, [isDrawingROI, polygonPoints.length], ); const undoPolygonPoint = useCallback(() => { if (polygonPoints.length === 0 || isSearching) { return; } setPolygonPoints((prev) => prev.slice(0, -1)); setIsDrawingROI(true); }, [isSearching, setIsDrawingROI, setPolygonPoints, polygonPoints.length]); const resetPolygon = useCallback(() => { if (polygonPoints.length === 0 || isSearching) { return; } setPolygonPoints([]); setIsDrawingROI(true); }, [isSearching, polygonPoints.length, setIsDrawingROI, setPolygonPoints]); const imageSize = useMemo(() => { if (!containerWidth || !containerHeight || !cameraConfig) { return { width: 0, height: 0 }; } const cameraAspectRatio = cameraConfig.detect.width / cameraConfig.detect.height; const availableAspectRatio = containerWidth / containerHeight; if (availableAspectRatio >= cameraAspectRatio) { return { width: containerHeight * cameraAspectRatio, height: containerHeight, }; } return { width: containerWidth, height: containerWidth / cameraAspectRatio, }; }, [containerWidth, containerHeight, cameraConfig]); useEffect(() => { setImageLoaded(false); }, [selectedCamera]); const Overlay = isDesktop ? Dialog : Drawer; const Content = isDesktop ? DialogContent : DrawerContent; return ( event.preventDefault(), } : {})} className={cn( isDesktop ? "scrollbar-container max-h-[90dvh] overflow-y-auto sm:max-w-[75%]" : "flex max-h-[90dvh] flex-col overflow-hidden rounded-lg pb-4", )} >
{t("dialog.title")}

{t("description")}

{(!cameraLocked || !selectedCamera) && (
)}
{selectedCamera && cameraConfig && imageSize.width > 0 ? (
{t("dialog.previewAlt", setImageLoaded(true)} /> {!imageLoaded && (
)}
) : (
{t("selectCamera")}
)}
{selectedCamera && (
{t("polygonControls.points", { count: polygonPoints.length, })} {polygonClosed && }
{t("polygonControls.undo")} {t("polygonControls.reset")}
)}

{t("settings.title")}

setThreshold(value)} /> {threshold}

{t("settings.thresholdDesc")}

setMinArea(value)} /> {minArea}%

{t("settings.minAreaDesc")}

setFrameSkip(value)} /> {frameSkip}

{t("settings.frameSkipDesc")}

{t("settings.parallelModeDesc")}

setMaxResults(value)} /> {maxResults}

{t("settings.maxResultsDesc")}

); } type SearchRangeSelectorProps = { range?: TimeRange; setRange: React.Dispatch>; defaultRange: TimeRange; timeFormat?: "browser" | "12hour" | "24hour"; timezone?: string; }; function SearchRangeSelector({ range, setRange, defaultRange, timeFormat, timezone, }: SearchRangeSelectorProps) { const { t } = useTranslation(["views/motionSearch", "common"]); const [startOpen, setStartOpen] = useState(false); const [endOpen, setEndOpen] = useState(false); const timezoneOffset = useMemo( () => timezone ? Math.round(getUTCOffset(new Date(), timezone)) : undefined, [timezone], ); const localTimeOffset = useMemo( () => Math.round( getUTCOffset( new Date(), Intl.DateTimeFormat().resolvedOptions().timeZone, ), ), [], ); const startTime = useMemo(() => { let time = range?.after ?? defaultRange.after; if (timezoneOffset !== undefined) { time = time + (timezoneOffset - localTimeOffset) * 60; } return time; }, [range, defaultRange, timezoneOffset, localTimeOffset]); const endTime = useMemo(() => { let time = range?.before ?? defaultRange.before; if (timezoneOffset !== undefined) { time = time + (timezoneOffset - localTimeOffset) * 60; } return time; }, [range, defaultRange, timezoneOffset, localTimeOffset]); const formattedStart = useFormattedTimestamp( startTime, timeFormat === "24hour" ? t("time.formattedTimestamp.24hour", { ns: "common" }) : t("time.formattedTimestamp.12hour", { ns: "common" }), ); const formattedEnd = useFormattedTimestamp( endTime, timeFormat === "24hour" ? t("time.formattedTimestamp.24hour", { ns: "common" }) : t("time.formattedTimestamp.12hour", { ns: "common" }), ); const startClock = useMemo(() => { const date = new Date(startTime * 1000); return `${date.getHours().toString().padStart(2, "0")}:${date .getMinutes() .toString() .padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`; }, [startTime]); const endClock = useMemo(() => { const date = new Date(endTime * 1000); return `${date.getHours().toString().padStart(2, "0")}:${date .getMinutes() .toString() .padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`; }, [endTime]); return (
{ if (!open) { setStartOpen(false); } }} modal={false} > { if (!day) { return; } setRange({ before: endTime, after: day.getTime() / 1000 + 1, }); }} /> { const clock = e.target.value; const [hour, minute, second] = isIOS ? [...clock.split(":"), "00"] : clock.split(":"); const start = new Date(startTime * 1000); start.setHours( parseInt(hour), parseInt(minute), parseInt(second ?? 0), 0, ); setRange({ before: endTime, after: start.getTime() / 1000, }); }} /> { if (!open) { setEndOpen(false); } }} modal={false} > { if (!day) { return; } setRange({ after: startTime, before: day.getTime() / 1000, }); }} /> { const clock = e.target.value; const [hour, minute, second] = isIOS ? [...clock.split(":"), "00"] : clock.split(":"); const end = new Date(endTime * 1000); end.setHours( parseInt(hour), parseInt(minute), parseInt(second ?? 0), 0, ); setRange({ before: end.getTime() / 1000, after: startTime, }); }} />
); }