mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-02 09:02:15 +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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user