mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 10:48:58 +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
* improve display of gpu graphs in system metrics * docs tweaks * Only hide cameras with ui.dashboard disabled from the All Cameras dashboard The settings camera selector and zone editor also filtered on ui.dashboard, so hiding a camera from the dashboard made its zones and masks uneditable in the UI (GH 23870). Drop those filters and correct the field title, help text, and reference docs to describe what the option actually does * hide cameras with ui.review disabled from the Motion tab and the review summaries The Motion tab built its own camera list that never checked ui.review, so a hidden camera still got a preview tile, and its motion and overlap queries fell back to every allowed camera. The review and recordings summaries had the same gap: they are aggregate day counts that can't be filtered client side, so a hidden camera kept contributing to the severity tab counts and calendar indicators while its items were absent from the list. Filter the motion camera list on ui.review and query all four endpoints with the visible camera list instead of letting the backend default to all, and skip the summary queries until the config resolves so the counts don't briefly render as zero. * Scope every review page query to the cameras visible in review The segments and the summary counts were derived from different camera sets: the list was fetched for all cameras and filtered client side, while the summaries were fetched for the visible cameras only when no explicit camera filter was set. A ?cameras= link can name a camera hidden from review, which left the count above zero with an empty list, pinning the new items to review popover open and making the auto refresh effect loop. Intersect an explicit camera selection with the visible list rather than trusting it, pass that to the segment and summary queries alike, and drop the now redundant client side filter, which the raw segments handed to the history view were bypassing anyway.
1738 lines
56 KiB
TypeScript
1738 lines
56 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 {
|
|
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);
|
|
|
|
// 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
|
|
cameraName={selectedMotionPreviewCamera.name}
|
|
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">
|
|
<div>{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>{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",
|
|
)}
|
|
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}
|
|
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}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|