mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 02:29:19 +03:00
* fix useImageLoaded hook running on every render * fix volume not applying for all cameras * Fix maximum update depth exceeded errors on Review page - use-overlay-state: use refs for location to keep setter identity stable across renders, preventing cascading re-render loops when effects depend on the setter. Add Object.is bail-out guard to skip redundant navigate calls. Move setPersistedValue after bail-out to avoid unnecessary IndexedDB writes. * don't try to fetch previews when motion search dialog is open * revert unneeded changes re-rendering was caused by the overlay state hook, not this one * filter dicts to only use id field in sync recordings
1490 lines
48 KiB
TypeScript
1490 lines
48 KiB
TypeScript
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(timeRange, {
|
||
camera: selectedCamera ?? undefined,
|
||
fetchPreviews: !isSearchDialogOpen,
|
||
});
|
||
|
||
// 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>
|
||
);
|
||
}
|