Files
frigate/web/src/views/events/EventView.tsx
T

1113 lines
34 KiB
TypeScript
Raw Normal View History

2024-02-27 14:39:05 -07:00
import Logo from "@/components/Logo";
import NewReviewData from "@/components/dynamic/NewReviewData";
import ReviewActionGroup from "@/components/filter/ReviewActionGroup";
2024-02-25 12:04:44 -07:00
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
2024-02-21 13:07:32 -07:00
import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
2024-03-03 10:32:47 -06:00
import ActivityIndicator from "@/components/indicators/activity-indicator";
2024-02-21 13:07:32 -07:00
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
2024-03-18 15:58:54 -05:00
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
2024-03-01 17:34:41 -07:00
import { useScrollLockout } from "@/hooks/use-mouse-listener";
2024-02-21 13:07:32 -07:00
import { FrigateConfig } from "@/types/frigateConfig";
2024-02-28 15:23:56 -07:00
import { Preview } from "@/types/preview";
2024-03-03 17:19:02 -07:00
import {
2024-03-05 12:55:44 -07:00
MotionData,
2024-04-29 10:12:57 -06:00
REVIEW_PADDING,
2024-03-03 17:19:02 -07:00
ReviewFilter,
ReviewSegment,
ReviewSeverity,
ReviewSummary,
2024-05-26 15:49:12 -06:00
SegmentedReviewData,
2024-03-03 17:19:02 -07:00
} from "@/types/review";
2024-03-03 21:21:30 -07:00
import { getChunkedTimeRange } from "@/utils/timelineUtil";
import axios from "axios";
2024-03-03 21:21:30 -07:00
import {
MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
2024-06-13 09:45:07 -05:00
import { isDesktop, isMobile, isMobileOnly } from "react-device-detect";
2024-06-07 06:57:15 -05:00
import { LuFolderCheck, LuFolderX } from "react-icons/lu";
2024-02-21 13:07:32 -07:00
import { MdCircle } from "react-icons/md";
import useSWR from "swr";
2024-03-05 12:55:44 -07:00
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
2024-03-05 17:39:37 -07:00
import { Button } from "@/components/ui/button";
2024-03-11 17:31:05 -06:00
import PreviewPlayer, {
PreviewController,
} from "@/components/player/PreviewPlayer";
2024-03-20 21:56:15 -05:00
import SummaryTimeline from "@/components/timeline/SummaryTimeline";
import { RecordingStartingPoint } from "@/types/record";
2024-03-22 10:56:53 -06:00
import VideoControls from "@/components/player/VideoControls";
import { TimeRange } from "@/types/timeline";
2024-03-25 11:19:55 -05:00
import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity";
2024-04-04 10:09:19 -05:00
import useOptimisticState from "@/hooks/use-optimistic-state";
2024-04-04 15:54:58 -05:00
import { Skeleton } from "@/components/ui/skeleton";
2024-04-11 06:42:16 -06:00
import scrollIntoView from "scroll-into-view-if-needed";
2024-05-03 08:03:41 -06:00
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
2024-06-02 12:00:59 -05:00
import { cn } from "@/lib/utils";
2024-06-14 12:14:32 -05:00
import { FilterList, LAST_24_HOURS_KEY } from "@/types/filter";
2024-06-13 09:45:07 -05:00
import { GiSoundWaves } from "react-icons/gi";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
2024-02-21 13:07:32 -07:00
2024-02-27 14:39:05 -07:00
type EventViewProps = {
2024-05-26 15:49:12 -06:00
reviewItems?: SegmentedReviewData;
currentReviewItems: ReviewSegment[] | null;
2024-03-05 17:39:37 -07:00
reviewSummary?: ReviewSummary;
2024-02-22 17:03:34 -07:00
relevantPreviews?: Preview[];
2024-03-30 12:44:12 -06:00
timeRange: TimeRange;
2024-02-25 12:04:44 -07:00
filter?: ReviewFilter;
severity: ReviewSeverity;
startTime?: number;
showReviewed: boolean;
setShowReviewed: (show: boolean) => void;
setSeverity: (severity: ReviewSeverity) => void;
2024-03-05 05:02:34 -07:00
markItemAsReviewed: (review: ReviewSegment) => void;
2024-03-14 14:49:03 -06:00
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
onOpenRecording: (recordingInfo: RecordingStartingPoint) => void;
2024-02-22 17:03:34 -07:00
pullLatestData: () => void;
2024-02-25 12:04:44 -07:00
updateFilter: (filter: ReviewFilter) => void;
2024-02-22 17:03:34 -07:00
};
2024-02-27 14:39:05 -07:00
export default function EventView({
2024-05-26 15:49:12 -06:00
reviewItems,
currentReviewItems,
2024-03-03 17:19:02 -07:00
reviewSummary,
2024-02-22 17:03:34 -07:00
relevantPreviews,
timeRange,
2024-02-25 12:04:44 -07:00
filter,
severity,
startTime,
showReviewed,
setShowReviewed,
setSeverity,
2024-02-22 17:03:34 -07:00
markItemAsReviewed,
2024-03-14 14:49:03 -06:00
markAllItemsAsReviewed,
onOpenRecording,
2024-02-22 17:03:34 -07:00
pullLatestData,
2024-02-25 12:04:44 -07:00
updateFilter,
2024-02-27 14:39:05 -07:00
}: EventViewProps) {
2024-02-21 13:07:32 -07:00
const { data: config } = useSWR<FrigateConfig>("config");
const contentRef = useRef<HTMLDivElement | null>(null);
2024-03-03 17:19:02 -07:00
// review counts
const reviewCounts = useMemo(() => {
2024-03-05 17:39:37 -07:00
if (!reviewSummary) {
return { alert: -1, detection: -1, significant_motion: -1 };
2024-03-03 17:19:02 -07:00
}
let summary;
if (filter?.before == undefined) {
2024-06-14 12:14:32 -05:00
summary = reviewSummary[LAST_24_HOURS_KEY];
2024-03-03 17:19:02 -07:00
} else {
2024-03-05 05:02:34 -07:00
const day = new Date(filter.before * 1000);
const key = `${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`;
2024-03-05 17:39:37 -07:00
summary = reviewSummary[key];
2024-03-05 05:02:34 -07:00
}
if (!summary) {
return { alert: 0, detection: 0, significant_motion: 0 };
2024-03-03 17:19:02 -07:00
}
if (showReviewed) {
2024-03-03 17:19:02 -07:00
return {
2024-05-13 07:42:11 -05:00
alert: summary.total_alert ?? 0,
detection: summary.total_detection ?? 0,
significant_motion: summary.total_motion ?? 0,
2024-03-03 17:19:02 -07:00
};
} else {
return {
alert: summary.total_alert - summary.reviewed_alert,
detection: summary.total_detection - summary.reviewed_detection,
significant_motion: summary.total_motion - summary.reviewed_motion,
};
}
}, [filter, showReviewed, reviewSummary]);
2024-03-03 17:19:02 -07:00
// review interaction
const [selectedReviews, setSelectedReviews] = useState<string[]>([]);
const onSelectReview = useCallback(
(review: ReviewSegment, ctrl: boolean) => {
if (selectedReviews.length > 0 || ctrl) {
const index = selectedReviews.indexOf(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.id);
setSelectedReviews(copy);
}
} else {
onOpenRecording({
camera: review.camera,
2024-04-29 10:12:57 -06:00
startTime: review.start_time - REVIEW_PADDING,
severity: review.severity,
});
2024-05-26 15:49:12 -06:00
review.has_been_reviewed = true;
markItemAsReviewed(review);
}
},
[selectedReviews, setSelectedReviews, onOpenRecording, markItemAsReviewed],
);
const onSelectAllReviews = useCallback(() => {
if (!currentReviewItems || currentReviewItems.length == 0) {
return;
}
if (selectedReviews.length < currentReviewItems.length) {
setSelectedReviews(currentReviewItems.map((seg) => seg.id));
} else {
setSelectedReviews([]);
}
}, [currentReviewItems, selectedReviews]);
2024-03-01 10:56:45 -07:00
const exportReview = useCallback(
(id: string) => {
2024-03-11 07:05:44 -06:00
const review = reviewItems?.all?.find((seg) => seg.id == id);
2024-03-01 10:56:45 -07:00
if (!review) {
return;
}
2024-06-17 06:19:16 -06:00
const endTime = review.end_time
? review.end_time + REVIEW_PADDING
: Date.now() / 1000;
2024-05-03 08:03:41 -06:00
axios
.post(
2024-06-17 06:19:16 -06:00
`export/${review.camera}/start/${review.start_time - REVIEW_PADDING}/end/${endTime}`,
2024-05-03 08:03:41 -06:00
{ playback: "realtime" },
)
.then((response) => {
if (response.status == 200) {
toast.success(
"Successfully started export. View the file in the /exports folder.",
{ position: "top-center" },
);
}
})
.catch((error) => {
if (error.response?.data?.message) {
toast.error(
`Failed to start export: ${error.response.data.message}`,
{ position: "top-center" },
);
} else {
toast.error(`Failed to start export: ${error.message}`, {
position: "top-center",
});
}
});
2024-03-01 10:56:45 -07:00
},
2024-03-07 09:11:24 -07:00
[reviewItems],
2024-03-01 10:56:45 -07:00
);
const [motionOnly, setMotionOnly] = useState(false);
2024-04-04 10:09:19 -05:00
const [severityToggle, setSeverityToggle] = useOptimisticState(
severity,
setSeverity,
100,
);
2024-05-10 11:42:56 -06:00
// review filter info
2024-06-11 09:19:17 -05:00
const reviewFilterList = useMemo<FilterList>(() => {
2024-05-10 11:42:56 -06:00
const uniqueLabels = new Set<string>();
2024-06-11 09:19:17 -05:00
const uniqueZones = new Set<string>();
2024-05-10 11:42:56 -06:00
reviewItems?.all?.forEach((rev) => {
rev.data.objects.forEach((obj) =>
uniqueLabels.add(obj.replace("-verified", "")),
);
rev.data.audio.forEach((aud) => uniqueLabels.add(aud));
});
2024-06-11 09:19:17 -05:00
reviewItems?.all?.forEach((rev) => {
rev.data.zones.forEach((zone) => uniqueZones.add(zone));
});
return { labels: [...uniqueLabels], zones: [...uniqueZones] };
2024-05-10 11:42:56 -06:00
}, [reviewItems]);
2024-02-21 13:07:32 -07:00
if (!config) {
return <ActivityIndicator />;
}
return (
2024-05-16 11:51:57 -05:00
<div className="flex size-full flex-col pt-2 md:py-2">
2024-05-04 14:54:50 -05:00
<Toaster closeButton={true} />
2024-05-16 17:00:14 -05:00
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:pl-3">
2024-02-28 07:16:32 -07:00
{isMobile && (
2024-05-14 10:06:44 -05:00
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
2024-02-28 07:16:32 -07:00
)}
2024-02-21 13:07:32 -07:00
<ToggleGroup
2024-05-14 10:06:44 -05:00
className="*:rounded-md *:px-3 *:py-4"
2024-02-21 13:07:32 -07:00
type="single"
size="sm"
2024-04-04 10:09:19 -05:00
value={severityToggle}
2024-03-05 17:39:37 -07:00
onValueChange={(value: ReviewSeverity) =>
2024-04-04 10:09:19 -05:00
value ? setSeverityToggle(value) : null
2024-03-05 17:39:37 -07:00
} // don't allow the severity to be unselected
2024-02-21 13:07:32 -07:00
>
<ToggleGroupItem
2024-06-13 09:45:07 -05:00
className={cn(severityToggle != "alert" && "text-muted-foreground")}
2024-02-21 13:07:32 -07:00
value="alert"
aria-label="Select alerts"
>
2024-06-13 09:45:07 -05:00
{isMobileOnly ? (
<div
className={cn(
"flex size-6 items-center justify-center rounded text-severity_alert",
severityToggle == "alert" ? "font-semibold" : "font-medium",
)}
>
{reviewCounts.alert > -1 ? (
reviewCounts.alert
) : (
<ActivityIndicator className="size-4" />
)}
</div>
) : (
<>
<MdCircle className="size-2 text-severity_alert md:mr-[10px]" />
<div className="hidden md:flex md:flex-row md:items-center">
Alerts
{reviewCounts.alert > -1 ? (
` ∙ ${reviewCounts.alert}`
) : (
<ActivityIndicator className="ml-2 size-4" />
)}
</div>
</>
)}
2024-02-21 13:07:32 -07:00
</ToggleGroupItem>
<ToggleGroupItem
2024-06-13 09:45:07 -05:00
className={cn(
severityToggle != "detection" && "text-muted-foreground",
)}
2024-02-21 13:07:32 -07:00
value="detection"
aria-label="Select detections"
>
2024-06-13 09:45:07 -05:00
{isMobileOnly ? (
<div
className={cn(
"flex size-6 items-center justify-center rounded text-severity_detection",
severityToggle == "detection"
? "font-semibold"
: "font-medium",
)}
>
{reviewCounts.detection > -1 ? (
reviewCounts.detection
) : (
<ActivityIndicator className="size-4" />
)}
</div>
) : (
<>
<MdCircle className="size-2 text-severity_detection md:mr-[10px]" />
<div className="hidden md:flex md:flex-row md:items-center">
Detections
{reviewCounts.detection > -1 ? (
` ∙ ${reviewCounts.detection}`
) : (
<ActivityIndicator className="ml-2 size-4" />
)}
</div>
</>
)}
2024-02-21 13:07:32 -07:00
</ToggleGroupItem>
<ToggleGroupItem
2024-06-13 09:45:07 -05:00
className={cn(
"rounded-lg px-3 py-4",
severityToggle != "significant_motion" && "text-muted-foreground",
)}
2024-02-21 13:07:32 -07:00
value="significant_motion"
aria-label="Select motion"
>
2024-06-13 09:45:07 -05:00
{isMobileOnly ? (
<GiSoundWaves className="size-6 rotate-90 text-severity_significant_motion" />
) : (
<>
<MdCircle className="size-2 text-severity_significant_motion md:mr-[10px]" />
<div className="hidden md:block">Motion</div>
</>
)}
2024-02-21 13:07:32 -07:00
</ToggleGroupItem>
</ToggleGroup>
2024-03-01 10:56:45 -07:00
{selectedReviews.length <= 0 ? (
<ReviewFilterGroup
2024-03-26 15:03:58 -06:00
filters={
severity == "significant_motion"
? ["cameras", "date", "motionOnly"]
2024-03-28 08:43:05 -06:00
: ["cameras", "reviewed", "date", "general"]
2024-03-26 15:03:58 -06:00
}
2024-05-01 08:11:16 -06:00
currentSeverity={severityToggle}
reviewSummary={reviewSummary}
filter={filter}
motionOnly={motionOnly}
2024-06-11 09:19:17 -05:00
filterList={reviewFilterList}
showReviewed={showReviewed}
setShowReviewed={setShowReviewed}
2024-05-10 11:42:56 -06:00
onUpdateFilter={updateFilter}
setMotionOnly={setMotionOnly}
/>
2024-03-01 10:56:45 -07:00
) : (
<ReviewActionGroup
selectedReviews={selectedReviews}
setSelectedReviews={setSelectedReviews}
2024-03-01 10:56:45 -07:00
onExport={exportReview}
pullLatestData={pullLatestData}
/>
)}
2024-02-21 13:07:32 -07:00
</div>
2024-02-22 21:15:50 -06:00
<div className="flex h-full overflow-hidden">
2024-03-03 21:21:30 -07:00
{severity != "significant_motion" && (
<DetectionReview
2024-02-22 21:15:50 -06:00
contentRef={contentRef}
2024-03-03 21:21:30 -07:00
reviewItems={reviewItems}
2024-05-26 15:49:12 -06:00
currentItems={currentReviewItems}
2024-03-03 21:21:30 -07:00
relevantPreviews={relevantPreviews}
selectedReviews={selectedReviews}
2024-04-12 06:31:30 -06:00
itemsToReview={reviewCounts[severityToggle]}
2024-03-03 21:21:30 -07:00
severity={severity}
filter={filter}
timeRange={timeRange}
2024-04-11 06:42:16 -06:00
startTime={startTime}
2024-04-11 14:54:09 -06:00
loading={severity != severityToggle}
2024-03-03 21:21:30 -07:00
markItemAsReviewed={markItemAsReviewed}
2024-03-14 14:49:03 -06:00
markAllItemsAsReviewed={markAllItemsAsReviewed}
2024-03-03 21:21:30 -07:00
onSelectReview={onSelectReview}
onSelectAllReviews={onSelectAllReviews}
2024-08-12 14:30:16 -06:00
setSelectedReviews={setSelectedReviews}
2024-03-03 21:21:30 -07:00
pullLatestData={pullLatestData}
2024-02-22 21:15:50 -06:00
/>
2024-03-03 21:21:30 -07:00
)}
{severity == "significant_motion" && (
<MotionReview
2024-03-09 07:08:06 -07:00
key={timeRange.before}
2024-03-03 21:21:30 -07:00
contentRef={contentRef}
reviewItems={reviewItems}
relevantPreviews={relevantPreviews}
timeRange={timeRange}
startTime={startTime}
2024-03-03 21:21:30 -07:00
filter={filter}
motionOnly={motionOnly}
onOpenRecording={onOpenRecording}
2024-03-03 21:21:30 -07:00
/>
)}
2024-02-21 13:07:32 -07:00
</div>
</div>
);
}
2024-03-03 21:21:30 -07:00
type DetectionReviewProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
2024-03-11 07:05:44 -06:00
reviewItems?: {
2024-03-03 21:21:30 -07:00
all: ReviewSegment[];
alert: ReviewSegment[];
detection: ReviewSegment[];
significant_motion: ReviewSegment[];
};
2024-05-26 15:49:12 -06:00
currentItems: ReviewSegment[] | null;
2024-03-05 17:39:37 -07:00
itemsToReview?: number;
2024-03-03 21:21:30 -07:00
relevantPreviews?: Preview[];
selectedReviews: string[];
severity: ReviewSeverity;
filter?: ReviewFilter;
timeRange: { before: number; after: number };
2024-04-11 06:42:16 -06:00
startTime?: number;
2024-04-11 14:54:09 -06:00
loading: boolean;
2024-03-05 05:02:34 -07:00
markItemAsReviewed: (review: ReviewSegment) => void;
2024-03-14 14:49:03 -06:00
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
onSelectReview: (review: ReviewSegment, ctrl: boolean) => void;
onSelectAllReviews: () => void;
2024-08-12 14:30:16 -06:00
setSelectedReviews: (reviewIds: string[]) => void;
2024-03-03 21:21:30 -07:00
pullLatestData: () => void;
};
function DetectionReview({
contentRef,
reviewItems,
2024-05-26 15:49:12 -06:00
currentItems,
2024-03-05 17:39:37 -07:00
itemsToReview,
2024-03-03 21:21:30 -07:00
relevantPreviews,
selectedReviews,
severity,
filter,
timeRange,
2024-04-11 06:42:16 -06:00
startTime,
2024-04-11 14:54:09 -06:00
loading,
2024-03-03 21:21:30 -07:00
markItemAsReviewed,
2024-03-14 14:49:03 -06:00
markAllItemsAsReviewed,
2024-03-03 21:21:30 -07:00
onSelectReview,
onSelectAllReviews,
2024-08-12 14:30:16 -06:00
setSelectedReviews,
2024-03-03 21:21:30 -07:00
pullLatestData,
}: DetectionReviewProps) {
2024-03-20 21:56:15 -05:00
const reviewTimelineRef = useRef<HTMLDivElement>(null);
2024-03-03 21:21:30 -07:00
const segmentDuration = 60;
// 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],
);
2024-03-03 21:21:30 -07:00
// timeline interaction
const timelineDuration = useMemo(
() => timeRange.before - timeRange.after,
[timeRange],
);
const { alignStartDateToTimeline, getVisibleTimelineDuration } =
useTimelineUtils({
segmentDuration,
timelineDuration,
timelineRef: reviewTimelineRef,
});
2024-03-03 21:21:30 -07:00
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;
2024-03-03 21:21:30 -07:00
// 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],
);
2024-03-03 21:21:30 -07:00
2024-04-11 06:42:16 -06:00
// existing review item
useEffect(() => {
if (!startTime || !currentItems || currentItems.length == 0) {
return;
}
const element = contentRef.current?.querySelector(
2024-05-01 08:24:19 -06:00
`[data-start="${startTime + REVIEW_PADDING}"]`,
2024-04-11 06:42:16 -06:00
);
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"], (key, modifiers) => {
if (modifiers.repeat || !modifiers.down) {
return;
}
if (key == "a" && modifiers.ctrl) {
onSelectAllReviews();
}
if (key == "r" && selectedReviews.length > 0) {
currentItems?.forEach((item) => {
if (selectedReviews.includes(item.id)) {
item.has_been_reviewed = true;
markItemAsReviewed(item);
}
});
setSelectedReviews([]);
}
});
2024-03-03 21:21:30 -07:00
return (
<>
<div
ref={contentRef}
2024-05-14 10:06:44 -05:00
className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4"
2024-03-03 21:21:30 -07:00
>
{filter?.before == undefined && (
<NewReviewData
2024-05-14 10:06:44 -05:00
className="pointer-events-none absolute left-1/2 z-50 -translate-x-1/2"
2024-03-03 21:21:30 -07:00
contentRef={contentRef}
2024-03-25 20:25:06 -06:00
reviewItems={currentItems}
2024-04-12 06:31:30 -06:00
itemsToReview={loading ? 0 : itemsToReview}
2024-03-03 21:21:30 -07:00
pullLatestData={pullLatestData}
/>
)}
2024-03-11 07:05:44 -06:00
{!currentItems && (
2024-05-14 10:06:44 -05:00
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
2024-03-11 07:05:44 -06:00
<ActivityIndicator />
</div>
)}
2024-04-12 06:31:30 -06:00
{!loading && currentItems?.length === 0 && (
2024-05-14 10:06:44 -05:00
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
2024-03-03 21:21:30 -07:00
<LuFolderCheck className="size-16" />
2024-03-11 07:05:44 -06:00
There are no {severity.replace(/_/g, " ")}s to review
2024-03-03 21:21:30 -07:00
</div>
)}
<div
2024-05-16 17:00:14 -05:00
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"
2024-03-03 21:21:30 -07:00
ref={contentRef}
>
2024-04-12 06:31:30 -06:00
{!loading && currentItems
? currentItems.map((value) => {
const selected = selectedReviews.includes(value.id);
2024-03-03 21:21:30 -07:00
2024-04-12 06:31:30 -06:00
return (
<div
key={value.id}
ref={minimapRef}
data-start={value.start_time}
data-segment-start={
alignStartDateToTimeline(value.start_time) -
segmentDuration
}
className="review-item relative rounded-lg"
>
2024-05-14 10:06:44 -05:00
<div className="aspect-video overflow-hidden rounded-lg">
2024-04-12 06:31:30 -06:00
<PreviewThumbnailPlayer
review={value}
allPreviews={relevantPreviews}
timeRange={timeRange}
setReviewed={markItemAsReviewed}
scrollLock={scrollLock}
onTimeUpdate={onPreviewTimeUpdate}
onClick={onSelectReview}
/>
</div>
<div
2024-05-14 10:06:44 -05:00
className={`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"}`}
2024-03-03 21:21:30 -07:00
/>
</div>
2024-04-12 06:31:30 -06:00
);
})
: (itemsToReview ?? 0) > 0 &&
Array(itemsToReview)
2024-04-12 06:31:30 -06:00
.fill(0)
.map((_, idx) => (
2024-05-14 10:06:44 -05:00
<Skeleton key={idx} className="aspect-video size-full" />
))}
2024-04-12 06:31:30 -06:00
{!loading &&
(currentItems?.filter((seg) => seg.end_time)?.length ?? 0) > 0 &&
2024-04-12 06:31:30 -06:00
(itemsToReview ?? 0) > 0 && (
2024-05-14 10:06:44 -05:00
<div className="col-span-full flex items-center justify-center">
2024-04-12 06:31:30 -06:00
<Button
className="text-white"
variant="select"
onClick={() => {
2024-08-12 14:30:16 -06:00
setSelectedReviews([]);
2024-04-12 06:31:30 -06:00
markAllItemsAsReviewed(currentItems ?? []);
}}
>
Mark these items as reviewed
</Button>
</div>
)}
2024-03-03 21:21:30 -07:00
</div>
</div>
2024-05-14 10:06:44 -05:00
<div className="flex w-[65px] flex-row md:w-[110px]">
<div className="no-scrollbar w-[55px] overflow-y-auto md:w-[100px]">
2024-04-11 14:54:09 -06:00
{loading ? (
<Skeleton className="size-full" />
) : (
<EventReviewTimeline
segmentDuration={segmentDuration}
timestampSpread={15}
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}
/>
)}
2024-03-20 21:56:15 -05:00
</div>
<div className="w-[10px]">
2024-04-11 14:54:09 -06:00
{loading ? (
<Skeleton className="w-full" />
) : (
<SummaryTimeline
reviewTimelineRef={reviewTimelineRef}
timelineStart={timeRange.before}
timelineEnd={timeRange.after}
segmentDuration={segmentDuration}
events={reviewItems?.all ?? []}
severityType={severity}
/>
)}
2024-03-20 21:56:15 -05:00
</div>
2024-03-03 21:21:30 -07:00
</div>
</>
);
}
type MotionReviewProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
2024-03-11 07:05:44 -06:00
reviewItems?: {
2024-03-03 21:21:30 -07:00
all: ReviewSegment[];
alert: ReviewSegment[];
detection: ReviewSegment[];
significant_motion: ReviewSegment[];
};
relevantPreviews?: Preview[];
timeRange: TimeRange;
startTime?: number;
2024-03-03 21:21:30 -07:00
filter?: ReviewFilter;
motionOnly?: boolean;
onOpenRecording: (data: RecordingStartingPoint) => void;
2024-03-03 21:21:30 -07:00
};
function MotionReview({
contentRef,
reviewItems,
relevantPreviews,
timeRange,
startTime,
2024-03-03 21:21:30 -07:00
filter,
motionOnly = false,
onOpenRecording,
2024-03-03 21:21:30 -07:00
}: MotionReviewProps) {
const segmentDuration = 30;
const { data: config } = useSWR<FrigateConfig>("config");
const reviewCameras = useMemo(() => {
if (!config) {
return [];
}
let cameras;
if (!filter || !filter.cameras) {
cameras = Object.values(config.cameras);
} else {
const filteredCams = filter.cameras;
cameras = Object.values(config.cameras).filter((cam) =>
filteredCams.includes(cam.name),
);
}
return cameras.sort((a, b) => a.ui.order - b.ui.order);
}, [config, filter]);
2024-03-11 17:31:05 -06:00
const videoPlayersRef = useRef<{ [camera: string]: PreviewController }>({});
2024-03-03 21:21:30 -07:00
2024-03-05 12:55:44 -07:00
// motion data
const { data: motionData } = useSWR<MotionData[]>([
2024-03-09 07:08:06 -07:00
"review/activity/motion",
2024-03-05 12:55:44 -07:00
{
before: timeRange.before,
after: timeRange.after,
scale: segmentDuration / 2,
2024-03-05 17:39:37 -07:00
cameras: filter?.cameras?.join(",") ?? null,
2024-03-05 12:55:44 -07:00
},
]);
2024-03-03 21:21:30 -07:00
// timeline time
const timeRangeSegments = useMemo(
2024-03-11 17:31:05 -06:00
() => getChunkedTimeRange(timeRange.after, timeRange.before),
[timeRange],
2024-03-03 21:21:30 -07:00
);
2024-03-07 17:31:43 -07:00
const initialIndex = useMemo(() => {
if (!startTime) {
return timeRangeSegments.ranges.length - 1;
}
return timeRangeSegments.ranges.findIndex(
2024-03-26 15:03:58 -06:00
(seg) => seg.after <= startTime && seg.before >= startTime,
);
// only render once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [selectedRangeIdx, setSelectedRangeIdx] = useState(initialIndex);
2024-03-03 21:21:30 -07:00
const [currentTime, setCurrentTime] = useState<number>(
2024-03-26 15:03:58 -06:00
startTime ?? timeRangeSegments.ranges[selectedRangeIdx]?.before,
2024-03-03 21:21:30 -07:00
);
2024-03-07 17:31:43 -07:00
const currentTimeRange = useMemo(
() => timeRangeSegments.ranges[selectedRangeIdx],
[selectedRangeIdx, timeRangeSegments],
);
2024-03-03 21:21:30 -07:00
2024-03-30 12:44:12 -06:00
const [previewStart, setPreviewStart] = useState(startTime);
const [scrubbing, setScrubbing] = useState(false);
2024-03-22 10:56:53 -06:00
const [playing, setPlaying] = useState(false);
2024-03-03 21:21:30 -07:00
// move to next clip
2024-03-05 12:55:44 -07:00
2024-03-03 21:21:30 -07:00
useEffect(() => {
2024-03-07 17:31:43 -07:00
if (
2024-03-26 15:03:58 -06:00
currentTime > currentTimeRange.before + 60 ||
currentTime < currentTimeRange.after - 60
2024-03-07 17:31:43 -07:00
) {
const index = timeRangeSegments.ranges.findIndex(
2024-03-26 15:03:58 -06:00
(seg) => seg.after <= currentTime && seg.before >= currentTime,
2024-03-07 17:31:43 -07:00
);
if (index != -1) {
2024-03-30 12:44:12 -06:00
setPreviewStart(currentTime);
2024-03-07 17:31:43 -07:00
setSelectedRangeIdx(index);
}
return;
}
2024-03-03 21:21:30 -07:00
Object.values(videoPlayersRef.current).forEach((controller) => {
controller.scrubToTimestamp(currentTime);
});
2024-03-30 12:44:12 -06:00
// only refresh when current time or available segments changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTime, timeRangeSegments]);
2024-03-07 17:31:43 -07:00
2024-03-22 10:56:53 -06:00
// playback
const [playbackRate, setPlaybackRate] = useState(8);
const [controlsOpen, setControlsOpen] = useState(false);
2024-03-25 11:19:55 -05:00
const nextTimestamp = useCameraMotionNextTimestamp(
timeRangeSegments.end,
segmentDuration,
motionOnly,
reviewItems?.all ?? [],
motionData ?? [],
currentTime,
);
const timeoutIdRef = useRef<NodeJS.Timeout | null>(null);
2024-03-22 10:56:53 -06:00
useEffect(() => {
if (nextTimestamp) {
if (!playing && timeoutIdRef.current != null) {
clearTimeout(timeoutIdRef.current);
2024-03-22 10:56:53 -06:00
return;
}
if (nextTimestamp >= timeRange.before - 4) {
setPlaying(false);
return;
}
const handleTimeout = () => {
setCurrentTime(nextTimestamp);
timeoutIdRef.current = setTimeout(handleTimeout, 500 / playbackRate);
};
2024-03-22 10:56:53 -06:00
timeoutIdRef.current = setTimeout(handleTimeout, 500 / playbackRate);
return () => {
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current);
}
};
}
}, [playing, playbackRate, nextTimestamp, setPlaying, timeRange]);
2024-03-22 10:56:53 -06:00
2024-03-24 12:39:28 -05:00
const { alignStartDateToTimeline } = useTimelineUtils({
segmentDuration,
});
const getDetectionType = useCallback(
(cameraName: string) => {
if (motionOnly) {
2024-03-25 11:19:55 -05:00
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)
);
});
2024-03-25 11:19:55 -05:00
return matchingItem ? "significant_motion" : null;
} else {
const segmentStartTime = alignStartDateToTimeline(currentTime);
const segmentEndTime = segmentStartTime + segmentDuration;
2024-04-11 06:42:16 -06:00
const matchingItem = reviewItems?.all.find((item) => {
const endTime = item.end_time ?? timeRange.before;
return (
2024-03-25 11:19:55 -05:00
((item.start_time >= segmentStartTime &&
item.start_time < segmentEndTime) ||
2024-04-11 06:42:16 -06:00
(endTime > segmentStartTime && endTime <= segmentEndTime) ||
2024-03-25 11:19:55 -05:00
(item.start_time <= segmentStartTime &&
2024-04-11 06:42:16 -06:00
endTime >= segmentEndTime)) &&
item.camera === cameraName
);
});
2024-03-25 11:19:55 -05:00
return matchingItem ? matchingItem.severity : null;
}
},
2024-03-25 11:19:55 -05:00
[
reviewItems,
motionData,
currentTime,
2024-04-11 06:42:16 -06:00
timeRange,
2024-03-25 11:19:55 -05:00
motionOnly,
alignStartDateToTimeline,
],
);
2024-06-07 06:57:15 -05:00
if (motionData?.length === 0) {
return (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuFolderX className="size-16" />
No motion data found
</div>
);
}
2024-07-21 13:14:59 -06:00
if (relevantPreviews == undefined) {
2024-03-07 17:31:43 -07:00
return <ActivityIndicator />;
}
2024-03-03 21:21:30 -07:00
return (
<>
2024-05-14 10:06:44 -05:00
<div className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4">
<div
ref={contentRef}
2024-06-02 12:00:59 -05:00
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",
2024-06-02 12:00:59 -05:00
"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;
2024-04-01 09:23:57 -05:00
let spans;
const aspectRatio = camera.detect.width / camera.detect.height;
if (aspectRatio > 2) {
2024-04-01 09:23:57 -05:00
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 (
2024-04-03 20:20:47 -05:00
<div key={camera.name} className={`relative ${spans}`}>
2024-04-04 15:54:58 -05:00
{motionData ? (
2024-04-05 08:44:00 -05:00
<>
<PreviewPlayer
className={`rounded-lg md:rounded-2xl ${spans} ${grow}`}
2024-04-05 08:44:00 -05:00
camera={camera.name}
timeRange={currentTimeRange}
startTime={previewStart}
2024-07-21 13:14:59 -06:00
cameraPreviews={relevantPreviews}
2024-04-05 08:44:00 -05:00
isScrubbing={scrubbing}
onControllerReady={(controller) => {
videoPlayersRef.current[camera.name] = controller;
}}
onClick={() =>
onOpenRecording({
camera: camera.name,
2024-04-08 08:35:35 -05:00
startTime: Math.min(
currentTime,
Date.now() / 1000 - 30,
),
2024-04-05 08:44:00 -05:00
severity: "significant_motion",
})
}
/>
<div
2024-05-14 10:06:44 -05:00
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"}`}
2024-04-05 08:44:00 -05:00
/>
</>
2024-04-04 15:54:58 -05:00
) : (
2024-04-05 08:44:00 -05:00
<Skeleton
2024-05-14 10:06:44 -05:00
className={`size-full rounded-lg md:rounded-2xl ${spans} ${grow}`}
2024-04-05 08:44:00 -05:00
/>
2024-04-04 15:54:58 -05:00
)}
2024-04-01 09:23:57 -05:00
</div>
);
})}
</div>
2024-03-03 21:21:30 -07:00
</div>
2024-05-14 10:06:44 -05:00
<div className="no-scrollbar w-[55px] overflow-y-auto md:w-[100px]">
2024-04-04 15:54:58 -05:00
{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 ?? []}
severityType="significant_motion"
contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => {
if (playing && scrubbing) {
setPlaying(false);
}
2024-03-22 10:56:53 -06:00
2024-04-04 15:54:58 -05:00
setScrubbing(scrubbing);
}}
dense={isMobileOnly}
2024-04-04 15:54:58 -05:00
/>
) : (
2024-04-05 08:44:00 -05:00
<Skeleton className="size-full" />
2024-04-04 15:54:58 -05:00
)}
2024-03-03 21:21:30 -07:00
</div>
2024-03-22 10:56:53 -06:00
<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;
2024-03-22 10:56:53 -06:00
if (wasPlaying) {
setPlaying(false);
}
2024-03-22 10:56:53 -06:00
setCurrentTime(currentTime + diff);
2024-03-22 10:56:53 -06:00
if (wasPlaying) {
setTimeout(() => setPlaying(true), 100);
}
}}
onSetPlaybackRate={setPlaybackRate}
/>
2024-03-03 21:21:30 -07:00
</>
);
}