mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-26 06:11:54 +03:00
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* remove redundant per-view toasters in settings * add variants to standardize dialog footer button layouts * remove text-md this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html * make wizard footers consistent with dialog footers * consistent destructive button style remove text-white from individual buttons and add it to the variant
1526 lines
49 KiB
TypeScript
1526 lines
49 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 { baseUrl } from "@/api/baseUrl";
|
||
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, use24HourTime } 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 is24Hour = use24HourTime(config);
|
||
const resultTimestampFormat = useMemo(
|
||
() =>
|
||
is24Hour
|
||
? t("time.formattedTimestamp.24hour", { ns: "common" })
|
||
: t("time.formattedTimestamp.12hour", { ns: "common" }),
|
||
[is24Hour, 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" && exportMode !== "timeline_multi") ||
|
||
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 < 300) {
|
||
toast.success(
|
||
t("export.toast.success", { ns: "components/dialog" }),
|
||
{
|
||
position: "top-center",
|
||
action: (
|
||
<a
|
||
href={`${baseUrl}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" || exportMode === "timeline_multi") &&
|
||
Boolean(exportRange)
|
||
}
|
||
hidePreview={exportMode === "timeline_multi"}
|
||
saveLabel={
|
||
exportMode === "timeline_multi"
|
||
? t("export.fromTimeline.useThisRange", { ns: "components/dialog" })
|
||
: undefined
|
||
}
|
||
onPreview={handleExportPreview}
|
||
onSave={() => {
|
||
if (exportMode === "timeline_multi") {
|
||
setExportMode("select");
|
||
return;
|
||
}
|
||
|
||
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" || exportMode === "timeline_multi") &&
|
||
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 = (() => {
|
||
if (!progressMetrics || progressMetrics.segments_scanned <= 0) {
|
||
return 0;
|
||
}
|
||
const skipped =
|
||
progressMetrics.heatmap_roi_skip_segments +
|
||
progressMetrics.metadata_inactive_segments;
|
||
const totalWork = progressMetrics.segments_scanned - skipped;
|
||
const doneWork = progressMetrics.segments_processed - skipped;
|
||
if (totalWork <= 0) {
|
||
return 100;
|
||
}
|
||
return Math.min(100, Math.max(0, (doneWork / totalWork) * 100));
|
||
})();
|
||
|
||
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"
|
||
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 && (isSearching || searchResults.length > 0) && (
|
||
<div className="mx-2 my-3 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" ||
|
||
exportMode == "timeline_multi"
|
||
}
|
||
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>
|
||
);
|
||
}
|