2026-03-06 02:53:48 +03:00
|
|
|
|
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
|
2026-03-06 23:41:15 +03:00
|
|
|
|
const allPreviews = useCameraPreviews(timeRange, {
|
|
|
|
|
|
camera: selectedCamera ?? undefined,
|
|
|
|
|
|
fetchPreviews: !isSearchDialogOpen,
|
|
|
|
|
|
});
|
2026-03-06 02:53:48 +03:00
|
|
|
|
|
|
|
|
|
|
// 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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|