import Logo from "@/components/Logo"; import NewReviewData from "@/components/dynamic/NewReviewData"; 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 { useEventUtils } from "@/hooks/use-event-utils"; import { useScrollLockout } from "@/hooks/use-mouse-listener"; import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; import { MotionData, ReviewFilter, ReviewSegment, ReviewSeverity, ReviewSummary, } from "@/types/review"; import { getChunkedTimeRange } from "@/utils/timelineUtil"; import axios from "axios"; import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { isDesktop, isMobile } from "react-device-detect"; import { LuFolderCheck } from "react-icons/lu"; import { MdCircle } from "react-icons/md"; import useSWR from "swr"; import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline"; import { Button } from "@/components/ui/button"; import PreviewVideoPlayer, { PreviewVideoController, } from "@/components/player/PreviewVideoPlayer"; type EventViewProps = { reviews?: ReviewSegment[]; reviewSummary?: ReviewSummary; relevantPreviews?: Preview[]; timeRange: { before: number; after: number }; filter?: ReviewFilter; severity: ReviewSeverity; startTime?: number; setSeverity: (severity: ReviewSeverity) => void; markItemAsReviewed: (review: ReviewSegment) => void; onOpenReview: (reviewId: string) => void; pullLatestData: () => void; updateFilter: (filter: ReviewFilter) => void; }; export default function EventView({ reviews, reviewSummary, relevantPreviews, timeRange, filter, severity, startTime, setSeverity, markItemAsReviewed, onOpenReview, pullLatestData, updateFilter, }: EventViewProps) { const { data: config } = useSWR("config"); const contentRef = useRef(null); // review counts const reviewCounts = useMemo(() => { if (!reviewSummary) { return { alert: 0, detection: 0, significant_motion: 0 }; } let summary; if (filter?.before == undefined) { summary = reviewSummary["last24Hours"]; } 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 (filter?.showReviewed == 1) { return { alert: summary.total_alert, detection: summary.total_detection, significant_motion: summary.total_motion, }; } 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, reviewSummary]); // review paging const reviewItems = useMemo(() => { const all: ReviewSegment[] = []; const alerts: ReviewSegment[] = []; const detections: ReviewSegment[] = []; const motion: ReviewSegment[] = []; reviews?.forEach((segment) => { all.push(segment); switch (segment.severity) { case "alert": alerts.push(segment); break; case "detection": detections.push(segment); break; default: motion.push(segment); break; } }); return { all: all, alert: alerts, detection: detections, significant_motion: motion, }; }, [reviews]); // review interaction const [selectedReviews, setSelectedReviews] = useState([]); const onSelectReview = useCallback( (reviewId: string, ctrl: boolean) => { if (selectedReviews.length > 0 || ctrl) { const index = selectedReviews.indexOf(reviewId); 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(reviewId); setSelectedReviews(copy); } } else { onOpenReview(reviewId); } }, [selectedReviews, setSelectedReviews, onOpenReview], ); const exportReview = useCallback( (id: string) => { const review = reviewItems.all?.find((seg) => seg.id == id); if (!review) { return; } axios.post( `export/${review.camera}/start/${review.start_time}/end/${review.end_time}`, { playback: "realtime" }, ); }, [reviewItems], ); if (!config) { return ; } return (
{isMobile && ( )} value ? setSeverity(value) : null } // don't allow the severity to be unselected >
Alerts ∙ {reviewCounts.alert}
Detections ∙ {reviewCounts.detection}
Motion
{selectedReviews.length <= 0 ? ( ) : ( )}
{severity != "significant_motion" && ( )} {severity == "significant_motion" && ( )}
); } type DetectionReviewProps = { contentRef: MutableRefObject; reviewItems: { all: ReviewSegment[]; alert: ReviewSegment[]; detection: ReviewSegment[]; significant_motion: ReviewSegment[]; }; itemsToReview?: number; relevantPreviews?: Preview[]; selectedReviews: string[]; severity: ReviewSeverity; filter?: ReviewFilter; timeRange: { before: number; after: number }; markItemAsReviewed: (review: ReviewSegment) => void; onSelectReview: (id: string, ctrl: boolean) => void; pullLatestData: () => void; }; function DetectionReview({ contentRef, reviewItems, itemsToReview, relevantPreviews, selectedReviews, severity, filter, timeRange, markItemAsReviewed, onSelectReview, pullLatestData, }: DetectionReviewProps) { const segmentDuration = 60; // review data const currentItems = useMemo(() => { const current = reviewItems[severity]; if (!current || current.length == 0) { return null; } if (filter?.showReviewed != 1) { return current.filter((seg) => !seg.has_been_reviewed); } else { return current; } // only refresh when severity or filter changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [severity, filter, reviewItems.all.length]); // preview const [previewTime, setPreviewTime] = useState(); // review interaction const [hasUpdate, setHasUpdate] = useState(false); const markAllReviewed = useCallback(async () => { if (!currentItems) { return; } await axios.post(`reviews/viewed`, { ids: currentItems?.map((seg) => seg.id), }); setHasUpdate(false); pullLatestData(); }, [currentItems, pullLatestData]); // timeline interaction const { alignStartDateToTimeline } = useEventUtils( reviewItems.all, segmentDuration, ); const scrollLock = useScrollLockout(contentRef); const [minimap, setMinimap] = useState([]); const minimapObserver = useRef(null); useEffect(() => { const visibleTimestamps = new Set(); 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; } return contentRef.current.scrollHeight > contentRef.current.clientHeight; // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [contentRef.current?.scrollHeight, severity]); return ( <>
{filter?.before == undefined && ( )} {itemsToReview == 0 && (
There are no {severity.replace(/_/g, " ")} items to review
)}
{currentItems && currentItems.map((value) => { const selected = selectedReviews.includes(value.id); return (
); })} {(itemsToReview ?? 0) > 0 && (
)}
); } type MotionReviewProps = { contentRef: MutableRefObject; reviewItems: { all: ReviewSegment[]; alert: ReviewSegment[]; detection: ReviewSegment[]; significant_motion: ReviewSegment[]; }; relevantPreviews?: Preview[]; timeRange: { before: number; after: number }; startTime?: number; filter?: ReviewFilter; onSelectReview: (data: string, ctrl: boolean) => void; }; function MotionReview({ contentRef, reviewItems, relevantPreviews, timeRange, startTime, filter, onSelectReview, }: MotionReviewProps) { const segmentDuration = 30; const { data: config } = useSWR("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]); const videoPlayersRef = useRef<{ [camera: string]: PreviewVideoController }>( {}, ); // motion data const { data: motionData } = useSWR([ "review/activity/motion", { before: timeRange.before, after: timeRange.after, scale: segmentDuration / 2, cameras: filter?.cameras?.join(",") ?? null, }, ]); // timeline time const lastFullHour = useMemo(() => { const end = new Date(timeRange.before * 1000); end.setMinutes(0, 0, 0); return end.getTime() / 1000; }, [timeRange]); const timeRangeSegments = useMemo( () => getChunkedTimeRange(timeRange.after, lastFullHour), [lastFullHour, timeRange], ); const initialIndex = useMemo(() => { if (!startTime) { return timeRangeSegments.ranges.length - 1; } return timeRangeSegments.ranges.findIndex( (seg) => seg.start <= startTime && seg.end >= startTime, ); // only render once // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const [selectedRangeIdx, setSelectedRangeIdx] = useState(initialIndex); const [currentTime, setCurrentTime] = useState( startTime ?? timeRangeSegments.ranges[selectedRangeIdx]?.start, ); const currentTimeRange = useMemo( () => timeRangeSegments.ranges[selectedRangeIdx], [selectedRangeIdx, timeRangeSegments], ); // move to next clip useEffect(() => { if ( currentTime > currentTimeRange.end + 60 || currentTime < currentTimeRange.start - 60 ) { const index = timeRangeSegments.ranges.findIndex( (seg) => seg.start <= currentTime && seg.end >= currentTime, ); if (index != -1) { Object.values(videoPlayersRef.current).forEach((controller) => { controller.setNewPreviewStartTime(currentTime); }); setSelectedRangeIdx(index); } return; } Object.values(videoPlayersRef.current).forEach((controller) => { controller.scrubToTimestamp(currentTime); }); }, [currentTime, currentTimeRange, timeRangeSegments]); if (!relevantPreviews) { return ; } return ( <>
{reviewCameras.map((camera) => { let grow; const aspectRatio = camera.detect.width / camera.detect.height; if (aspectRatio > 2) { grow = "sm:col-span-2 aspect-wide"; } else if (aspectRatio < 1) { grow = "md:row-span-2 md:h-full aspect-tall"; } else { grow = "aspect-video"; } return ( { videoPlayersRef.current[camera.name] = controller; }} onClick={() => onSelectReview(`motion,${camera.name},${currentTime}`, false) } /> ); })}
); }