import Logo from "@/components/Logo"; import NewReviewData from "@/components/dynamic/NewReviewData"; import ReviewActionGroup from "@/components/filter/ReviewActionGroup"; import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup"; import DynamicVideoPlayer, { DynamicVideoController, } from "@/components/player/DynamicVideoPlayer"; 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 { 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"; type EventViewProps = { reviewPages?: ReviewSegment[][]; reviewSummary?: ReviewSummary[]; relevantPreviews?: Preview[]; timeRange: { before: number; after: number }; reachedEnd: boolean; isValidating: boolean; filter?: ReviewFilter; severity: ReviewSeverity; setSeverity: (severity: ReviewSeverity) => void; loadNextPage: () => void; markItemAsReviewed: (review: ReviewSegment) => void; onOpenReview: (reviewId: string) => void; pullLatestData: () => void; updateFilter: (filter: ReviewFilter) => void; }; export default function EventView({ reviewPages, reviewSummary, relevantPreviews, timeRange, reachedEnd, isValidating, filter, severity, setSeverity, loadNextPage, markItemAsReviewed, onOpenReview, pullLatestData, updateFilter, }: EventViewProps) { const { data: config } = useSWR("config"); const contentRef = useRef(null); // review counts const reviewCounts = useMemo(() => { if (!reviewSummary || reviewSummary.length == 0) { return { alert: 0, detection: 0, significant_motion: 0 }; } let summary; if (filter?.before == undefined) { summary = reviewSummary[0]; } 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.find((check) => check.day == 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[] = []; reviewPages?.forEach((page) => { page.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, }; }, [reviewPages]); const currentItems = useMemo(() => { const current = reviewItems[severity]; if (!current || current.length == 0) { return null; } return current; }, [reviewItems, severity]); const pagingObserver = useRef(null); // 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 = currentItems?.find((seg) => seg.id == id); if (!review) { return; } axios.post( `export/${review.camera}/start/${review.start_time}/end/${review.end_time}`, { playback: "realtime" }, ); }, [currentItems], ); if (!config) { return ; } return (
{isMobile && ( )} setSeverity(value)} >
Alerts ∙ {reviewCounts.alert}
Detections ∙ {reviewCounts.detection}
Motion ∙ {reviewCounts.significant_motion}
{selectedReviews.length <= 0 ? ( ) : ( )}
{severity != "significant_motion" && ( )} {severity == "significant_motion" && ( )}
); } type DetectionReviewProps = { contentRef: MutableRefObject; currentItems: ReviewSegment[] | null; reviewItems: { all: ReviewSegment[]; alert: ReviewSegment[]; detection: ReviewSegment[]; significant_motion: ReviewSegment[]; }; relevantPreviews?: Preview[]; pagingObserver: MutableRefObject; selectedReviews: string[]; severity: ReviewSeverity; filter?: ReviewFilter; isValidating: boolean; reachedEnd: boolean; timeRange: { before: number; after: number }; loadNextPage: () => void; markItemAsReviewed: (review: ReviewSegment) => void; onSelectReview: (id: string, ctrl: boolean) => void; pullLatestData: () => void; }; function DetectionReview({ contentRef, currentItems, reviewItems, relevantPreviews, pagingObserver, selectedReviews, severity, filter, isValidating, reachedEnd, timeRange, loadNextPage, markItemAsReviewed, onSelectReview, pullLatestData, }: DetectionReviewProps) { const segmentDuration = 60; // preview const [previewTime, setPreviewTime] = useState(); // review interaction const lastReviewRef = useCallback( (node: HTMLElement | null) => { if (isValidating) return; if (pagingObserver.current) pagingObserver.current.disconnect(); try { pagingObserver.current = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && !reachedEnd) { loadNextPage(); } }); if (node) pagingObserver.current.observe(node); } catch (e) { // no op } }, [isValidating, pagingObserver, reachedEnd, loadNextPage], ); // 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 && ( )} {!isValidating && currentItems == null && (
There are no {severity.replace(/_/g, " ")} items to review
)}
{currentItems ? ( currentItems.map((value, segIdx) => { const lastRow = segIdx == currentItems.length - 1; const selected = selectedReviews.includes(value.id); return (
{lastRow && !reachedEnd && }
); }) ) : severity != "alert" ? (
) : null}
); } type MotionReviewProps = { contentRef: MutableRefObject; reviewItems: { all: ReviewSegment[]; alert: ReviewSegment[]; detection: ReviewSegment[]; significant_motion: ReviewSegment[]; }; relevantPreviews?: Preview[]; timeRange: { before: number; after: number }; filter?: ReviewFilter; }; function MotionReview({ contentRef, reviewItems, relevantPreviews, timeRange, filter, }: MotionReviewProps) { const segmentDuration = 30; const { data: config } = useSWR("config"); const [playerReady, setPlayerReady] = useState(false); 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]: DynamicVideoController }>( {}, ); // 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 [selectedRangeIdx, setSelectedRangeIdx] = useState( timeRangeSegments.ranges.length - 1, ); const [currentTime, setCurrentTime] = useState( timeRangeSegments.ranges[selectedRangeIdx].start, ); // move to next clip useEffect(() => { if ( !videoPlayersRef.current && Object.values(videoPlayersRef.current).length > 0 ) { return; } const firstController = Object.values(videoPlayersRef.current)[0]; if (firstController) { firstController.onClipChangedEvent((dir) => { if ( dir == "forward" && selectedRangeIdx < timeRangeSegments.ranges.length - 1 ) { setSelectedRangeIdx(selectedRangeIdx + 1); } else if (selectedRangeIdx > 0) { setSelectedRangeIdx(selectedRangeIdx - 1); } }); } }, [selectedRangeIdx, timeRangeSegments, videoPlayersRef, playerReady]); useEffect(() => { Object.values(videoPlayersRef.current).forEach((controller) => { controller.scrubToTimestamp(currentTime); }); }, [currentTime]); 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; setPlayerReady(true); }} />
); })}
); }