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 { 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, 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 PreviewPlayer, { PreviewController, } from "@/components/player/PreviewPlayer"; import SummaryTimeline from "@/components/timeline/SummaryTimeline"; import { RecordingStartingPoint } from "@/types/record"; import VideoControls from "@/components/player/VideoControls"; 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; markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void; onOpenRecording: (recordingInfo: RecordingStartingPoint) => void; pullLatestData: () => void; updateFilter: (filter: ReviewFilter) => void; }; export default function EventView({ reviews, reviewSummary, relevantPreviews, timeRange, filter, severity, startTime, setSeverity, markItemAsReviewed, markAllItemsAsReviewed, onOpenRecording, 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: -1, detection: -1, significant_motion: -1 }; } 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(() => { if (!reviews) { return undefined; } 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( (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, startTime: review.start_time, severity: review.severity, }); markItemAsReviewed(review); } }, [selectedReviews, setSelectedReviews, onOpenRecording, markItemAsReviewed], ); 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], ); const [motionOnly, setMotionOnly] = useState(false); if (!config) { return ; } return (
{isMobile && ( )} value ? setSeverity(value) : null } // don't allow the severity to be unselected >
Alerts{` ∙ ${reviewCounts.alert > -1 ? reviewCounts.alert : ""}`}
Detections {` ∙ ${reviewCounts.detection > -1 ? 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; markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void; onSelectReview: (review: ReviewSegment, ctrl: boolean) => void; pullLatestData: () => void; }; function DetectionReview({ contentRef, reviewItems, itemsToReview, relevantPreviews, selectedReviews, severity, filter, timeRange, markItemAsReviewed, markAllItemsAsReviewed, onSelectReview, pullLatestData, }: DetectionReviewProps) { const reviewTimelineRef = useRef(null); const segmentDuration = 60; // review data const currentItems = useMemo(() => { if (!reviewItems) { return null; } const current = reviewItems[severity]; if (!current || current.length == 0) { return []; } 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(); const onPreviewTimeUpdate = useCallback( (time: number | undefined) => { if (!time) { setPreviewTime(time); return; } if (!previewTime || time > previewTime) { setPreviewTime(time); } }, [previewTime, setPreviewTime], ); // review interaction const [hasUpdate, setHasUpdate] = useState(false); // timeline interaction const timelineDuration = useMemo( () => timeRange.before - timeRange.after, [timeRange], ); const { alignStartDateToTimeline, getVisibleTimelineDuration } = useTimelineUtils({ segmentDuration, timelineDuration, timelineRef: reviewTimelineRef, }); 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; } // 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], ); return ( <>
{filter?.before == undefined && ( )} {!currentItems && (
)} {currentItems?.length === 0 && (
There are no {severity.replace(/_/g, " ")}s to review
)}
{currentItems && currentItems.map((value) => { const selected = selectedReviews.includes(value.id); return (
); })} {(currentItems?.length ?? 0) > 0 && (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; motionOnly?: boolean; onOpenRecording: (data: RecordingStartingPoint) => void; }; function MotionReview({ contentRef, reviewItems, relevantPreviews, timeRange, startTime, filter, motionOnly = false, onOpenRecording, }: 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]: PreviewController }>({}); // 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 timeRangeSegments = useMemo( () => getChunkedTimeRange(timeRange.after, timeRange.before), [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]?.end, ); const currentTimeRange = useMemo( () => timeRangeSegments.ranges[selectedRangeIdx], [selectedRangeIdx, timeRangeSegments], ); const [scrubbing, setScrubbing] = useState(false); const [playing, setPlaying] = useState(false); // 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]); // playback const [playbackRate, setPlaybackRate] = useState(8); const [controlsOpen, setControlsOpen] = useState(false); useEffect(() => { if (!playing) { return; } const interval = 500 / playbackRate; const startTime = currentTime; let counter = 0; const intervalId = setInterval(() => { counter += 0.5; if (startTime + counter >= timeRange.before) { setPlaying(false); return; } setCurrentTime(startTime + counter); }, interval); return () => { clearInterval(intervalId); }; // do not render when current time changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [playing, playbackRate]); 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={() => onOpenRecording({ camera: camera.name, startTime: currentTime, severity: "significant_motion", }) } /> ); })}
{ if (playing && scrubbing) { setPlaying(false); } setScrubbing(scrubbing); }} />
{ const wasPlaying = playing; if (wasPlaying) { setPlaying(false); } setCurrentTime(currentTime + diff); if (wasPlaying) { setTimeout(() => setPlaying(true), 100); } }} onSetPlaybackRate={setPlaybackRate} show={currentTime < timeRange.before - 4} /> ); }