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, RecordingsSummary, REVIEW_PADDING, ReviewFilter, ReviewSegment, ReviewSeverity, ReviewSummary, SegmentedReviewData, } from "@/types/review"; import { getChunkedTimeRange } from "@/utils/timelineUtil"; 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 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"; import { TimeRange } from "@/types/timeline"; import { useCameraMotionNextTimestamp } 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 ReviewDetailDialog from "@/components/overlay/detail/ReviewDetailDialog"; import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; 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; markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void; onOpenRecording: (recordingInfo: RecordingStartingPoint) => void; pullLatestData: () => void; updateFilter: (filter: ReviewFilter) => void; }; export default function EventView({ reviewItems, currentReviewItems, reviewSummary, recordingsSummary, relevantPreviews, timeRange, filter, severity, startTime, showReviewed, setShowReviewed, 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: -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]); // 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 - REVIEW_PADDING, severity: review.severity, }); 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]); 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 == 200) { toast.success( "Successfully started export. View the file in the /exports folder.", { position: "top-center" }, ); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error(`Failed to start export: ${errorMessage}`, { position: "top-center", }); }); }, [reviewItems], ); const [motionOnly, setMotionOnly] = useState(false); const [severityToggle, setSeverityToggle] = useOptimisticState( severity, setSeverity, 100, ); // review filter info const reviewFilterList = useMemo(() => { const uniqueLabels = new Set(); const uniqueZones = new Set(); 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 ; } return (
{isMobile && ( )} value ? setSeverityToggle(value) : null } // don't allow the severity to be unselected > {isMobileOnly ? (
{reviewCounts.alert > -1 ? ( reviewCounts.alert ) : ( )}
) : ( <>
Alerts {reviewCounts.alert > -1 ? ( ` ∙ ${reviewCounts.alert}` ) : ( )}
)}
{isMobileOnly ? (
{reviewCounts.detection > -1 ? ( reviewCounts.detection ) : ( )}
) : ( <>
Detections {reviewCounts.detection > -1 ? ( ` ∙ ${reviewCounts.detection}` ) : ( )}
)}
{isMobileOnly ? ( ) : ( <>
Motion
)}
{selectedReviews.length <= 0 ? ( ) : ( )}
{severity != "significant_motion" && ( )} {severity == "significant_motion" && ( )}
); } type DetectionReviewProps = { contentRef: MutableRefObject; reviewItems?: { all: ReviewSegment[]; alert: ReviewSegment[]; detection: ReviewSegment[]; significant_motion: ReviewSegment[]; }; currentItems: ReviewSegment[] | null; itemsToReview?: number; relevantPreviews?: Preview[]; selectedReviews: string[]; severity: ReviewSeverity; filter?: ReviewFilter; timeRange: { before: number; after: number }; startTime?: number; loading: boolean; markItemAsReviewed: (review: ReviewSegment) => void; markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void; onSelectReview: (review: ReviewSegment, ctrl: boolean) => void; onSelectAllReviews: () => void; setSelectedReviews: (reviewIds: string[]) => void; pullLatestData: () => void; }; function DetectionReview({ contentRef, reviewItems, currentItems, itemsToReview, relevantPreviews, selectedReviews, severity, filter, timeRange, startTime, loading, markItemAsReviewed, markAllItemsAsReviewed, onSelectReview, onSelectAllReviews, setSelectedReviews, pullLatestData, }: DetectionReviewProps) { const reviewTimelineRef = useRef(null); // detail const [reviewDetail, setReviewDetail] = useState(); // preview const [previewTime, setPreviewTime] = useState(); 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 = useMemo( () => [ { segmentDuration: 60, timestampSpread: 15 }, { segmentDuration: 30, timestampSpread: 5 }, { segmentDuration: 10, timestampSpread: 1 }, ], [], ); const handleZoomChange = useCallback( (newZoomLevel: number) => { setZoomSettings(possibleZoomLevels[newZoomLevel]); }, [possibleZoomLevels], ); 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([]); 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], ); // 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", "PageDown", "PageUp"], (key, modifiers) => { if (modifiers.repeat || !modifiers.down) { return; } switch (key) { case "a": if (modifiers.ctrl) { onSelectAllReviews(); } break; case "r": if (selectedReviews.length > 0) { currentItems?.forEach((item) => { if (selectedReviews.includes(item.id)) { item.has_been_reviewed = true; markItemAsReviewed(item); } }); setSelectedReviews([]); } break; case "PageDown": contentRef.current?.scrollBy({ top: contentRef.current.clientHeight / 2, behavior: "smooth", }); break; case "PageUp": contentRef.current?.scrollBy({ top: -contentRef.current.clientHeight / 2, behavior: "smooth", }); break; } }); return ( <>
{filter?.before == undefined && ( )} {!currentItems && (
)} {!loading && currentItems?.length === 0 && (
There are no {severity.replace(/_/g, " ")}s to review
)}
{!loading && currentItems ? currentItems.map((value) => { const selected = selectedReviews.includes(value.id); return (
{ if (detail) { setReviewDetail(review); } else { onSelectReview(review, ctrl); } }} />
); }) : (itemsToReview ?? 0) > 0 && Array(itemsToReview) .fill(0) .map((_, idx) => ( ))} {!loading && (currentItems?.filter((seg) => seg.end_time)?.length ?? 0) > 0 && (itemsToReview ?? 0) > 0 && (
)}
{loading ? ( ) : ( )}
{loading ? ( ) : ( )}
); } type MotionReviewProps = { contentRef: MutableRefObject; reviewItems?: { all: ReviewSegment[]; alert: ReviewSegment[]; detection: ReviewSegment[]; significant_motion: ReviewSegment[]; }; relevantPreviews?: Preview[]; timeRange: TimeRange; 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.after <= startTime && seg.before >= 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]?.before, ); const currentTimeRange = useMemo( () => timeRangeSegments.ranges[selectedRangeIdx], [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 nextTimestamp = useCameraMotionNextTimestamp( timeRangeSegments.end, segmentDuration, motionOnly, reviewItems?.all ?? [], motionData ?? [], currentTime, ); const timeoutIdRef = useRef(null); 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 { alignStartDateToTimeline } = useTimelineUtils({ segmentDuration, }); 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 (
No motion data found
); } if (relevantPreviews == undefined) { return ; } return ( <>
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 (
{motionData ? ( <> { videoPlayersRef.current[camera.name] = controller; }} onClick={() => onOpenRecording({ camera: camera.name, startTime: Math.min( currentTime, Date.now() / 1000 - 30, ), severity: "significant_motion", }) } />
) : ( )}
); })}
{motionData ? ( { if (playing && scrubbing) { setPlaying(false); } setScrubbing(scrubbing); }} dense={isMobileOnly} isZooming={false} zoomDirection={null} /> ) : ( )}
{ const wasPlaying = playing; if (wasPlaying) { setPlaying(false); } setCurrentTime(currentTime + diff); if (wasPlaying) { setTimeout(() => setPlaying(true), 100); } }} onSetPlaybackRate={setPlaybackRate} /> ); }