From 5864e6791b52c4ae0df50cd53bd13bf83a1be7b5 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 3 Mar 2024 06:22:37 -0700 Subject: [PATCH] Break detection grid into separate function --- .../components/player/DynamicVideoPlayer.tsx | 2 + web/src/utils/timelineUtil.tsx | 32 +- web/src/views/events/DesktopRecordingView.tsx | 4 +- web/src/views/events/EventView.tsx | 563 ++++++++++++------ 4 files changed, 419 insertions(+), 182 deletions(-) diff --git a/web/src/components/player/DynamicVideoPlayer.tsx b/web/src/components/player/DynamicVideoPlayer.tsx index 3ce3ba032..e4dccedda 100644 --- a/web/src/components/player/DynamicVideoPlayer.tsx +++ b/web/src/components/player/DynamicVideoPlayer.tsx @@ -144,6 +144,7 @@ export default function DynamicVideoPlayer({ const initialPreviewSource = useMemo(() => { const preview = cameraPreviews.find( (preview) => + preview.camera == camera && Math.round(preview.start) >= timeRange.start && Math.floor(preview.end) <= timeRange.end, ); @@ -191,6 +192,7 @@ export default function DynamicVideoPlayer({ const preview = cameraPreviews.find( (preview) => + preview.camera == camera && Math.round(preview.start) >= timeRange.start && Math.floor(preview.end) <= timeRange.end, ); diff --git a/web/src/utils/timelineUtil.tsx b/web/src/utils/timelineUtil.tsx index 4bb6816a8..291a3fb52 100644 --- a/web/src/utils/timelineUtil.tsx +++ b/web/src/utils/timelineUtil.tsx @@ -120,7 +120,7 @@ export function getTimelineItemDescription(timelineItem: Timeline) { } } -export function getChunkedTimeRange(timestamp: number) { +export function getChunkedTimeDay(timestamp: number) { const endOfThisHour = new Date(); endOfThisHour.setHours(endOfThisHour.getHours() + 1, 0, 0, 0); const data: { start: number; end: number }[] = []; @@ -147,3 +147,33 @@ export function getChunkedTimeRange(timestamp: number) { return { start: startTimestamp, end, ranges: data }; } + +export function getChunkedTimeRange( + startTimestamp: number, + endTimestamp: number, +) { + const endOfThisHour = new Date(); + endOfThisHour.setHours(endOfThisHour.getHours() + 1, 0, 0, 0); + const data: { start: number; end: number }[] = []; + const startDay = new Date(startTimestamp * 1000); + startDay.setMinutes(0, 0, 0); + let start = startDay.getTime() / 1000; + let end = 0; + + while (end < endTimestamp) { + startDay.setHours(startDay.getHours() + 1); + + if (startDay > endOfThisHour || startDay.getTime() / 1000 > endTimestamp) { + break; + } + + end = endOfHourOrCurrentTime(startDay.getTime() / 1000); + data.push({ + start, + end, + }); + start = startDay.getTime() / 1000; + } + + return { start: startTimestamp, end: endTimestamp, ranges: data }; +} diff --git a/web/src/views/events/DesktopRecordingView.tsx b/web/src/views/events/DesktopRecordingView.tsx index 3ee05f729..2f646e7bf 100644 --- a/web/src/views/events/DesktopRecordingView.tsx +++ b/web/src/views/events/DesktopRecordingView.tsx @@ -5,7 +5,7 @@ import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; import { Button } from "@/components/ui/button"; import { Preview } from "@/types/preview"; import { ReviewSegment } from "@/types/review"; -import { getChunkedTimeRange } from "@/utils/timelineUtil"; +import { getChunkedTimeDay } from "@/utils/timelineUtil"; import { useEffect, useMemo, useRef, useState } from "react"; import { IoMdArrowRoundBack } from "react-icons/io"; import { useNavigate } from "react-router-dom"; @@ -31,7 +31,7 @@ export default function DesktopRecordingView({ // timeline time const timeRange = useMemo( - () => getChunkedTimeRange(selectedReview.start_time), + () => getChunkedTimeDay(selectedReview.start_time), [selectedReview], ); const [selectedRangeIdx, setSelectedRangeIdx] = useState( diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 3dec71458..ebd0176cc 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -2,6 +2,9 @@ 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"; @@ -10,14 +13,17 @@ 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 { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review"; +import { getChunkedTimeRange } from "@/utils/timelineUtil"; import axios from "axios"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +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"; @@ -57,7 +63,6 @@ export default function EventView({ }: EventViewProps) { const { data: config } = useSWR("config"); const contentRef = useRef(null); - const segmentDuration = 60; // review counts @@ -122,11 +127,6 @@ export default function EventView({ }; }, [reviewPages]); - const { alignStartDateToTimeline } = useEventUtils( - reviewItems.all, - segmentDuration, - ); - const currentItems = useMemo(() => { const current = reviewItems[severity]; @@ -137,99 +137,7 @@ export default function EventView({ return current; }, [reviewItems, severity]); - 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]); - - // timeline interaction - - const pagingObserver = useRef(); - 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, reachedEnd, loadNextPage], - ); - - const [minimap, setMinimap] = useState([]); - const minimapObserver = useRef(); - 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]); - const minimapRef = useCallback( - (node: HTMLElement | null) => { - if (!minimapObserver.current) { - return; - } - - try { - if (node) minimapObserver.current.observe(node); - } catch (e) { - // no op - } - }, - [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]); - - // preview playback - - const [previewTime, setPreviewTime] = useState(); - const scrollLock = useScrollLockout(contentRef); + const pagingObserver = useRef(null); // review interaction @@ -339,82 +247,379 @@ export default function EventView({
-
- {filter?.before == undefined && ( - - )} - - {!isValidating && currentItems == null && ( -
- - There are no {severity.replace(/_/g, " ")} items to review -
- )} - -
- {currentItems ? ( - currentItems.map((value, segIdx) => { - const lastRow = segIdx == reviewItems[severity].length - 1; - const selected = selectedReviews.includes(value.id); - - return ( -
-
- -
- {lastRow && !reachedEnd && } -
- ); - }) - ) : severity != "alert" ? ( -
- ) : null} -
-
-
- -
+ )} + {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: (id: string) => 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 reviewCameras = useMemo(() => { + if (!config) { + return []; + } + + let cameras; + if (!filter || !filter.cameras) { + cameras = Object.values(config.cameras).filter( + (cam) => cam.name == "front_cam", + ); + } 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, + ); + + 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); + }} + /> +
+ ); + })} +
+
+ +
+ + ); +}