mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 02:08:57 +03:00
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
When a motion region filter is active, zoom each preview clip into the outer bounds of the selected cells instead of showing the full frame. Tiles take on the aspect ratio of the cropped region, clamped to avoid slivers when the selection is a single row or column, so the grid stays uniform. A "Crop to filter" switch in the preview settings turns this off and restores the previous 16:9 tiles. The transform is applied to a wrapper holding both the media and the dim overlay canvas so the motion heatmap stays registered to the pixels. Fix the region filter grid, which mapped cells onto a hardcoded 16:9 box while the snapshot was letterboxed inside it with object-contain. Heatmap cells are indexed against the detect frame, so on a 4:3 camera every painted cell was off by up to 12.5% of the frame width, and the true left and right edges of the image could only be reached by painting the black bars. The grid box now takes the camera's detect aspect ratio, capped at 65dvh tall so 4:3 and portrait cameras do not overflow the dialog.
1768 lines
57 KiB
TypeScript
1768 lines
57 KiB
TypeScript
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 { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
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";
|
|
import { Preview } from "@/types/preview";
|
|
import {
|
|
MotionData,
|
|
RecordingsSummary,
|
|
REVIEW_PADDING,
|
|
ReviewFilter,
|
|
ReviewSegment,
|
|
ReviewSeverity,
|
|
ReviewSummary,
|
|
SegmentedReviewData,
|
|
ZoomLevel,
|
|
} from "@/types/review";
|
|
import { getChunkedTimeRange } from "@/utils/timelineUtil";
|
|
import { isReplayCamera } from "@/utils/cameraUtil";
|
|
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
|
|
import axios from "axios";
|
|
import {
|
|
MutableRefObject,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
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 { baseUrl } from "@/api/baseUrl";
|
|
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";
|
|
import SummaryTimeline from "@/components/timeline/SummaryTimeline";
|
|
import { RecordingStartingPoint } from "@/types/record";
|
|
import VideoControls from "@/components/player/VideoControls";
|
|
import { TimeRange } from "@/types/timeline";
|
|
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
|
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";
|
|
import { Toaster } from "@/components/ui/sonner";
|
|
import { toast } from "sonner";
|
|
import { cn } from "@/lib/utils";
|
|
import { FilterList, LAST_24_HOURS_KEY } from "@/types/filter";
|
|
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, FaFilter } from "react-icons/fa";
|
|
import MotionRegionFilterGrid from "@/components/filter/MotionRegionFilterGrid";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
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";
|
|
|
|
type EventViewProps = {
|
|
reviewItems?: SegmentedReviewData;
|
|
currentReviewItems: ReviewSegment[] | null;
|
|
reviewSummary?: ReviewSummary;
|
|
recordingsSummary?: RecordingsSummary;
|
|
relevantPreviews?: Preview[];
|
|
timeRange: TimeRange;
|
|
filter?: ReviewFilter;
|
|
severity: ReviewSeverity;
|
|
startTime?: number;
|
|
showReviewed: boolean;
|
|
setShowReviewed: (show: boolean) => void;
|
|
setSeverity: (severity: ReviewSeverity) => void;
|
|
markItemAsReviewed: (review: ReviewSegment) => void;
|
|
markItemsAsReviewed: (
|
|
currentItems: ReviewSegment[],
|
|
itemsToMarkReviewed?: ReviewSegment[] | undefined,
|
|
) => void;
|
|
onOpenRecording: (recordingInfo: RecordingStartingPoint) => void;
|
|
motionPreviewsCamera: string | null;
|
|
setMotionPreviewsCamera: (camera: string | null) => void;
|
|
setMotionSearchCamera: (camera: string) => void;
|
|
pullLatestData: () => void;
|
|
updateFilter: (filter: ReviewFilter) => void;
|
|
};
|
|
export default function EventView({
|
|
reviewItems,
|
|
currentReviewItems,
|
|
reviewSummary,
|
|
recordingsSummary,
|
|
relevantPreviews,
|
|
timeRange,
|
|
filter,
|
|
severity,
|
|
startTime,
|
|
showReviewed,
|
|
setShowReviewed,
|
|
setSeverity,
|
|
markItemAsReviewed,
|
|
markItemsAsReviewed,
|
|
onOpenRecording,
|
|
motionPreviewsCamera,
|
|
setMotionPreviewsCamera,
|
|
setMotionSearchCamera,
|
|
pullLatestData,
|
|
updateFilter,
|
|
}: EventViewProps) {
|
|
const { t } = useTranslation(["views/events"]);
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
// review counts
|
|
|
|
const reviewCounts = useMemo(() => {
|
|
if (!reviewSummary) {
|
|
return { alert: -1, detection: -1, significant_motion: -1 };
|
|
}
|
|
|
|
let summary;
|
|
if (filter?.before == undefined) {
|
|
summary = reviewSummary[LAST_24_HOURS_KEY];
|
|
} else {
|
|
const day = new Date(filter.before * 1000);
|
|
const key = `${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`;
|
|
summary = reviewSummary[key];
|
|
}
|
|
|
|
if (!summary) {
|
|
return { alert: 0, detection: 0, significant_motion: 0 };
|
|
}
|
|
|
|
if (showReviewed) {
|
|
return {
|
|
alert: summary.total_alert ?? 0,
|
|
detection: summary.total_detection ?? 0,
|
|
};
|
|
} else {
|
|
return {
|
|
alert: summary.total_alert - summary.reviewed_alert,
|
|
detection: summary.total_detection - summary.reviewed_detection,
|
|
};
|
|
}
|
|
}, [filter, showReviewed, reviewSummary]);
|
|
|
|
const emptyCardData: EmptyCardData = useMemo(() => {
|
|
if (
|
|
!config ||
|
|
Object.values(config.cameras).find(
|
|
(cam) => cam.record.enabled_in_config,
|
|
) != undefined
|
|
) {
|
|
return {
|
|
title: t("empty." + severity.replace(/_/g, " ")),
|
|
};
|
|
}
|
|
|
|
return {
|
|
title: t("empty.recordingsDisabled.title"),
|
|
description: t("empty.recordingsDisabled.description"),
|
|
};
|
|
}, [config, severity, t]);
|
|
|
|
// review interaction
|
|
|
|
const [selectedReviews, setSelectedReviews] = useState<ReviewSegment[]>([]);
|
|
const onSelectReview = useCallback(
|
|
(review: ReviewSegment, ctrl: boolean, detail: boolean) => {
|
|
if (selectedReviews.length > 0 || ctrl) {
|
|
const index = selectedReviews.findIndex((r) => r.id === review.id);
|
|
|
|
if (index != -1) {
|
|
if (selectedReviews.length == 1) {
|
|
setSelectedReviews([]);
|
|
} else {
|
|
const copy = [
|
|
...selectedReviews.slice(0, index),
|
|
...selectedReviews.slice(index + 1),
|
|
];
|
|
setSelectedReviews(copy);
|
|
}
|
|
} else {
|
|
const copy = [...selectedReviews];
|
|
copy.push(review);
|
|
setSelectedReviews(copy);
|
|
}
|
|
} else {
|
|
// If a specific date is selected in the calendar and it's after the event start,
|
|
// use the selected date instead of the event start time
|
|
const effectiveStartTime =
|
|
timeRange.after > review.start_time
|
|
? timeRange.after
|
|
: review.start_time;
|
|
|
|
onOpenRecording({
|
|
camera: review.camera,
|
|
startTime: effectiveStartTime - REVIEW_PADDING,
|
|
severity: review.severity,
|
|
timelineType: detail ? "detail" : undefined,
|
|
});
|
|
|
|
review.has_been_reviewed = true;
|
|
markItemAsReviewed(review);
|
|
}
|
|
},
|
|
[
|
|
selectedReviews,
|
|
setSelectedReviews,
|
|
onOpenRecording,
|
|
markItemAsReviewed,
|
|
timeRange.after,
|
|
],
|
|
);
|
|
const onSelectAllReviews = useCallback(() => {
|
|
if (!currentReviewItems || currentReviewItems.length == 0) {
|
|
return;
|
|
}
|
|
|
|
if (selectedReviews.length < currentReviewItems.length) {
|
|
setSelectedReviews(currentReviewItems);
|
|
} else {
|
|
setSelectedReviews([]);
|
|
}
|
|
}, [currentReviewItems, selectedReviews]);
|
|
|
|
const exportReview = useCallback(
|
|
(id: string) => {
|
|
const review = reviewItems?.all?.find((seg) => seg.id == id);
|
|
|
|
if (!review) {
|
|
return;
|
|
}
|
|
|
|
const endTime = review.end_time
|
|
? review.end_time + REVIEW_PADDING
|
|
: Date.now() / 1000;
|
|
|
|
axios
|
|
.post(
|
|
`export/${review.camera}/start/${review.start_time - REVIEW_PADDING}/end/${endTime}`,
|
|
{ playback: "realtime", image_path: review.thumb_path },
|
|
)
|
|
.then((response) => {
|
|
if (response.status < 300) {
|
|
toast.success(
|
|
t("export.toast.success", { ns: "components/dialog" }),
|
|
{
|
|
position: "top-center",
|
|
action: (
|
|
<a
|
|
href={`${baseUrl}export`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<Button>
|
|
{t("export.toast.view", { ns: "components/dialog" })}
|
|
</Button>
|
|
</a>
|
|
),
|
|
},
|
|
);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
const errorMessage =
|
|
error.response?.data?.message ||
|
|
error.response?.data?.detail ||
|
|
"Unknown error";
|
|
toast.error(
|
|
t("export.toast.error.failed", {
|
|
ns: "components/dialog",
|
|
message: errorMessage,
|
|
}),
|
|
{
|
|
position: "top-center",
|
|
},
|
|
);
|
|
});
|
|
},
|
|
[reviewItems, t],
|
|
);
|
|
|
|
const [motionOnly, setMotionOnly] = useState(false);
|
|
const [severityToggle, setSeverityToggle] = useOptimisticState(
|
|
severity,
|
|
setSeverity,
|
|
100,
|
|
);
|
|
|
|
const motionPreviewsOpen =
|
|
severity === "significant_motion" && motionPreviewsCamera != null;
|
|
|
|
useEffect(() => {
|
|
if (severity !== "significant_motion") {
|
|
setMotionPreviewsCamera(null);
|
|
}
|
|
}, [setMotionPreviewsCamera, severity]);
|
|
|
|
// review filter info
|
|
|
|
const reviewFilterList = useMemo<FilterList>(() => {
|
|
const uniqueLabels = new Set<string>();
|
|
const uniqueZones = new Set<string>();
|
|
|
|
reviewItems?.all?.forEach((rev) => {
|
|
rev.data.objects.forEach((obj) =>
|
|
uniqueLabels.add(obj.replace("-verified", "")),
|
|
);
|
|
rev.data.audio.forEach((aud) => uniqueLabels.add(aud));
|
|
});
|
|
|
|
reviewItems?.all?.forEach((rev) => {
|
|
rev.data.zones.forEach((zone) => uniqueZones.add(zone));
|
|
});
|
|
|
|
return { labels: [...uniqueLabels], zones: [...uniqueZones] };
|
|
}, [reviewItems]);
|
|
|
|
if (!config) {
|
|
return <ActivityIndicator />;
|
|
}
|
|
|
|
return (
|
|
<div className="flex size-full flex-col pt-2 md:py-2">
|
|
<Toaster closeButton={true} />
|
|
{!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
|
|
>
|
|
<ToggleGroupItem
|
|
className={cn(
|
|
severityToggle != "alert" && "text-muted-foreground",
|
|
)}
|
|
value="alert"
|
|
aria-label={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
|
|
) : (
|
|
<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")}
|
|
{reviewCounts.alert > -1 ? (
|
|
` ∙ ${reviewCounts.alert}`
|
|
) : (
|
|
<ActivityIndicator className="ml-2 size-4" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ToggleGroupItem>
|
|
<ToggleGroupItem
|
|
className={cn(
|
|
severityToggle != "detection" && "text-muted-foreground",
|
|
)}
|
|
value="detection"
|
|
aria-label={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
|
|
) : (
|
|
<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")}
|
|
{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}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className={cn(
|
|
"h-full min-h-0 overflow-hidden",
|
|
motionPreviewsOpen ? "flex flex-col" : "flex",
|
|
)}
|
|
>
|
|
{severity != "significant_motion" && (
|
|
<DetectionReview
|
|
contentRef={contentRef}
|
|
reviewItems={reviewItems}
|
|
currentItems={currentReviewItems}
|
|
relevantPreviews={relevantPreviews}
|
|
selectedReviews={selectedReviews}
|
|
itemsToReview={reviewCounts[severityToggle]}
|
|
severity={severity}
|
|
filter={filter}
|
|
timeRange={timeRange}
|
|
startTime={startTime}
|
|
loading={severity != severityToggle}
|
|
emptyCardData={emptyCardData}
|
|
markItemAsReviewed={markItemAsReviewed}
|
|
markItemsAsReviewed={markItemsAsReviewed}
|
|
onSelectReview={onSelectReview}
|
|
onSelectAllReviews={onSelectAllReviews}
|
|
setSelectedReviews={setSelectedReviews}
|
|
pullLatestData={pullLatestData}
|
|
/>
|
|
)}
|
|
{severity == "significant_motion" && (
|
|
<MotionReview
|
|
key={timeRange.before}
|
|
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}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type DetectionReviewProps = {
|
|
contentRef: MutableRefObject<HTMLDivElement | null>;
|
|
reviewItems?: {
|
|
all: ReviewSegment[];
|
|
alert: ReviewSegment[];
|
|
detection: ReviewSegment[];
|
|
significant_motion: ReviewSegment[];
|
|
};
|
|
currentItems: ReviewSegment[] | null;
|
|
itemsToReview?: number;
|
|
relevantPreviews?: Preview[];
|
|
selectedReviews: ReviewSegment[];
|
|
severity: ReviewSeverity;
|
|
filter?: ReviewFilter;
|
|
timeRange: { before: number; after: number };
|
|
startTime?: number;
|
|
loading: boolean;
|
|
emptyCardData: EmptyCardData;
|
|
markItemAsReviewed: (review: ReviewSegment) => void;
|
|
markItemsAsReviewed: (
|
|
currentItems: ReviewSegment[],
|
|
itemsToMarkReviewed?: ReviewSegment[] | undefined,
|
|
) => void;
|
|
onSelectReview: (
|
|
review: ReviewSegment,
|
|
ctrl: boolean,
|
|
detail: boolean,
|
|
) => void;
|
|
onSelectAllReviews: () => void;
|
|
setSelectedReviews: (reviews: ReviewSegment[]) => void;
|
|
pullLatestData: () => void;
|
|
};
|
|
function DetectionReview({
|
|
contentRef,
|
|
reviewItems,
|
|
currentItems,
|
|
itemsToReview,
|
|
relevantPreviews,
|
|
selectedReviews,
|
|
severity,
|
|
filter,
|
|
timeRange,
|
|
startTime,
|
|
loading,
|
|
emptyCardData,
|
|
markItemAsReviewed,
|
|
markItemsAsReviewed,
|
|
onSelectReview,
|
|
onSelectAllReviews,
|
|
setSelectedReviews,
|
|
pullLatestData,
|
|
}: DetectionReviewProps) {
|
|
const { t } = useTranslation(["views/events"]);
|
|
|
|
const reviewTimelineRef = useRef<HTMLDivElement>(null);
|
|
|
|
// preview
|
|
|
|
const [previewTime, setPreviewTime] = useState<number>();
|
|
|
|
const onPreviewTimeUpdate = useCallback(
|
|
(time: number | undefined) => {
|
|
if (!time) {
|
|
setPreviewTime(time);
|
|
return;
|
|
}
|
|
|
|
if (!previewTime || time > previewTime) {
|
|
setPreviewTime(time);
|
|
}
|
|
},
|
|
[previewTime, setPreviewTime],
|
|
);
|
|
|
|
// timeline interaction
|
|
|
|
const timelineDuration = useMemo(
|
|
() => timeRange.before - timeRange.after,
|
|
[timeRange],
|
|
);
|
|
|
|
const [zoomSettings, setZoomSettings] = useState({
|
|
segmentDuration: 60,
|
|
timestampSpread: 15,
|
|
});
|
|
|
|
const possibleZoomLevels: ZoomLevel[] = useMemo(
|
|
() => [
|
|
{ segmentDuration: 60, timestampSpread: 15 },
|
|
{ segmentDuration: 30, timestampSpread: 5 },
|
|
{ segmentDuration: 10, 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: reviewTimelineRef,
|
|
timelineDuration,
|
|
});
|
|
|
|
const { alignStartDateToTimeline, getVisibleTimelineDuration } =
|
|
useTimelineUtils({
|
|
segmentDuration: zoomSettings.segmentDuration,
|
|
timelineDuration,
|
|
timelineRef: reviewTimelineRef,
|
|
});
|
|
|
|
const scrollLock = useScrollLockout(contentRef);
|
|
|
|
const [minimap, setMinimap] = useState<string[]>([]);
|
|
const minimapObserver = useRef<IntersectionObserver | null>(null);
|
|
useEffect(() => {
|
|
const visibleTimestamps = new Set<string>();
|
|
minimapObserver.current = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
const start = (entry.target as HTMLElement).dataset.start;
|
|
|
|
if (!start) {
|
|
return;
|
|
}
|
|
|
|
if (entry.isIntersecting) {
|
|
visibleTimestamps.add(start);
|
|
} else {
|
|
visibleTimestamps.delete(start);
|
|
}
|
|
|
|
setMinimap([...visibleTimestamps]);
|
|
});
|
|
},
|
|
{ root: contentRef.current, threshold: isDesktop ? 0.1 : 0.5 },
|
|
);
|
|
|
|
return () => {
|
|
minimapObserver.current?.disconnect();
|
|
};
|
|
}, [contentRef, minimapObserver]);
|
|
|
|
const minimapBounds = useMemo(() => {
|
|
const data = {
|
|
start: 0,
|
|
end: 0,
|
|
};
|
|
const list = minimap.sort();
|
|
|
|
if (list.length > 0) {
|
|
data.end = parseFloat(list.at(-1) || "0");
|
|
data.start = parseFloat(list[0]);
|
|
}
|
|
|
|
return data;
|
|
}, [minimap]);
|
|
|
|
const minimapRef = useCallback(
|
|
(node: HTMLElement | null) => {
|
|
if (!minimapObserver.current) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (node) minimapObserver.current.observe(node);
|
|
} catch (e) {
|
|
// no op
|
|
}
|
|
},
|
|
[minimapObserver],
|
|
);
|
|
|
|
const showMinimap = useMemo(() => {
|
|
if (!contentRef.current) {
|
|
return false;
|
|
}
|
|
|
|
// don't show minimap if the view is not scrollable
|
|
if (contentRef.current.scrollHeight < contentRef.current.clientHeight) {
|
|
return false;
|
|
}
|
|
|
|
const visibleTime = getVisibleTimelineDuration();
|
|
const minimapTime = minimapBounds.end - minimapBounds.start;
|
|
if (visibleTime && minimapTime >= visibleTime * 0.75) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
// we know that these deps are correct
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [contentRef.current?.scrollHeight, minimapBounds]);
|
|
|
|
const visibleTimestamps = useMemo(
|
|
() => minimap.map((str) => parseFloat(str)),
|
|
[minimap],
|
|
);
|
|
|
|
// existing review item
|
|
|
|
useEffect(() => {
|
|
if (loading || currentItems == null || itemsToReview == undefined) {
|
|
return;
|
|
}
|
|
|
|
if (currentItems.length == 0 && itemsToReview > 0) {
|
|
pullLatestData();
|
|
}
|
|
}, [loading, currentItems, itemsToReview, pullLatestData]);
|
|
|
|
useEffect(() => {
|
|
if (!startTime || !currentItems || currentItems.length == 0) {
|
|
return;
|
|
}
|
|
|
|
const element = contentRef.current?.querySelector(
|
|
`[data-start="${startTime + REVIEW_PADDING}"]`,
|
|
);
|
|
if (element) {
|
|
scrollIntoView(element, {
|
|
scrollMode: "if-needed",
|
|
behavior: "smooth",
|
|
});
|
|
}
|
|
// only run when start time changes
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [startTime]);
|
|
|
|
// keyboard
|
|
|
|
useKeyboardListener(
|
|
["a", "r", "Escape"],
|
|
(key, modifiers) => {
|
|
if (!modifiers.down) {
|
|
return true;
|
|
}
|
|
|
|
switch (key) {
|
|
case "a":
|
|
if (modifiers.ctrl && !modifiers.repeat) {
|
|
onSelectAllReviews();
|
|
return true;
|
|
}
|
|
break;
|
|
case "r":
|
|
if (selectedReviews.length > 0 && !modifiers.repeat) {
|
|
markItemsAsReviewed(currentItems || [], selectedReviews);
|
|
setSelectedReviews([]);
|
|
return true;
|
|
}
|
|
break;
|
|
case "Escape":
|
|
setSelectedReviews([]);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
contentRef,
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
ref={contentRef}
|
|
className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4"
|
|
>
|
|
{filter?.before == undefined && (
|
|
<NewReviewData
|
|
className="pointer-events-none absolute left-1/2 z-[49] -translate-x-1/2"
|
|
contentRef={contentRef}
|
|
reviewItems={currentItems}
|
|
itemsToReview={loading ? 0 : itemsToReview}
|
|
pullLatestData={pullLatestData}
|
|
/>
|
|
)}
|
|
|
|
{!currentItems && (
|
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
|
<ActivityIndicator />
|
|
</div>
|
|
)}
|
|
|
|
{!loading && currentItems?.length === 0 && (
|
|
<EmptyCard
|
|
className="absolute left-[50%] top-[50%] -translate-x-1/2 -translate-y-1/2 items-center text-center"
|
|
title={emptyCardData.title}
|
|
titleHeading={false}
|
|
description={emptyCardData.description}
|
|
icon={<LuFolderCheck className="size-16" />}
|
|
/>
|
|
)}
|
|
|
|
<div
|
|
className="grid w-full gap-2 px-1 sm:grid-cols-2 md:mx-2 md:grid-cols-3 md:gap-4 3xl:grid-cols-4"
|
|
ref={contentRef}
|
|
>
|
|
{!loading && currentItems
|
|
? currentItems.map((value) => {
|
|
const selected = selectedReviews.some((r) => r.id === value.id);
|
|
|
|
return (
|
|
<div
|
|
key={value.id}
|
|
ref={minimapRef}
|
|
data-start={value.start_time}
|
|
data-segment-start={
|
|
alignStartDateToTimeline(value.start_time) -
|
|
zoomSettings.segmentDuration
|
|
}
|
|
className="review-item relative rounded-lg"
|
|
>
|
|
<div className="aspect-video overflow-hidden rounded-lg">
|
|
<PreviewThumbnailPlayer
|
|
review={value}
|
|
allPreviews={relevantPreviews}
|
|
timeRange={timeRange}
|
|
setReviewed={markItemAsReviewed}
|
|
scrollLock={scrollLock}
|
|
onTimeUpdate={onPreviewTimeUpdate}
|
|
onClick={(
|
|
review: ReviewSegment,
|
|
ctrl: boolean,
|
|
detail: boolean,
|
|
) => {
|
|
onSelectReview(review, ctrl, detail);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px]",
|
|
selected
|
|
? `outline-severity_${value.severity} shadow-severity_${value.severity}`
|
|
: "outline-transparent duration-500",
|
|
)}
|
|
/>
|
|
</div>
|
|
);
|
|
})
|
|
: (itemsToReview ?? 0) > 0 &&
|
|
Array(itemsToReview)
|
|
.fill(0)
|
|
.map((_, idx) => (
|
|
<Skeleton key={idx} className="aspect-video size-full" />
|
|
))}
|
|
{!loading &&
|
|
(currentItems?.filter((seg) => seg.end_time)?.length ?? 0) > 0 &&
|
|
(itemsToReview ?? 0) > 0 && (
|
|
<div className="col-span-full flex items-center justify-center">
|
|
<Button
|
|
className="text-balance text-white"
|
|
aria-label={t("markTheseItemsAsReviewed")}
|
|
variant="select"
|
|
onClick={() => {
|
|
setSelectedReviews([]);
|
|
markItemsAsReviewed(currentItems ?? []);
|
|
}}
|
|
>
|
|
{t("markTheseItemsAsReviewed")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex w-[65px] flex-row md:w-[110px]">
|
|
<div className="no-scrollbar relative w-[55px] md:w-[100px]">
|
|
{loading ? (
|
|
<Skeleton className="size-full" />
|
|
) : (
|
|
<EventReviewTimeline
|
|
segmentDuration={zoomSettings.segmentDuration}
|
|
timestampSpread={zoomSettings.timestampSpread}
|
|
timelineStart={timeRange.before}
|
|
timelineEnd={timeRange.after}
|
|
showMinimap={showMinimap && !previewTime}
|
|
minimapStartTime={minimapBounds.start}
|
|
minimapEndTime={minimapBounds.end}
|
|
showHandlebar={previewTime != undefined}
|
|
handlebarTime={previewTime}
|
|
visibleTimestamps={visibleTimestamps}
|
|
events={reviewItems?.all ?? []}
|
|
severityType={severity}
|
|
contentRef={contentRef}
|
|
timelineRef={reviewTimelineRef}
|
|
dense={isMobile}
|
|
isZooming={isZooming}
|
|
zoomDirection={zoomDirection}
|
|
possibleZoomLevels={possibleZoomLevels}
|
|
currentZoomLevel={currentZoomLevel}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="w-[10px]">
|
|
{loading ? (
|
|
<Skeleton className="w-full" />
|
|
) : (
|
|
<SummaryTimeline
|
|
reviewTimelineRef={reviewTimelineRef}
|
|
timelineStart={timeRange.before}
|
|
timelineEnd={timeRange.after}
|
|
segmentDuration={zoomSettings.segmentDuration}
|
|
events={reviewItems?.all ?? []}
|
|
severityType={severity}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
type MotionReviewProps = {
|
|
contentRef: MutableRefObject<HTMLDivElement | null>;
|
|
reviewItems?: {
|
|
all: ReviewSegment[];
|
|
alert: ReviewSegment[];
|
|
detection: ReviewSegment[];
|
|
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;
|
|
};
|
|
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");
|
|
const allowedCameras = useAllowedCameras();
|
|
|
|
const reviewCameras = useMemo(() => {
|
|
if (!config) {
|
|
return [];
|
|
}
|
|
|
|
const selectedCams = filter?.cameras;
|
|
const cameras = Object.values(config.cameras).filter((cam) => {
|
|
if (isReplayCamera(cam.name)) {
|
|
return false;
|
|
}
|
|
if (!allowedCameras.includes(cam.name)) {
|
|
return false;
|
|
}
|
|
if (cam.ui?.review === false) {
|
|
return false;
|
|
}
|
|
if (selectedCams && !selectedCams.includes(cam.name)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
return cameras.sort((a, b) => a.ui.order - b.ui.order);
|
|
}, [config, filter, allowedCameras]);
|
|
|
|
const reviewCamerasParam = useMemo(
|
|
() => reviewCameras.map((cam) => cam.name).join(","),
|
|
[reviewCameras],
|
|
);
|
|
|
|
const videoPlayersRef = useRef<{ [camera: string]: PreviewController }>({});
|
|
|
|
// motion data
|
|
|
|
const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils(
|
|
{
|
|
segmentDuration,
|
|
},
|
|
);
|
|
|
|
const alignedAfter = alignStartDateToTimeline(timeRange.after);
|
|
const alignedBefore = alignEndDateToTimeline(timeRange.before);
|
|
|
|
const { data: motionData } = useSWR<MotionData[]>([
|
|
"review/activity/motion",
|
|
{
|
|
before: alignedBefore,
|
|
after: alignedAfter,
|
|
scale: segmentDuration / 2,
|
|
cameras: reviewCamerasParam,
|
|
},
|
|
]);
|
|
|
|
const { data: overlapReviewSegments } = useSWR<ReviewSegment[]>([
|
|
"review",
|
|
{
|
|
before: alignedBefore,
|
|
after: alignedAfter,
|
|
cameras: reviewCamerasParam,
|
|
},
|
|
]);
|
|
|
|
// timeline time
|
|
|
|
const timeRangeSegments = useMemo(
|
|
() => getChunkedTimeRange(timeRange.after, timeRange.before),
|
|
[timeRange],
|
|
);
|
|
|
|
const initialIndex = useMemo(() => {
|
|
if (!startTime) {
|
|
return timeRangeSegments.ranges.length - 1;
|
|
}
|
|
|
|
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 ??
|
|
timeRangeSegments.end,
|
|
);
|
|
const currentTimeRange = useMemo(
|
|
() =>
|
|
timeRangeSegments.ranges[selectedRangeIdx] ??
|
|
timeRangeSegments.ranges[timeRangeSegments.ranges.length - 1],
|
|
[selectedRangeIdx, timeRangeSegments],
|
|
);
|
|
|
|
const [previewStart, setPreviewStart] = useState(startTime);
|
|
|
|
const [scrubbing, setScrubbing] = useState(false);
|
|
const [playing, setPlaying] = useState(false);
|
|
|
|
// move to next clip
|
|
|
|
useEffect(() => {
|
|
if (
|
|
currentTime > currentTimeRange.before + 60 ||
|
|
currentTime < currentTimeRange.after - 60
|
|
) {
|
|
const index = timeRangeSegments.ranges.findIndex(
|
|
(seg) => seg.after <= currentTime && seg.before >= currentTime,
|
|
);
|
|
|
|
if (index != -1) {
|
|
setPreviewStart(currentTime);
|
|
setSelectedRangeIdx(index);
|
|
}
|
|
return;
|
|
}
|
|
|
|
Object.values(videoPlayersRef.current).forEach((controller) => {
|
|
controller.scrubToTimestamp(currentTime);
|
|
});
|
|
// only refresh when current time or available segments changes
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [currentTime, timeRangeSegments]);
|
|
|
|
// playback
|
|
|
|
const [playbackRate, setPlaybackRate] = useState(8);
|
|
const [controlsOpen, setControlsOpen] = useState(false);
|
|
const [dimStrength, setDimStrength] = useState(82);
|
|
const [isPreviewSettingsOpen, setIsPreviewSettingsOpen] = useState(false);
|
|
const [motionFilterCells, setMotionFilterCells] = useState<Set<number>>(
|
|
new Set(),
|
|
);
|
|
const [pendingFilterCells, setPendingFilterCells] = useState<Set<number>>(
|
|
new Set(),
|
|
);
|
|
const [isRegionFilterOpen, setIsRegionFilterOpen] = useState(false);
|
|
const [cropToFilter, setCropToFilter] = useState(true);
|
|
|
|
// reset filter when camera changes
|
|
useEffect(() => {
|
|
setMotionFilterCells(new Set());
|
|
setPendingFilterCells(new Set());
|
|
}, [motionPreviewsCamera]);
|
|
|
|
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,
|
|
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) {
|
|
clearTimeout(timeoutIdRef.current);
|
|
return;
|
|
}
|
|
|
|
if (nextTimestamp >= timeRange.before - 4) {
|
|
setPlaying(false);
|
|
return;
|
|
}
|
|
|
|
const handleTimeout = () => {
|
|
setCurrentTime(nextTimestamp);
|
|
timeoutIdRef.current = setTimeout(handleTimeout, 500 / playbackRate);
|
|
};
|
|
|
|
timeoutIdRef.current = setTimeout(handleTimeout, 500 / playbackRate);
|
|
|
|
return () => {
|
|
if (timeoutIdRef.current) {
|
|
clearTimeout(timeoutIdRef.current);
|
|
}
|
|
};
|
|
}
|
|
}, [playing, playbackRate, nextTimestamp, setPlaying, timeRange]);
|
|
|
|
const getDetectionType = useCallback(
|
|
(cameraName: string) => {
|
|
if (motionOnly) {
|
|
const segmentStartTime = alignStartDateToTimeline(currentTime);
|
|
const segmentEndTime = segmentStartTime + segmentDuration;
|
|
const matchingItem = motionData?.find((item) => {
|
|
const cameras = item.camera.split(",").map((camera) => camera.trim());
|
|
return (
|
|
item.start_time >= segmentStartTime &&
|
|
item.start_time < segmentEndTime &&
|
|
cameras.includes(cameraName)
|
|
);
|
|
});
|
|
|
|
return matchingItem ? "significant_motion" : null;
|
|
} else {
|
|
const segmentStartTime = alignStartDateToTimeline(currentTime);
|
|
const segmentEndTime = segmentStartTime + segmentDuration;
|
|
const matchingItem = reviewItems?.all.find((item) => {
|
|
const endTime = item.end_time ?? timeRange.before;
|
|
|
|
return (
|
|
((item.start_time >= segmentStartTime &&
|
|
item.start_time < segmentEndTime) ||
|
|
(endTime > segmentStartTime && endTime <= segmentEndTime) ||
|
|
(item.start_time <= segmentStartTime &&
|
|
endTime >= segmentEndTime)) &&
|
|
item.camera === cameraName
|
|
);
|
|
});
|
|
|
|
return matchingItem ? matchingItem.severity : null;
|
|
}
|
|
},
|
|
[
|
|
reviewItems,
|
|
motionData,
|
|
currentTime,
|
|
timeRange,
|
|
motionOnly,
|
|
alignStartDateToTimeline,
|
|
],
|
|
);
|
|
|
|
if (motionData?.length === 0) {
|
|
return (
|
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
|
<EmptyCard
|
|
title={emptyCardData.title}
|
|
description={emptyCardData.description}
|
|
icon={<LuFolderX className="size-16" />}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (relevantPreviews == undefined) {
|
|
return <ActivityIndicator />;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{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}
|
|
/>
|
|
)}
|
|
<Dialog
|
|
open={isRegionFilterOpen}
|
|
onOpenChange={(open) => {
|
|
if (open) {
|
|
setPendingFilterCells(new Set(motionFilterCells));
|
|
}
|
|
setIsRegionFilterOpen(open);
|
|
}}
|
|
>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
className={cn(
|
|
isDesktop ? "flex items-center gap-2" : "rounded-lg",
|
|
)}
|
|
size="sm"
|
|
variant={motionFilterCells.size > 0 ? "select" : "default"}
|
|
aria-label={t("motionPreviews.filter")}
|
|
>
|
|
<FaFilter
|
|
className={
|
|
motionFilterCells.size > 0
|
|
? "text-selected-foreground"
|
|
: "text-secondary-foreground"
|
|
}
|
|
/>
|
|
{isDesktop && t("motionPreviews.filter")}
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-[85%] md:max-w-[70%] lg:max-w-[60%]">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("motionPreviews.filter")}</DialogTitle>
|
|
<DialogDescription>
|
|
{t("motionPreviews.filterDesc")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<MotionRegionFilterGrid
|
|
camera={selectedMotionPreviewCamera}
|
|
selectedCells={pendingFilterCells}
|
|
onCellsChange={setPendingFilterCells}
|
|
/>
|
|
<DialogFooter>
|
|
<Button
|
|
disabled={pendingFilterCells.size === 0}
|
|
onClick={() => {
|
|
setPendingFilterCells(new Set());
|
|
}}
|
|
>
|
|
{t("motionPreviews.filterClear")}
|
|
</Button>
|
|
<Button
|
|
variant="select"
|
|
onClick={() => {
|
|
setMotionFilterCells(new Set(pendingFilterCells));
|
|
setIsRegionFilterOpen(false);
|
|
}}
|
|
>
|
|
{t("button.apply", { ns: "common" })}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<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>{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">
|
|
<Label
|
|
className="cursor-pointer"
|
|
htmlFor="motionPreviewSpeed"
|
|
>
|
|
{t("motionPreviews.speed")}
|
|
</Label>
|
|
<div className="text-xs text-muted-foreground">
|
|
{t("motionPreviews.speedDesc")}
|
|
</div>
|
|
</div>
|
|
<Select
|
|
value={String(playbackRate)}
|
|
onValueChange={(value) =>
|
|
setPlaybackRate(Number(value))
|
|
}
|
|
>
|
|
<SelectTrigger
|
|
id="motionPreviewSpeed"
|
|
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">
|
|
<Label>{t("motionPreviews.dim")}</Label>
|
|
<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>
|
|
|
|
<div className="space-y-0.5">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<Label
|
|
className="cursor-pointer"
|
|
htmlFor="cropToFilter"
|
|
>
|
|
{t("motionPreviews.crop")}
|
|
</Label>
|
|
<Switch
|
|
id="cropToFilter"
|
|
checked={cropToFilter}
|
|
onCheckedChange={setCropToFilter}
|
|
aria-label={t("motionPreviews.cropAria")}
|
|
/>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{t("motionPreviews.cropDesc")}
|
|
</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",
|
|
)}
|
|
open={isPreviewSettingsOpen}
|
|
onOpenChange={setIsPreviewSettingsOpen}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<MotionPreviewsPane
|
|
camera={selectedMotionPreviewCamera}
|
|
contentRef={contentRef}
|
|
cameraPreviews={relevantPreviews}
|
|
motionRanges={motionPreviewRanges}
|
|
isLoadingMotionRanges={
|
|
motionData == undefined || overlapReviewSegments == undefined
|
|
}
|
|
playbackRate={playbackRate}
|
|
nonMotionAlpha={dimStrength / 100}
|
|
motionFilterCells={motionFilterCells}
|
|
cropToFilter={cropToFilter}
|
|
onSeek={(timestamp) => {
|
|
onOpenRecording({
|
|
camera: selectedMotionPreviewCamera.name,
|
|
startTime: timestamp,
|
|
severity: "significant_motion",
|
|
});
|
|
}}
|
|
/>
|
|
</>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
"no-scrollbar flex min-w-0 flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4",
|
|
selectedMotionPreviewCamera && "hidden",
|
|
)}
|
|
>
|
|
<div
|
|
ref={selectedMotionPreviewCamera ? undefined : 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);
|
|
}
|
|
|
|
setScrubbing(scrubbing);
|
|
}}
|
|
dense={isMobileOnly}
|
|
isZooming={false}
|
|
zoomDirection={null}
|
|
alwaysShowMotionLine={true}
|
|
/>
|
|
) : (
|
|
<Skeleton className="size-full" />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!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;
|
|
|
|
if (wasPlaying) {
|
|
setPlaying(false);
|
|
}
|
|
|
|
setCurrentTime(currentTime + diff);
|
|
|
|
if (wasPlaying) {
|
|
setTimeout(() => setPlaying(true), 100);
|
|
}
|
|
}}
|
|
onSetPlaybackRate={setPlaybackRate}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|