Files
frigate/web/src/views/events/EventView.tsx
T
Josh HawkinsandGitHub f1cc0e49d4
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
Miscellaneous fixes (0.18 beta) (#23873)
* 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.
2026-07-30 17:20:41 -05:00

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}
/>
)}
</>
);
}