mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-02 17:12:16 +03:00
Improve motion review and add motion search (#22253)
* implement motion search and motion previews * tweaks * fix merge issue * fix copilot instructions
This commit is contained in:
+601
-224
@@ -1,11 +1,21 @@
|
||||
import Logo from "@/components/Logo";
|
||||
import NewReviewData from "@/components/dynamic/NewReviewData";
|
||||
import CalendarFilterButton from "@/components/filter/CalendarFilterButton";
|
||||
import ReviewActionGroup from "@/components/filter/ReviewActionGroup";
|
||||
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
|
||||
import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
|
||||
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { VolumeSlider } from "@/components/ui/slider";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||
import { useScrollLockout } from "@/hooks/use-mouse-listener";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
@@ -22,6 +32,7 @@ import {
|
||||
ZoomLevel,
|
||||
} from "@/types/review";
|
||||
import { getChunkedTimeRange } from "@/utils/timelineUtil";
|
||||
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
|
||||
import axios from "axios";
|
||||
import {
|
||||
MutableRefObject,
|
||||
@@ -34,9 +45,18 @@ import {
|
||||
import { isDesktop, isMobile, isMobileOnly } from "react-device-detect";
|
||||
import { LuFolderCheck, LuFolderX } from "react-icons/lu";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import { FiMoreVertical } from "react-icons/fi";
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
import useSWR from "swr";
|
||||
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import BlurredIconButton from "@/components/button/BlurredIconButton";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import PreviewPlayer, {
|
||||
PreviewController,
|
||||
} from "@/components/player/PreviewPlayer";
|
||||
@@ -44,7 +64,10 @@ import SummaryTimeline from "@/components/timeline/SummaryTimeline";
|
||||
import { RecordingStartingPoint } from "@/types/record";
|
||||
import VideoControls from "@/components/player/VideoControls";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity";
|
||||
import {
|
||||
useCameraMotionNextTimestamp,
|
||||
useCameraMotionOnlyRanges,
|
||||
} from "@/hooks/use-camera-activity";
|
||||
import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
@@ -56,6 +79,10 @@ import { GiSoundWaves } from "react-icons/gi";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaCog } from "react-icons/fa";
|
||||
import ReviewActivityCalendar from "@/components/overlay/ReviewActivityCalendar";
|
||||
import PlatformAwareDialog from "@/components/overlay/dialog/PlatformAwareDialog";
|
||||
import MotionPreviewsPane from "./MotionPreviewsPane";
|
||||
import { EmptyCard } from "@/components/card/EmptyCard";
|
||||
import { EmptyCardData } from "@/types/card";
|
||||
|
||||
@@ -75,6 +102,9 @@ type EventViewProps = {
|
||||
markItemAsReviewed: (review: ReviewSegment) => void;
|
||||
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
|
||||
onOpenRecording: (recordingInfo: RecordingStartingPoint) => void;
|
||||
motionPreviewsCamera: string | null;
|
||||
setMotionPreviewsCamera: (camera: string | null) => void;
|
||||
setMotionSearchCamera: (camera: string) => void;
|
||||
pullLatestData: () => void;
|
||||
updateFilter: (filter: ReviewFilter) => void;
|
||||
};
|
||||
@@ -94,6 +124,9 @@ export default function EventView({
|
||||
markItemAsReviewed,
|
||||
markAllItemsAsReviewed,
|
||||
onOpenRecording,
|
||||
motionPreviewsCamera,
|
||||
setMotionPreviewsCamera,
|
||||
setMotionSearchCamera,
|
||||
pullLatestData,
|
||||
updateFilter,
|
||||
}: EventViewProps) {
|
||||
@@ -274,6 +307,15 @@ export default function EventView({
|
||||
100,
|
||||
);
|
||||
|
||||
const motionPreviewsOpen =
|
||||
severity === "significant_motion" && motionPreviewsCamera != null;
|
||||
|
||||
useEffect(() => {
|
||||
if (severity !== "significant_motion") {
|
||||
setMotionPreviewsCamera(null);
|
||||
}
|
||||
}, [setMotionPreviewsCamera, severity]);
|
||||
|
||||
// review filter info
|
||||
|
||||
const reviewFilterList = useMemo<FilterList>(() => {
|
||||
@@ -301,124 +343,136 @@ export default function EventView({
|
||||
return (
|
||||
<div className="flex size-full flex-col pt-2 md:py-2">
|
||||
<Toaster closeButton={true} />
|
||||
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:pl-3">
|
||||
{isMobile && (
|
||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||
)}
|
||||
<ToggleGroup
|
||||
className="*:rounded-md *:px-3 *:py-4"
|
||||
type="single"
|
||||
size="sm"
|
||||
value={severityToggle}
|
||||
onValueChange={(value: ReviewSeverity) =>
|
||||
value ? setSeverityToggle(value) : null
|
||||
} // don't allow the severity to be unselected
|
||||
>
|
||||
<ToggleGroupItem
|
||||
className={cn(severityToggle != "alert" && "text-muted-foreground")}
|
||||
value="alert"
|
||||
aria-label={t("alerts")}
|
||||
{!motionPreviewsOpen && (
|
||||
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:pl-3">
|
||||
{isMobile && (
|
||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||
)}
|
||||
<ToggleGroup
|
||||
className="*:rounded-md *:px-3 *:py-4"
|
||||
type="single"
|
||||
size="sm"
|
||||
value={severityToggle}
|
||||
onValueChange={(value: ReviewSeverity) =>
|
||||
value ? setSeverityToggle(value) : null
|
||||
} // don't allow the severity to be unselected
|
||||
>
|
||||
<div
|
||||
<ToggleGroupItem
|
||||
className={cn(
|
||||
"flex size-6 items-center justify-center rounded text-severity_alert sm:hidden",
|
||||
severityToggle == "alert" ? "font-semibold" : "font-medium",
|
||||
severityToggle != "alert" && "text-muted-foreground",
|
||||
)}
|
||||
value="alert"
|
||||
aria-label={t("alerts")}
|
||||
>
|
||||
{reviewCounts.alert > -1 ? (
|
||||
reviewCounts.alert
|
||||
) : (
|
||||
<ActivityIndicator className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden items-center sm:flex">
|
||||
<MdCircle className="size-2 text-severity_alert md:mr-[10px]" />
|
||||
<div className="hidden md:flex md:flex-row md:items-center">
|
||||
{t("alerts")}
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-6 items-center justify-center rounded text-severity_alert sm:hidden",
|
||||
severityToggle == "alert" ? "font-semibold" : "font-medium",
|
||||
)}
|
||||
>
|
||||
{reviewCounts.alert > -1 ? (
|
||||
` ∙ ${reviewCounts.alert}`
|
||||
reviewCounts.alert
|
||||
) : (
|
||||
<ActivityIndicator className="ml-2 size-4" />
|
||||
<ActivityIndicator className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
className={cn(
|
||||
severityToggle != "detection" && "text-muted-foreground",
|
||||
)}
|
||||
value="detection"
|
||||
aria-label={t("detections")}
|
||||
>
|
||||
<div
|
||||
<div className="hidden items-center sm:flex">
|
||||
<MdCircle className="size-2 text-severity_alert md:mr-[10px]" />
|
||||
<div className="hidden md:flex md:flex-row md:items-center">
|
||||
{t("alerts")}
|
||||
{reviewCounts.alert > -1 ? (
|
||||
` ∙ ${reviewCounts.alert}`
|
||||
) : (
|
||||
<ActivityIndicator className="ml-2 size-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
className={cn(
|
||||
"flex size-6 items-center justify-center rounded text-severity_detection sm:hidden",
|
||||
severityToggle == "detection" ? "font-semibold" : "font-medium",
|
||||
severityToggle != "detection" && "text-muted-foreground",
|
||||
)}
|
||||
value="detection"
|
||||
aria-label={t("detections")}
|
||||
>
|
||||
{reviewCounts.detection > -1 ? (
|
||||
reviewCounts.detection
|
||||
) : (
|
||||
<ActivityIndicator className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden items-center sm:flex">
|
||||
<MdCircle className="size-2 text-severity_detection md:mr-[10px]" />
|
||||
<div className="hidden md:flex md:flex-row md:items-center">
|
||||
{t("detections")}
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-6 items-center justify-center rounded text-severity_detection sm:hidden",
|
||||
severityToggle == "detection"
|
||||
? "font-semibold"
|
||||
: "font-medium",
|
||||
)}
|
||||
>
|
||||
{reviewCounts.detection > -1 ? (
|
||||
` ∙ ${reviewCounts.detection}`
|
||||
reviewCounts.detection
|
||||
) : (
|
||||
<ActivityIndicator className="ml-2 size-4" />
|
||||
<ActivityIndicator className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-4",
|
||||
severityToggle != "significant_motion" && "text-muted-foreground",
|
||||
)}
|
||||
value="significant_motion"
|
||||
aria-label={t("motion.label")}
|
||||
>
|
||||
<GiSoundWaves className="size-6 rotate-90 text-severity_significant_motion sm:hidden" />
|
||||
<div className="hidden items-center sm:flex">
|
||||
<MdCircle className="size-2 text-severity_significant_motion md:mr-[10px]" />
|
||||
<div className="hidden md:block">{t("motion.label")}</div>
|
||||
</div>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<div className="hidden items-center sm:flex">
|
||||
<MdCircle className="size-2 text-severity_detection md:mr-[10px]" />
|
||||
<div className="hidden md:flex md:flex-row md:items-center">
|
||||
{t("detections")}
|
||||
{reviewCounts.detection > -1 ? (
|
||||
` ∙ ${reviewCounts.detection}`
|
||||
) : (
|
||||
<ActivityIndicator className="ml-2 size-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-4",
|
||||
severityToggle != "significant_motion" &&
|
||||
"text-muted-foreground",
|
||||
)}
|
||||
value="significant_motion"
|
||||
aria-label={t("motion.label")}
|
||||
>
|
||||
<GiSoundWaves className="size-6 rotate-90 text-severity_significant_motion sm:hidden" />
|
||||
<div className="hidden items-center sm:flex">
|
||||
<MdCircle className="size-2 text-severity_significant_motion md:mr-[10px]" />
|
||||
<div className="hidden md:block">{t("motion.label")}</div>
|
||||
</div>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
{selectedReviews.length <= 0 ? (
|
||||
<ReviewFilterGroup
|
||||
filters={
|
||||
severity == "significant_motion"
|
||||
? ["cameras", "date", "motionOnly"]
|
||||
: ["cameras", "reviewed", "date", "general"]
|
||||
}
|
||||
currentSeverity={severityToggle}
|
||||
reviewSummary={reviewSummary}
|
||||
recordingsSummary={recordingsSummary}
|
||||
filter={filter}
|
||||
motionOnly={motionOnly}
|
||||
filterList={reviewFilterList}
|
||||
showReviewed={showReviewed}
|
||||
setShowReviewed={setShowReviewed}
|
||||
onUpdateFilter={updateFilter}
|
||||
setMotionOnly={setMotionOnly}
|
||||
/>
|
||||
) : (
|
||||
<ReviewActionGroup
|
||||
selectedReviews={selectedReviews}
|
||||
setSelectedReviews={setSelectedReviews}
|
||||
onExport={exportReview}
|
||||
pullLatestData={pullLatestData}
|
||||
/>
|
||||
{selectedReviews.length <= 0 ? (
|
||||
<ReviewFilterGroup
|
||||
filters={
|
||||
severity == "significant_motion"
|
||||
? ["cameras", "date", "motionOnly"]
|
||||
: ["cameras", "reviewed", "date", "general"]
|
||||
}
|
||||
currentSeverity={severityToggle}
|
||||
reviewSummary={reviewSummary}
|
||||
recordingsSummary={recordingsSummary}
|
||||
filter={filter}
|
||||
motionOnly={motionOnly}
|
||||
filterList={reviewFilterList}
|
||||
showReviewed={showReviewed}
|
||||
setShowReviewed={setShowReviewed}
|
||||
onUpdateFilter={updateFilter}
|
||||
setMotionOnly={setMotionOnly}
|
||||
/>
|
||||
) : (
|
||||
<ReviewActionGroup
|
||||
selectedReviews={selectedReviews}
|
||||
setSelectedReviews={setSelectedReviews}
|
||||
onExport={exportReview}
|
||||
pullLatestData={pullLatestData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"h-full min-h-0 overflow-hidden",
|
||||
motionPreviewsOpen ? "flex flex-col" : "flex",
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex h-full overflow-hidden">
|
||||
>
|
||||
{severity != "significant_motion" && (
|
||||
<DetectionReview
|
||||
contentRef={contentRef}
|
||||
@@ -447,10 +501,16 @@ export default function EventView({
|
||||
contentRef={contentRef}
|
||||
reviewItems={reviewItems}
|
||||
relevantPreviews={relevantPreviews}
|
||||
reviewSummary={reviewSummary}
|
||||
recordingsSummary={recordingsSummary}
|
||||
timeRange={timeRange}
|
||||
startTime={startTime}
|
||||
filter={filter}
|
||||
motionOnly={motionOnly}
|
||||
updateFilter={updateFilter}
|
||||
motionPreviewsCamera={motionPreviewsCamera}
|
||||
setMotionPreviewsCamera={setMotionPreviewsCamera}
|
||||
setMotionSearchCamera={setMotionSearchCamera}
|
||||
emptyCardData={emptyCardData}
|
||||
onOpenRecording={onOpenRecording}
|
||||
/>
|
||||
@@ -898,10 +958,16 @@ type MotionReviewProps = {
|
||||
significant_motion: ReviewSegment[];
|
||||
};
|
||||
relevantPreviews?: Preview[];
|
||||
reviewSummary?: ReviewSummary;
|
||||
recordingsSummary?: RecordingsSummary;
|
||||
timeRange: TimeRange;
|
||||
startTime?: number;
|
||||
filter?: ReviewFilter;
|
||||
motionOnly?: boolean;
|
||||
updateFilter: (filter: ReviewFilter) => void;
|
||||
motionPreviewsCamera: string | null;
|
||||
setMotionPreviewsCamera: (camera: string | null) => void;
|
||||
setMotionSearchCamera: (camera: string) => void;
|
||||
emptyCardData: EmptyCardData;
|
||||
onOpenRecording: (data: RecordingStartingPoint) => void;
|
||||
};
|
||||
@@ -909,13 +975,20 @@ function MotionReview({
|
||||
contentRef,
|
||||
reviewItems,
|
||||
relevantPreviews,
|
||||
reviewSummary,
|
||||
recordingsSummary,
|
||||
timeRange,
|
||||
startTime,
|
||||
filter,
|
||||
motionOnly = false,
|
||||
updateFilter,
|
||||
motionPreviewsCamera,
|
||||
setMotionPreviewsCamera,
|
||||
setMotionSearchCamera,
|
||||
emptyCardData,
|
||||
onOpenRecording,
|
||||
}: MotionReviewProps) {
|
||||
const { t } = useTranslation(["views/events", "common"]);
|
||||
const segmentDuration = 30;
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
@@ -961,6 +1034,15 @@ function MotionReview({
|
||||
},
|
||||
]);
|
||||
|
||||
const { data: overlapReviewSegments } = useSWR<ReviewSegment[]>([
|
||||
"review",
|
||||
{
|
||||
before: alignedBefore,
|
||||
after: alignedAfter,
|
||||
cameras: filter?.cameras?.join(",") ?? null,
|
||||
},
|
||||
]);
|
||||
|
||||
// timeline time
|
||||
|
||||
const timeRangeSegments = useMemo(
|
||||
@@ -973,19 +1055,29 @@ function MotionReview({
|
||||
return timeRangeSegments.ranges.length - 1;
|
||||
}
|
||||
|
||||
return timeRangeSegments.ranges.findIndex(
|
||||
const index = timeRangeSegments.ranges.findIndex(
|
||||
(seg) => seg.after <= startTime && seg.before >= startTime,
|
||||
);
|
||||
|
||||
if (index === -1) {
|
||||
return timeRangeSegments.ranges.length - 1;
|
||||
}
|
||||
|
||||
return index;
|
||||
// only render once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const [selectedRangeIdx, setSelectedRangeIdx] = useState(initialIndex);
|
||||
const [currentTime, setCurrentTime] = useState<number>(
|
||||
startTime ?? timeRangeSegments.ranges[selectedRangeIdx]?.before,
|
||||
startTime ??
|
||||
timeRangeSegments.ranges[selectedRangeIdx]?.before ??
|
||||
timeRangeSegments.end,
|
||||
);
|
||||
const currentTimeRange = useMemo(
|
||||
() => timeRangeSegments.ranges[selectedRangeIdx],
|
||||
() =>
|
||||
timeRangeSegments.ranges[selectedRangeIdx] ??
|
||||
timeRangeSegments.ranges[timeRangeSegments.ranges.length - 1],
|
||||
[selectedRangeIdx, timeRangeSegments],
|
||||
);
|
||||
|
||||
@@ -1023,18 +1115,86 @@ function MotionReview({
|
||||
|
||||
const [playbackRate, setPlaybackRate] = useState(8);
|
||||
const [controlsOpen, setControlsOpen] = useState(false);
|
||||
const [dimStrength, setDimStrength] = useState(82);
|
||||
const [isPreviewSettingsOpen, setIsPreviewSettingsOpen] = useState(false);
|
||||
|
||||
const objectReviewItems = useMemo(
|
||||
() =>
|
||||
(overlapReviewSegments ?? []).filter(
|
||||
(item) =>
|
||||
item.severity === "alert" ||
|
||||
item.severity === "detection" ||
|
||||
(item.data.detections?.length ?? 0) > 0 ||
|
||||
(item.data.objects?.length ?? 0) > 0,
|
||||
),
|
||||
[overlapReviewSegments],
|
||||
);
|
||||
|
||||
const nextTimestamp = useCameraMotionNextTimestamp(
|
||||
timeRangeSegments.end,
|
||||
segmentDuration,
|
||||
motionOnly,
|
||||
reviewItems?.all ?? [],
|
||||
objectReviewItems,
|
||||
motionData ?? [],
|
||||
currentTime,
|
||||
);
|
||||
|
||||
const timeoutIdRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const selectedMotionPreviewCamera = useMemo(
|
||||
() =>
|
||||
reviewCameras.find((camera) => camera.name === motionPreviewsCamera) ??
|
||||
null,
|
||||
[motionPreviewsCamera, reviewCameras],
|
||||
);
|
||||
|
||||
const onUpdateSelectedDay = useCallback(
|
||||
(day?: Date) => {
|
||||
updateFilter({
|
||||
...filter,
|
||||
after: day == undefined ? undefined : day.getTime() / 1000,
|
||||
before: day == undefined ? undefined : getEndOfDayTimestamp(day),
|
||||
});
|
||||
},
|
||||
[filter, updateFilter],
|
||||
);
|
||||
|
||||
const selectedCameraMotionData = useMemo(() => {
|
||||
if (!motionPreviewsCamera) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (motionData ?? []).filter((item) => {
|
||||
const cameras = item.camera.split(",").map((camera) => camera.trim());
|
||||
return cameras.includes(motionPreviewsCamera);
|
||||
});
|
||||
}, [motionData, motionPreviewsCamera]);
|
||||
|
||||
const selectedCameraReviewItems = useMemo(() => {
|
||||
if (!motionPreviewsCamera) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return objectReviewItems.filter(
|
||||
(item) => item.camera === motionPreviewsCamera,
|
||||
);
|
||||
}, [motionPreviewsCamera, objectReviewItems]);
|
||||
|
||||
const motionPreviewRanges = useCameraMotionOnlyRanges(
|
||||
segmentDuration,
|
||||
selectedCameraReviewItems,
|
||||
selectedCameraMotionData,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
motionPreviewsCamera &&
|
||||
!reviewCameras.some((camera) => camera.name === motionPreviewsCamera)
|
||||
) {
|
||||
setMotionPreviewsCamera(null);
|
||||
}
|
||||
}, [motionPreviewsCamera, reviewCameras, setMotionPreviewsCamera]);
|
||||
|
||||
useEffect(() => {
|
||||
if (nextTimestamp) {
|
||||
if (!playing && timeoutIdRef.current != null) {
|
||||
@@ -1124,132 +1284,349 @@ function MotionReview({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4">
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn(
|
||||
"no-scrollbar grid w-full grid-cols-1",
|
||||
isMobile && "landscape:grid-cols-2",
|
||||
reviewCameras.length > 3 &&
|
||||
isMobile &&
|
||||
"portrait:md:grid-cols-2 landscape:md:grid-cols-3",
|
||||
isDesktop && "grid-cols-2 lg:grid-cols-3",
|
||||
"gap-2 overflow-auto px-1 md:mx-2 md:gap-4 xl:grid-cols-3 3xl:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{reviewCameras.map((camera) => {
|
||||
let grow;
|
||||
let spans;
|
||||
const aspectRatio = camera.detect.width / camera.detect.height;
|
||||
if (aspectRatio > 2) {
|
||||
grow = "aspect-wide";
|
||||
spans = "sm:col-span-2";
|
||||
} else if (aspectRatio < 1) {
|
||||
grow = "h-full aspect-tall";
|
||||
spans = "md:row-span-2";
|
||||
} else {
|
||||
grow = "aspect-video";
|
||||
}
|
||||
const detectionType = getDetectionType(camera.name);
|
||||
return (
|
||||
<div key={camera.name} className={`relative ${spans}`}>
|
||||
{motionData ? (
|
||||
<>
|
||||
<PreviewPlayer
|
||||
className={`rounded-lg md:rounded-2xl ${spans} ${grow}`}
|
||||
camera={camera.name}
|
||||
timeRange={currentTimeRange}
|
||||
startTime={previewStart}
|
||||
cameraPreviews={relevantPreviews}
|
||||
isScrubbing={scrubbing}
|
||||
onControllerReady={(controller) => {
|
||||
videoPlayersRef.current[camera.name] = controller;
|
||||
}}
|
||||
onClick={() =>
|
||||
onOpenRecording({
|
||||
camera: camera.name,
|
||||
startTime: Math.min(
|
||||
currentTime,
|
||||
Date.now() / 1000 - 30,
|
||||
),
|
||||
severity: "significant_motion",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={`review-item-ring pointer-events-none absolute inset-0 z-20 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${detectionType ? `outline-severity_${detectionType} shadow-severity_${detectionType}` : "outline-transparent duration-500"}`}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Skeleton
|
||||
className={`size-full rounded-lg md:rounded-2xl ${spans} ${grow}`}
|
||||
/>
|
||||
{motionPreviewsCamera && selectedMotionPreviewCamera ? (
|
||||
<>
|
||||
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:px-3">
|
||||
<Button
|
||||
className="flex items-center gap-2.5 rounded-lg"
|
||||
aria-label={t("label.back", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={() => setMotionPreviewsCamera(null)}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isDesktop && (
|
||||
<CalendarFilterButton
|
||||
reviewSummary={reviewSummary}
|
||||
recordingsSummary={recordingsSummary}
|
||||
day={
|
||||
filter?.after == undefined
|
||||
? undefined
|
||||
: new Date(filter.after * 1000)
|
||||
}
|
||||
updateSelectedDay={onUpdateSelectedDay}
|
||||
/>
|
||||
)}
|
||||
<PlatformAwareDialog
|
||||
trigger={
|
||||
<Button
|
||||
className={cn(
|
||||
isDesktop ? "flex items-center gap-2" : "rounded-lg",
|
||||
)}
|
||||
size="sm"
|
||||
aria-label={
|
||||
isDesktop
|
||||
? t("motionPreviews.mobileSettingsTitle")
|
||||
: t("filters", { ns: "views/recording" })
|
||||
}
|
||||
>
|
||||
<FaCog className="text-secondary-foreground" />
|
||||
{isDesktop && t("motionPreviews.mobileSettingsTitle")}
|
||||
</Button>
|
||||
}
|
||||
content={
|
||||
<div className="space-y-4 py-2">
|
||||
{!isDesktop && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-md">
|
||||
{t("motionPreviews.mobileSettingsTitle")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("motionPreviews.mobileSettingsDesc")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">
|
||||
{t("motionPreviews.speed")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("motionPreviews.speedDesc")}
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={String(playbackRate)}
|
||||
onValueChange={(value) =>
|
||||
setPlaybackRate(Number(value))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-10 w-full"
|
||||
aria-label={t("motionPreviews.speedAria")}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[4, 8, 12, 16].map((speed) => (
|
||||
<SelectItem key={speed} value={String(speed)}>
|
||||
{speed}x
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">{t("motionPreviews.dim")}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("motionPreviews.dimDesc")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-1 py-2">
|
||||
<VolumeSlider
|
||||
className="w-full"
|
||||
min={25}
|
||||
max={95}
|
||||
step={1}
|
||||
value={[dimStrength]}
|
||||
aria-label={t("motionPreviews.dimAria")}
|
||||
onValueChange={(values) => {
|
||||
const nextValue = values[0];
|
||||
if (nextValue == undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDimStrength(nextValue);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isDesktop && (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
|
||||
<div className="flex w-full flex-row justify-center">
|
||||
<ReviewActivityCalendar
|
||||
recordingsSummary={recordingsSummary}
|
||||
selectedDay={
|
||||
filter?.after == undefined
|
||||
? undefined
|
||||
: new Date(filter.after * 1000)
|
||||
}
|
||||
onSelect={(day) => {
|
||||
onUpdateSelectedDay(day);
|
||||
setIsPreviewSettingsOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center p-2">
|
||||
<Button
|
||||
aria-label={t("button.reset", { ns: "common" })}
|
||||
onClick={() => {
|
||||
onUpdateSelectedDay(undefined);
|
||||
setIsPreviewSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("button.reset", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
contentClassName={cn(
|
||||
isDesktop
|
||||
? "w-80"
|
||||
: "scrollbar-container max-h-[75dvh] overflow-y-auto overflow-x-hidden px-4",
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="no-scrollbar w-[55px] overflow-y-auto md:w-[100px]">
|
||||
{motionData ? (
|
||||
<MotionReviewTimeline
|
||||
segmentDuration={segmentDuration}
|
||||
timestampSpread={15}
|
||||
timelineStart={timeRangeSegments.end}
|
||||
timelineEnd={timeRangeSegments.start}
|
||||
motionOnly={motionOnly}
|
||||
showHandlebar
|
||||
handlebarTime={currentTime}
|
||||
setHandlebarTime={setCurrentTime}
|
||||
events={reviewItems?.all ?? []}
|
||||
motion_events={motionData ?? []}
|
||||
open={isPreviewSettingsOpen}
|
||||
onOpenChange={setIsPreviewSettingsOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MotionPreviewsPane
|
||||
camera={selectedMotionPreviewCamera}
|
||||
contentRef={contentRef}
|
||||
onHandlebarDraggingChange={(scrubbing) => {
|
||||
if (playing && scrubbing) {
|
||||
setPlaying(false);
|
||||
}
|
||||
|
||||
setScrubbing(scrubbing);
|
||||
cameraPreviews={relevantPreviews}
|
||||
motionRanges={motionPreviewRanges}
|
||||
isLoadingMotionRanges={
|
||||
motionData == undefined || overlapReviewSegments == undefined
|
||||
}
|
||||
playbackRate={playbackRate}
|
||||
nonMotionAlpha={dimStrength / 100}
|
||||
onSeek={(timestamp) => {
|
||||
onOpenRecording({
|
||||
camera: selectedMotionPreviewCamera.name,
|
||||
startTime: timestamp,
|
||||
severity: "significant_motion",
|
||||
});
|
||||
}}
|
||||
dense={isMobileOnly}
|
||||
isZooming={false}
|
||||
zoomDirection={null}
|
||||
alwaysShowMotionLine={true}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="size-full" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="no-scrollbar flex min-w-0 flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4">
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn(
|
||||
"no-scrollbar grid w-full grid-cols-1",
|
||||
isMobile && "landscape:grid-cols-2",
|
||||
reviewCameras.length > 3 &&
|
||||
isMobile &&
|
||||
"portrait:md:grid-cols-2 landscape:md:grid-cols-3",
|
||||
isDesktop && "grid-cols-2 lg:grid-cols-3",
|
||||
"gap-2 overflow-auto px-1 md:mx-2 md:gap-4 xl:grid-cols-3 3xl:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{reviewCameras.map((camera) => {
|
||||
let grow;
|
||||
let spans;
|
||||
const aspectRatio = camera.detect.width / camera.detect.height;
|
||||
if (aspectRatio > 2) {
|
||||
grow = "aspect-wide";
|
||||
spans = "sm:col-span-2";
|
||||
} else if (aspectRatio < 1) {
|
||||
grow = "h-full aspect-tall";
|
||||
spans = "md:row-span-2";
|
||||
} else {
|
||||
grow = "aspect-video";
|
||||
}
|
||||
const detectionType = getDetectionType(camera.name);
|
||||
return (
|
||||
<div key={camera.name} className={`relative ${spans}`}>
|
||||
{motionData ? (
|
||||
<>
|
||||
<PreviewPlayer
|
||||
className={`rounded-lg md:rounded-2xl ${spans} ${grow}`}
|
||||
camera={camera.name}
|
||||
timeRange={currentTimeRange}
|
||||
startTime={previewStart}
|
||||
cameraPreviews={relevantPreviews}
|
||||
isScrubbing={scrubbing}
|
||||
onControllerReady={(controller) => {
|
||||
videoPlayersRef.current[camera.name] = controller;
|
||||
}}
|
||||
onClick={() =>
|
||||
onOpenRecording({
|
||||
camera: camera.name,
|
||||
startTime: Math.min(
|
||||
currentTime,
|
||||
Date.now() / 1000 - 30,
|
||||
),
|
||||
severity: "significant_motion",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={`review-item-ring pointer-events-none absolute inset-0 z-20 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${detectionType ? `outline-severity_${detectionType} shadow-severity_${detectionType}` : "outline-transparent duration-500"}`}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 z-30">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<BlurredIconButton
|
||||
aria-label={t("motionSearch.openMenu")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<FiMoreVertical className="size-5" />
|
||||
</BlurredIconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMotionPreviewsCamera(camera.name);
|
||||
}}
|
||||
>
|
||||
{t("motionPreviews.menuItem")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMotionSearchCamera(camera.name);
|
||||
}}
|
||||
>
|
||||
{t("motionSearch.menuItem")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Skeleton
|
||||
className={`size-full rounded-lg md:rounded-2xl ${spans} ${grow}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!selectedMotionPreviewCamera && (
|
||||
<div className="no-scrollbar w-[55px] overflow-y-auto md:w-[100px]">
|
||||
{motionData ? (
|
||||
<MotionReviewTimeline
|
||||
segmentDuration={segmentDuration}
|
||||
timestampSpread={15}
|
||||
timelineStart={timeRangeSegments.end}
|
||||
timelineEnd={timeRangeSegments.start}
|
||||
motionOnly={motionOnly}
|
||||
showHandlebar
|
||||
handlebarTime={currentTime}
|
||||
setHandlebarTime={setCurrentTime}
|
||||
events={reviewItems?.all ?? []}
|
||||
motion_events={motionData ?? []}
|
||||
contentRef={contentRef}
|
||||
onHandlebarDraggingChange={(scrubbing) => {
|
||||
if (playing && scrubbing) {
|
||||
setPlaying(false);
|
||||
}
|
||||
|
||||
<VideoControls
|
||||
className="absolute bottom-16 left-1/2 -translate-x-1/2 bg-secondary"
|
||||
features={{
|
||||
volume: false,
|
||||
seek: true,
|
||||
playbackRate: true,
|
||||
fullscreen: false,
|
||||
}}
|
||||
isPlaying={playing}
|
||||
show={!scrubbing || controlsOpen}
|
||||
playbackRates={[4, 8, 12, 16]}
|
||||
playbackRate={playbackRate}
|
||||
setControlsOpen={setControlsOpen}
|
||||
onPlayPause={setPlaying}
|
||||
onSeek={(diff) => {
|
||||
const wasPlaying = playing;
|
||||
setScrubbing(scrubbing);
|
||||
}}
|
||||
dense={isMobileOnly}
|
||||
isZooming={false}
|
||||
zoomDirection={null}
|
||||
alwaysShowMotionLine={true}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="size-full" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
if (wasPlaying) {
|
||||
setPlaying(false);
|
||||
}
|
||||
{!selectedMotionPreviewCamera && (
|
||||
<VideoControls
|
||||
className="absolute bottom-16 left-1/2 -translate-x-1/2 bg-secondary"
|
||||
features={{
|
||||
volume: false,
|
||||
seek: true,
|
||||
playbackRate: true,
|
||||
fullscreen: false,
|
||||
}}
|
||||
isPlaying={playing}
|
||||
show={!scrubbing || controlsOpen}
|
||||
playbackRates={[4, 8, 12, 16]}
|
||||
playbackRate={playbackRate}
|
||||
setControlsOpen={setControlsOpen}
|
||||
onPlayPause={setPlaying}
|
||||
onSeek={(diff) => {
|
||||
const wasPlaying = playing;
|
||||
|
||||
setCurrentTime(currentTime + diff);
|
||||
if (wasPlaying) {
|
||||
setPlaying(false);
|
||||
}
|
||||
|
||||
if (wasPlaying) {
|
||||
setTimeout(() => setPlaying(true), 100);
|
||||
}
|
||||
}}
|
||||
onSetPlaybackRate={setPlaybackRate}
|
||||
/>
|
||||
setCurrentTime(currentTime + diff);
|
||||
|
||||
if (wasPlaying) {
|
||||
setTimeout(() => setPlaying(true), 100);
|
||||
}
|
||||
}}
|
||||
onSetPlaybackRate={setPlaybackRate}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,898 @@
|
||||
import { MotionOnlyRange } from "@/hooks/use-camera-activity";
|
||||
import { Preview } from "@/types/preview";
|
||||
import {
|
||||
MutableRefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { isCurrentHour } from "@/utils/dateUtil";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CameraConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { Recording } from "@/types/record";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import TimeAgo from "@/components/dynamic/TimeAgo";
|
||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
|
||||
const MOTION_HEATMAP_GRID_SIZE = 16;
|
||||
const MIN_MOTION_CELL_ALPHA = 0.06;
|
||||
|
||||
function getPreviewForMotionRange(
|
||||
cameraPreviews: Preview[],
|
||||
cameraName: string,
|
||||
range: MotionOnlyRange,
|
||||
) {
|
||||
const matchingPreviews = cameraPreviews.filter(
|
||||
(preview) =>
|
||||
preview.camera === cameraName &&
|
||||
preview.end > range.start_time &&
|
||||
preview.start < range.end_time,
|
||||
);
|
||||
|
||||
if (!matchingPreviews.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const getOverlap = (preview: Preview) =>
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(preview.end, range.end_time) -
|
||||
Math.max(preview.start, range.start_time),
|
||||
);
|
||||
|
||||
return matchingPreviews.reduce((best, current) => {
|
||||
return getOverlap(current) > getOverlap(best) ? current : best;
|
||||
});
|
||||
}
|
||||
|
||||
function getRangeOverlapSeconds(
|
||||
rangeStart: number,
|
||||
rangeEnd: number,
|
||||
recordingStart: number,
|
||||
recordingEnd: number,
|
||||
) {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(rangeEnd, recordingEnd) - Math.max(rangeStart, recordingStart),
|
||||
);
|
||||
}
|
||||
|
||||
function getMotionHeatmapForRange(
|
||||
recordings: Recording[],
|
||||
range: MotionOnlyRange,
|
||||
) {
|
||||
const weightedHeatmap = new Map<number, number>();
|
||||
let totalWeight = 0;
|
||||
|
||||
recordings.forEach((recording) => {
|
||||
const overlapSeconds = getRangeOverlapSeconds(
|
||||
range.start_time,
|
||||
range.end_time,
|
||||
recording.start_time,
|
||||
recording.end_time,
|
||||
);
|
||||
|
||||
if (overlapSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
totalWeight += overlapSeconds;
|
||||
|
||||
if (!recording.motion_heatmap) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(recording.motion_heatmap).forEach(
|
||||
([cellIndex, intensity]) => {
|
||||
const index = Number(cellIndex);
|
||||
const level = Number(intensity);
|
||||
|
||||
if (Number.isNaN(index) || Number.isNaN(level) || level <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWeight = weightedHeatmap.get(index) ?? 0;
|
||||
weightedHeatmap.set(index, existingWeight + level * overlapSeconds);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (!totalWeight || weightedHeatmap.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mergedHeatmap: Record<string, number> = {};
|
||||
weightedHeatmap.forEach((weightedLevel, index) => {
|
||||
const normalizedLevel = Math.max(
|
||||
0,
|
||||
Math.min(255, Math.round(weightedLevel / totalWeight)),
|
||||
);
|
||||
|
||||
if (normalizedLevel > 0) {
|
||||
mergedHeatmap[index.toString()] = normalizedLevel;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(mergedHeatmap).length > 0 ? mergedHeatmap : null;
|
||||
}
|
||||
|
||||
type MotionPreviewClipProps = {
|
||||
cameraName: string;
|
||||
range: MotionOnlyRange;
|
||||
playbackRate: number;
|
||||
preview?: Preview;
|
||||
fallbackFrameTimes?: number[];
|
||||
motionHeatmap?: Record<string, number> | null;
|
||||
nonMotionAlpha: number;
|
||||
isVisible: boolean;
|
||||
onSeek: (timestamp: number) => void;
|
||||
};
|
||||
|
||||
function MotionPreviewClip({
|
||||
cameraName,
|
||||
range,
|
||||
playbackRate,
|
||||
preview,
|
||||
fallbackFrameTimes,
|
||||
motionHeatmap,
|
||||
nonMotionAlpha,
|
||||
isVisible,
|
||||
onSeek,
|
||||
}: MotionPreviewClipProps) {
|
||||
const { t } = useTranslation(["views/events", "common"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const dimOverlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const overlayContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [{ width: overlayWidth, height: overlayHeight }] =
|
||||
useResizeObserver(overlayContainerRef);
|
||||
const [videoLoaded, setVideoLoaded] = useState(false);
|
||||
const [videoPlaying, setVideoPlaying] = useState(false);
|
||||
const [fallbackImageLoaded, setFallbackImageLoaded] = useState(false);
|
||||
const [mediaDimensions, setMediaDimensions] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
const [fallbackFrameIndex, setFallbackFrameIndex] = useState(0);
|
||||
const [fallbackFramesReady, setFallbackFramesReady] = useState(false);
|
||||
|
||||
const formattedDate = useFormattedTimestamp(
|
||||
range.start_time,
|
||||
config?.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestampMonthDayHourMinute.24hour", {
|
||||
ns: "common",
|
||||
})
|
||||
: t("time.formattedTimestampMonthDayHourMinute.12hour", {
|
||||
ns: "common",
|
||||
}),
|
||||
config?.ui.timezone,
|
||||
);
|
||||
const fallbackFrameSrcs = useMemo(() => {
|
||||
if (!fallbackFrameTimes || fallbackFrameTimes.length === 0) {
|
||||
return [] as string[];
|
||||
}
|
||||
|
||||
return fallbackFrameTimes.map(
|
||||
(frameTime) =>
|
||||
`${baseUrl}api/preview/preview_${cameraName}-${frameTime}.webp/thumbnail.webp`,
|
||||
);
|
||||
}, [cameraName, fallbackFrameTimes]);
|
||||
|
||||
useEffect(() => {
|
||||
setFallbackFrameIndex(0);
|
||||
setFallbackFramesReady(false);
|
||||
}, [range.start_time, range.end_time, fallbackFrameTimes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fallbackFrameSrcs.length === 0) {
|
||||
setFallbackFramesReady(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const preloadFrames = async () => {
|
||||
await Promise.allSettled(
|
||||
fallbackFrameSrcs.map(
|
||||
(src) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve();
|
||||
image.onerror = () => resolve();
|
||||
image.src = src;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (!cancelled) {
|
||||
setFallbackFramesReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
void preloadFrames();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fallbackFrameSrcs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fallbackFramesReady || fallbackFrameSrcs.length <= 1 || !isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = Math.max(
|
||||
50,
|
||||
Math.round(1000 / Math.max(1, playbackRate)),
|
||||
);
|
||||
const intervalId = window.setInterval(() => {
|
||||
setFallbackFrameIndex((previous) => {
|
||||
return (previous + 1) % fallbackFrameSrcs.length;
|
||||
});
|
||||
}, intervalMs);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [fallbackFrameSrcs.length, fallbackFramesReady, isVisible, playbackRate]);
|
||||
|
||||
const fallbackFrameSrc = useMemo(() => {
|
||||
if (fallbackFrameSrcs.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return fallbackFrameSrcs[fallbackFrameIndex] ?? fallbackFrameSrcs[0];
|
||||
}, [fallbackFrameIndex, fallbackFrameSrcs]);
|
||||
|
||||
useEffect(() => {
|
||||
setVideoLoaded(false);
|
||||
setVideoPlaying(false);
|
||||
setMediaDimensions(null);
|
||||
}, [preview?.src]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preview || !isVisible || videoLoaded || !videoRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoRef.current.currentSrc || videoRef.current.error) {
|
||||
setVideoLoaded(true);
|
||||
}
|
||||
}, [isVisible, preview, videoLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
setFallbackImageLoaded(false);
|
||||
setMediaDimensions(null);
|
||||
}, [fallbackFrameSrcs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fallbackFrameSrc || !isVisible || !fallbackFramesReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFallbackImageLoaded(true);
|
||||
}, [fallbackFrameSrc, fallbackFramesReady, isVisible]);
|
||||
|
||||
const showLoadingIndicator =
|
||||
(preview != undefined && isVisible && !videoPlaying) ||
|
||||
(fallbackFrameSrc != undefined && isVisible && !fallbackImageLoaded);
|
||||
|
||||
const clipStart = useMemo(() => {
|
||||
if (!preview) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, range.start_time - preview.start);
|
||||
}, [preview, range.start_time]);
|
||||
|
||||
const clipEnd = useMemo(() => {
|
||||
if (!preview) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const previewDuration = preview.end - preview.start;
|
||||
return Math.min(
|
||||
previewDuration,
|
||||
Math.max(clipStart + 0.1, range.end_time - preview.start),
|
||||
);
|
||||
}, [clipStart, preview, range.end_time]);
|
||||
|
||||
const resetPlayback = useCallback(() => {
|
||||
if (!videoRef.current || !preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
videoRef.current.currentTime = clipStart;
|
||||
videoRef.current.playbackRate = playbackRate;
|
||||
}, [clipStart, playbackRate, preview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current || !preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isVisible) {
|
||||
videoRef.current.pause();
|
||||
videoRef.current.currentTime = clipStart;
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoRef.current.readyState >= 2) {
|
||||
resetPlayback();
|
||||
void videoRef.current.play().catch(() => undefined);
|
||||
}
|
||||
}, [clipStart, isVisible, preview, resetPlayback]);
|
||||
|
||||
const drawDimOverlay = useCallback(() => {
|
||||
if (!dimOverlayCanvasRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = dimOverlayCanvasRef.current;
|
||||
const context = canvas.getContext("2d");
|
||||
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (overlayWidth <= 0 || overlayHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = Math.max(1, overlayWidth);
|
||||
const height = Math.max(1, overlayHeight);
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const pixelWidth = Math.max(1, Math.round(width * dpr));
|
||||
const pixelHeight = Math.max(1, Math.round(height * dpr));
|
||||
|
||||
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
|
||||
canvas.width = pixelWidth;
|
||||
canvas.height = pixelHeight;
|
||||
}
|
||||
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
|
||||
if (!motionHeatmap) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate the actual rendered media area (object-contain letterboxing)
|
||||
let drawX = 0;
|
||||
let drawY = 0;
|
||||
let drawWidth = width;
|
||||
let drawHeight = height;
|
||||
|
||||
if (
|
||||
mediaDimensions &&
|
||||
mediaDimensions.width > 0 &&
|
||||
mediaDimensions.height > 0
|
||||
) {
|
||||
const containerAspect = width / height;
|
||||
const mediaAspect = mediaDimensions.width / mediaDimensions.height;
|
||||
|
||||
if (mediaAspect < containerAspect) {
|
||||
// Portrait / tall: constrained by height, bars on left and right
|
||||
drawHeight = height;
|
||||
drawWidth = height * mediaAspect;
|
||||
drawX = (width - drawWidth) / 2;
|
||||
drawY = 0;
|
||||
} else {
|
||||
// Wide / landscape: constrained by width, bars on top and bottom
|
||||
drawWidth = width;
|
||||
drawHeight = width / mediaAspect;
|
||||
drawX = 0;
|
||||
drawY = (height - drawHeight) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
const heatmapLevels = Object.values(motionHeatmap)
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0);
|
||||
|
||||
const maxHeatmapLevel =
|
||||
heatmapLevels.length > 0 ? Math.max(...heatmapLevels) : 0;
|
||||
|
||||
const maskCanvas = document.createElement("canvas");
|
||||
maskCanvas.width = MOTION_HEATMAP_GRID_SIZE;
|
||||
maskCanvas.height = MOTION_HEATMAP_GRID_SIZE;
|
||||
|
||||
const maskContext = maskCanvas.getContext("2d");
|
||||
if (!maskContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageData = maskContext.createImageData(
|
||||
MOTION_HEATMAP_GRID_SIZE,
|
||||
MOTION_HEATMAP_GRID_SIZE,
|
||||
);
|
||||
|
||||
for (let index = 0; index < MOTION_HEATMAP_GRID_SIZE ** 2; index++) {
|
||||
const level = Number(motionHeatmap[index.toString()] ?? 0);
|
||||
const normalizedLevel =
|
||||
maxHeatmapLevel > 0
|
||||
? Math.min(1, Math.max(0, level / maxHeatmapLevel))
|
||||
: 0;
|
||||
const boostedLevel = Math.sqrt(normalizedLevel);
|
||||
const alpha =
|
||||
nonMotionAlpha -
|
||||
boostedLevel * (nonMotionAlpha - MIN_MOTION_CELL_ALPHA);
|
||||
|
||||
const pixelOffset = index * 4;
|
||||
imageData.data[pixelOffset] = 0;
|
||||
imageData.data[pixelOffset + 1] = 0;
|
||||
imageData.data[pixelOffset + 2] = 0;
|
||||
imageData.data[pixelOffset + 3] = Math.round(
|
||||
Math.max(0, Math.min(1, alpha)) * 255,
|
||||
);
|
||||
}
|
||||
|
||||
maskContext.putImageData(imageData, 0, 0);
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = "high";
|
||||
context.drawImage(maskCanvas, drawX, drawY, drawWidth, drawHeight);
|
||||
}, [
|
||||
motionHeatmap,
|
||||
nonMotionAlpha,
|
||||
overlayHeight,
|
||||
overlayWidth,
|
||||
mediaDimensions,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
drawDimOverlay();
|
||||
}, [drawDimOverlay]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayContainerRef}
|
||||
className="relative aspect-video size-full cursor-pointer overflow-hidden rounded-lg bg-black md:rounded-2xl"
|
||||
onClick={() => onSeek(range.start_time)}
|
||||
>
|
||||
{showLoadingIndicator && (
|
||||
<Skeleton className="absolute inset-0 z-10 rounded-lg md:rounded-2xl" />
|
||||
)}
|
||||
{preview ? (
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="size-full bg-black object-contain"
|
||||
playsInline
|
||||
preload={isVisible ? "metadata" : "none"}
|
||||
muted
|
||||
autoPlay={isVisible}
|
||||
onLoadedMetadata={() => {
|
||||
setVideoLoaded(true);
|
||||
|
||||
if (videoRef.current) {
|
||||
setMediaDimensions({
|
||||
width: videoRef.current.videoWidth,
|
||||
height: videoRef.current.videoHeight,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetPlayback();
|
||||
|
||||
if (videoRef.current) {
|
||||
void videoRef.current.play().catch(() => undefined);
|
||||
}
|
||||
}}
|
||||
onCanPlay={() => {
|
||||
setVideoLoaded(true);
|
||||
|
||||
if (!isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoRef.current) {
|
||||
void videoRef.current.play().catch(() => undefined);
|
||||
}
|
||||
}}
|
||||
onPlay={() => setVideoPlaying(true)}
|
||||
onLoadedData={() => setVideoLoaded(true)}
|
||||
onError={() => {
|
||||
setVideoLoaded(true);
|
||||
setVideoPlaying(true);
|
||||
}}
|
||||
onTimeUpdate={() => {
|
||||
if (!videoRef.current || !preview || !isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoRef.current.currentTime >= clipEnd) {
|
||||
videoRef.current.currentTime = clipStart;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isVisible && (
|
||||
<source
|
||||
src={`${baseUrl}${preview.src.substring(1)}`}
|
||||
type={preview.type}
|
||||
/>
|
||||
)}
|
||||
</video>
|
||||
{motionHeatmap && (
|
||||
<canvas
|
||||
ref={dimOverlayCanvasRef}
|
||||
className="pointer-events-none absolute inset-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : fallbackFrameSrc ? (
|
||||
<>
|
||||
<img
|
||||
src={fallbackFrameSrc}
|
||||
className="size-full bg-black object-contain"
|
||||
loading="lazy"
|
||||
alt=""
|
||||
onLoad={(e) => {
|
||||
setFallbackImageLoaded(true);
|
||||
const img = e.currentTarget;
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
setMediaDimensions({
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onError={() => setFallbackImageLoaded(true)}
|
||||
/>
|
||||
{motionHeatmap && (
|
||||
<canvas
|
||||
ref={dimOverlayCanvasRef}
|
||||
className="pointer-events-none absolute inset-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t("motionPreviews.noPreview")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-30 p-2">
|
||||
<div className="flex flex-col items-start text-xs text-white/90 drop-shadow-lg">
|
||||
{range.end_time ? (
|
||||
<TimeAgo time={range.start_time * 1000} dense />
|
||||
) : (
|
||||
<ActivityIndicator size={14} />
|
||||
)}
|
||||
{formattedDate}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MotionPreviewsPaneProps = {
|
||||
camera: CameraConfig;
|
||||
contentRef: MutableRefObject<HTMLDivElement | null>;
|
||||
cameraPreviews: Preview[];
|
||||
motionRanges: MotionOnlyRange[];
|
||||
isLoadingMotionRanges?: boolean;
|
||||
playbackRate: number;
|
||||
nonMotionAlpha: number;
|
||||
onSeek: (timestamp: number) => void;
|
||||
};
|
||||
|
||||
export default function MotionPreviewsPane({
|
||||
camera,
|
||||
contentRef,
|
||||
cameraPreviews,
|
||||
motionRanges,
|
||||
isLoadingMotionRanges = false,
|
||||
playbackRate,
|
||||
nonMotionAlpha,
|
||||
onSeek,
|
||||
}: MotionPreviewsPaneProps) {
|
||||
const { t } = useTranslation(["views/events"]);
|
||||
const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [windowVisible, setWindowVisible] = useState(true);
|
||||
useEffect(() => {
|
||||
const visibilityListener = () => {
|
||||
setWindowVisible(document.visibilityState == "visible");
|
||||
};
|
||||
|
||||
addEventListener("visibilitychange", visibilityListener);
|
||||
|
||||
return () => {
|
||||
removeEventListener("visibilitychange", visibilityListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [visibleClips, setVisibleClips] = useState<string[]>([]);
|
||||
const [hasVisibilityData, setHasVisibilityData] = useState(false);
|
||||
const clipObserver = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
const recordingTimeRange = useMemo(() => {
|
||||
if (!motionRanges.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return motionRanges.reduce(
|
||||
(bounds, range) => ({
|
||||
after: Math.min(bounds.after, range.start_time),
|
||||
before: Math.max(bounds.before, range.end_time),
|
||||
}),
|
||||
{
|
||||
after: motionRanges[0].start_time,
|
||||
before: motionRanges[0].end_time,
|
||||
},
|
||||
);
|
||||
}, [motionRanges]);
|
||||
|
||||
const { data: cameraRecordings } = useSWR<Recording[]>(
|
||||
recordingTimeRange
|
||||
? [
|
||||
`${camera.name}/recordings`,
|
||||
{
|
||||
after: Math.floor(recordingTimeRange.after),
|
||||
before: Math.ceil(recordingTimeRange.before),
|
||||
},
|
||||
]
|
||||
: null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
},
|
||||
);
|
||||
const { data: previewFrames } = useSWR<string[]>(
|
||||
recordingTimeRange
|
||||
? `preview/${camera.name}/start/${Math.floor(recordingTimeRange.after)}/end/${Math.ceil(recordingTimeRange.before)}/frames`
|
||||
: null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
},
|
||||
);
|
||||
|
||||
const previewFrameTimes = useMemo(() => {
|
||||
if (!previewFrames) {
|
||||
return [] as number[];
|
||||
}
|
||||
|
||||
return previewFrames
|
||||
.map((frame) => {
|
||||
const timestampPart = frame.split("-").at(-1)?.replace(".webp", "");
|
||||
return timestampPart ? Number(timestampPart) : NaN;
|
||||
})
|
||||
.filter((value) => Number.isFinite(value))
|
||||
.sort((a, b) => a - b);
|
||||
}, [previewFrames]);
|
||||
|
||||
const getFallbackFrameTimesForRange = useCallback(
|
||||
(range: MotionOnlyRange) => {
|
||||
if (!isCurrentHour(range.end_time) || previewFrameTimes.length === 0) {
|
||||
return [] as number[];
|
||||
}
|
||||
|
||||
const inRangeFrames = previewFrameTimes.filter(
|
||||
(frameTime) =>
|
||||
frameTime >= range.start_time && frameTime <= range.end_time,
|
||||
);
|
||||
|
||||
// Use all in-range frames when enough data exists for natural animation
|
||||
if (inRangeFrames.length > 1) {
|
||||
return inRangeFrames;
|
||||
}
|
||||
|
||||
// If sparse, keep the single in-range frame and add only the next 2 frames
|
||||
if (inRangeFrames.length === 1) {
|
||||
const inRangeFrame = inRangeFrames[0];
|
||||
const nextFrames = previewFrameTimes
|
||||
.filter((frameTime) => frameTime > inRangeFrame)
|
||||
.slice(0, 2);
|
||||
|
||||
return [inRangeFrame, ...nextFrames];
|
||||
}
|
||||
|
||||
const nextFramesFromStart = previewFrameTimes
|
||||
.filter((frameTime) => frameTime >= range.start_time)
|
||||
.slice(0, 3);
|
||||
// If no in-range frame exists, take up to 3 frames starting at clip start
|
||||
if (nextFramesFromStart.length > 0) {
|
||||
return nextFramesFromStart;
|
||||
}
|
||||
|
||||
const lastFrame = previewFrameTimes.at(-1);
|
||||
return lastFrame != undefined ? [lastFrame] : [];
|
||||
},
|
||||
[previewFrameTimes],
|
||||
);
|
||||
|
||||
const setContentNode = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
contentRef.current = node;
|
||||
setScrollContainer(node);
|
||||
},
|
||||
[contentRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleClipIds = new Set<string>();
|
||||
clipObserver.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
setHasVisibilityData(true);
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const clipId = (entry.target as HTMLElement).dataset.clipId;
|
||||
|
||||
if (!clipId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
visibleClipIds.add(clipId);
|
||||
} else {
|
||||
visibleClipIds.delete(clipId);
|
||||
}
|
||||
});
|
||||
|
||||
const rootRect = scrollContainer.getBoundingClientRect();
|
||||
const prunedVisibleClipIds = [...visibleClipIds].filter((clipId) => {
|
||||
const clipElement = scrollContainer.querySelector<HTMLElement>(
|
||||
`[data-clip-id="${clipId}"]`,
|
||||
);
|
||||
|
||||
if (!clipElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const clipRect = clipElement.getBoundingClientRect();
|
||||
|
||||
return (
|
||||
clipRect.bottom > rootRect.top && clipRect.top < rootRect.bottom
|
||||
);
|
||||
});
|
||||
|
||||
setVisibleClips(prunedVisibleClipIds);
|
||||
},
|
||||
{
|
||||
root: scrollContainer,
|
||||
threshold: 0,
|
||||
},
|
||||
);
|
||||
|
||||
scrollContainer
|
||||
.querySelectorAll<HTMLElement>("[data-clip-id]")
|
||||
.forEach((node) => {
|
||||
clipObserver.current?.observe(node);
|
||||
});
|
||||
|
||||
return () => {
|
||||
clipObserver.current?.disconnect();
|
||||
};
|
||||
}, [scrollContainer]);
|
||||
|
||||
const clipRef = useCallback((node: HTMLElement | null) => {
|
||||
if (!clipObserver.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (node) {
|
||||
clipObserver.current.observe(node);
|
||||
}
|
||||
} catch {
|
||||
// no op
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clipData = useMemo(
|
||||
() =>
|
||||
motionRanges
|
||||
.filter((range) => range.end_time > range.start_time)
|
||||
.sort((left, right) => right.start_time - left.start_time)
|
||||
.map((range) => {
|
||||
const preview = getPreviewForMotionRange(
|
||||
cameraPreviews,
|
||||
camera.name,
|
||||
range,
|
||||
);
|
||||
|
||||
return {
|
||||
range,
|
||||
preview,
|
||||
fallbackFrameTimes: !preview
|
||||
? getFallbackFrameTimesForRange(range)
|
||||
: undefined,
|
||||
motionHeatmap: getMotionHeatmapForRange(
|
||||
cameraRecordings ?? [],
|
||||
range,
|
||||
),
|
||||
};
|
||||
}),
|
||||
[
|
||||
cameraPreviews,
|
||||
camera.name,
|
||||
cameraRecordings,
|
||||
getFallbackFrameTimesForRange,
|
||||
motionRanges,
|
||||
],
|
||||
);
|
||||
|
||||
const hasCurrentHourRanges = useMemo(
|
||||
() => motionRanges.some((range) => isCurrentHour(range.end_time)),
|
||||
[motionRanges],
|
||||
);
|
||||
|
||||
const isLoadingPane =
|
||||
isLoadingMotionRanges ||
|
||||
(motionRanges.length > 0 && cameraRecordings == undefined) ||
|
||||
(hasCurrentHourRanges && previewFrames == undefined);
|
||||
|
||||
if (isLoadingPane) {
|
||||
return (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden px-1 md:mx-2 md:gap-4">
|
||||
<div
|
||||
ref={setContentNode}
|
||||
className="no-scrollbar min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
{clipData.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-lg text-primary">
|
||||
{t("motionPreviews.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 pb-2 sm:grid-cols-2 md:gap-4 xl:grid-cols-4">
|
||||
{clipData.map(
|
||||
({ range, preview, fallbackFrameTimes, motionHeatmap }, idx) => (
|
||||
<div
|
||||
key={`${camera.name}-${range.start_time}-${range.end_time}-${preview?.src ?? "none"}-${idx}`}
|
||||
data-clip-id={`${camera.name}-${range.start_time}-${range.end_time}-${idx}`}
|
||||
ref={clipRef}
|
||||
>
|
||||
<MotionPreviewClip
|
||||
cameraName={camera.name}
|
||||
range={range}
|
||||
playbackRate={playbackRate}
|
||||
preview={preview}
|
||||
fallbackFrameTimes={fallbackFrameTimes}
|
||||
motionHeatmap={motionHeatmap}
|
||||
nonMotionAlpha={nonMotionAlpha}
|
||||
isVisible={
|
||||
windowVisible &&
|
||||
(visibleClips.includes(
|
||||
`${camera.name}-${range.start_time}-${range.end_time}-${idx}`,
|
||||
) ||
|
||||
(!hasVisibilityData && idx < 8))
|
||||
}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user