diff --git a/web/src/components/player/DynamicVideoPlayer.tsx b/web/src/components/player/DynamicVideoPlayer.tsx index 3ce3ba032..e5a9e36c1 100644 --- a/web/src/components/player/DynamicVideoPlayer.tsx +++ b/web/src/components/player/DynamicVideoPlayer.tsx @@ -18,6 +18,8 @@ import { Recording } from "@/types/record"; import { Preview } from "@/types/preview"; import { DynamicPlayback } from "@/types/playback"; +type PlayerMode = "playback" | "scrubbing"; + /** * Dynamically switches between video playback and scrubbing preview player. */ @@ -26,6 +28,7 @@ type DynamicVideoPlayerProps = { camera: string; timeRange: { start: number; end: number }; cameraPreviews: Preview[]; + previewOnly?: boolean; onControllerReady?: (controller: DynamicVideoController) => void; }; export default function DynamicVideoPlayer({ @@ -33,6 +36,7 @@ export default function DynamicVideoPlayer({ camera, timeRange, cameraPreviews, + previewOnly = false, onControllerReady, }: DynamicVideoPlayerProps) { const apiHost = useApiHost(); @@ -52,16 +56,15 @@ export default function DynamicVideoPlayer({ return ( config.cameras[camera].detect.width / config.cameras[camera].detect.height < - 1.7 + 1 ); }, [camera, config]); // controlling playback const playerRef = useRef(undefined); - const previewRef = useRef(undefined); - const [isScrubbing, setIsScrubbing] = useState(false); - const [hasPreview, setHasPreview] = useState(false); + const previewRef = useRef(null); + const [isScrubbing, setIsScrubbing] = useState(previewOnly); const [focusedItem, setFocusedItem] = useState( undefined, ); @@ -74,10 +77,11 @@ export default function DynamicVideoPlayer({ playerRef, previewRef, (config.cameras[camera]?.detect?.annotation_offset || 0) / 1000, + previewOnly ? "scrubbing" : "playback", setIsScrubbing, setFocusedItem, ); - }, [camera, config]); + }, [camera, config, previewOnly]); // keyboard control @@ -144,18 +148,17 @@ 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, ); if (preview) { - setHasPreview(true); return { src: preview.src, type: preview.type, }; } else { - setHasPreview(false); return undefined; } @@ -163,6 +166,8 @@ export default function DynamicVideoPlayer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const [currentPreview, setCurrentPreview] = useState(initialPreviewSource); + // state of playback player const recordingParams = useMemo(() => { @@ -172,12 +177,12 @@ export default function DynamicVideoPlayer({ }; }, [timeRange]); const { data: recordings } = useSWR( - [`${camera}/recordings`, recordingParams], + previewOnly ? null : [`${camera}/recordings`, recordingParams], { revalidateOnFocus: false }, ); useEffect(() => { - if (!controller || !recordings || recordings.length == 0) { + if (!controller || (!previewOnly && !recordings)) { return; } @@ -191,13 +196,14 @@ export default function DynamicVideoPlayer({ const preview = cameraPreviews.find( (preview) => + preview.camera == camera && Math.round(preview.start) >= timeRange.start && Math.floor(preview.end) <= timeRange.end, ); - setHasPreview(preview != undefined); + setCurrentPreview(preview); controller.newPlayback({ - recordings, + recordings: recordings ?? [], playbackUri, preview, }); @@ -212,74 +218,74 @@ export default function DynamicVideoPlayer({ return (
-
- { - playerRef.current = player; - player.on("playing", () => setFocusedItem(undefined)); - player.on("timeupdate", () => { - controller.updateProgress(player.currentTime() || 0); - }); - player.on("ended", () => controller.fireClipChangeEvent("forward")); - - if (onControllerReady) { - onControllerReady(controller); - } - }} - onDispose={() => { - playerRef.current = undefined; - }} + {!previewOnly && ( +
- {config && focusedItem && ( - - )} - -
-
{ + playerRef.current = player; + player.on("playing", () => setFocusedItem(undefined)); + player.on("timeupdate", () => { + controller.updateProgress(player.currentTime() || 0); + }); + player.on("ended", () => + controller.fireClipChangeEvent("forward"), + ); + + if (onControllerReady) { + onControllerReady(controller); + } + }} + onDispose={() => { + playerRef.current = undefined; + }} + > + {config && focusedItem && ( + + )} + +
+ )} +
+ {currentPreview != undefined && ( + + )} +
); } @@ -287,10 +293,10 @@ export default function DynamicVideoPlayer({ export class DynamicVideoController { // main state private playerRef: MutableRefObject; - private previewRef: MutableRefObject; + private previewRef: MutableRefObject; private setScrubbing: (isScrubbing: boolean) => void; private setFocusedItem: (timeline: Timeline) => void; - private playerMode: "playback" | "scrubbing" = "playback"; + private playerMode: PlayerMode = "playback"; // playback private recordings: Recording[] = []; @@ -299,6 +305,7 @@ export class DynamicVideoController { undefined; private annotationOffset: number; private timeToStart: number | undefined = undefined; + private clipChangeLockout: boolean = true; // preview private preview: Preview | undefined = undefined; @@ -308,14 +315,16 @@ export class DynamicVideoController { constructor( playerRef: MutableRefObject, - previewRef: MutableRefObject, + previewRef: MutableRefObject, annotationOffset: number, + defaultMode: PlayerMode, setScrubbing: (isScrubbing: boolean) => void, setFocusedItem: (timeline: Timeline) => void, ) { this.playerRef = playerRef; this.previewRef = previewRef; this.annotationOffset = annotationOffset; + this.playerMode = defaultMode; this.setScrubbing = setScrubbing; this.setFocusedItem = setFocusedItem; } @@ -334,12 +343,6 @@ export class DynamicVideoController { } this.preview = newPlayback.preview; - if (this.preview && this.previewRef.current) { - this.previewRef.current.src({ - src: this.preview.src, - type: this.preview.type, - }); - } } seekToTimestamp(time: number, play: boolean = false) { @@ -427,17 +430,39 @@ export class DynamicVideoController { } if (time > this.preview.end) { + if (this.clipChangeLockout) { + return; + } + if (this.playerMode == "scrubbing") { this.playerMode = "playback"; this.setScrubbing(false); this.timeToSeek = undefined; this.seeking = false; this.readyToScrub = false; + this.clipChangeLockout = true; this.fireClipChangeEvent("forward"); } return; } + if (time < this.preview.start) { + if (this.clipChangeLockout) { + return; + } + + if (this.playerMode == "scrubbing") { + this.playerMode = "playback"; + this.setScrubbing(false); + this.timeToSeek = undefined; + this.seeking = false; + this.readyToScrub = false; + this.clipChangeLockout = true; + this.fireClipChangeEvent("backward"); + } + return; + } + if (this.playerMode != "scrubbing") { this.playerMode = "scrubbing"; this.playerRef.current?.pause(); @@ -447,25 +472,33 @@ export class DynamicVideoController { if (this.seeking) { this.timeToSeek = time; } else { - this.previewRef.current?.currentTime( - Math.max(0, time - this.preview.start), - ); - this.seeking = true; + if (this.previewRef.current) { + this.previewRef.current.currentTime = Math.max( + 0, + time - this.preview.start, + ); + this.seeking = true; + } } } finishedSeeking() { - if (!this.preview || this.playerMode == "playback") { + if ( + !this.previewRef.current || + !this.preview || + this.playerMode == "playback" + ) { return; } + this.clipChangeLockout = false; + if ( this.timeToSeek && - this.timeToSeek != this.previewRef.current?.currentTime() + this.timeToSeek != this.previewRef.current?.currentTime ) { - this.previewRef.current?.currentTime( - this.timeToSeek - this.preview.start, - ); + this.previewRef.current.currentTime = + this.timeToSeek - this.preview.start; } else { this.seeking = false; } diff --git a/web/src/components/player/VideoPlayer.tsx b/web/src/components/player/VideoPlayer.tsx index d1b8a22a2..881238bec 100644 --- a/web/src/components/player/VideoPlayer.tsx +++ b/web/src/components/player/VideoPlayer.tsx @@ -88,7 +88,7 @@ export default function VideoPlayer({ return (
-
+
{children}
); diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index faece1026..3d601e290 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -5,8 +5,8 @@ import useOverlayState from "@/hooks/use-overlay-state"; import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review"; -import DesktopRecordingView from "@/views/events/DesktopRecordingView"; import EventView from "@/views/events/EventView"; +import RecordingView from "@/views/events/RecordingView"; import axios from "axios"; import { useCallback, useMemo, useState } from "react"; import useSWR from "swr"; @@ -220,7 +220,7 @@ export default function Events() { if (selectedData) { return ( - 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/EventView.tsx b/web/src/views/events/EventView.tsx index 3dec71458..e00a10a30 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"; @@ -16,8 +19,16 @@ import { ReviewSeverity, ReviewSummary, } 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 +68,6 @@ export default function EventView({ }: EventViewProps) { const { data: config } = useSWR("config"); const contentRef = useRef(null); - const segmentDuration = 60; // review counts @@ -122,11 +132,6 @@ export default function EventView({ }; }, [reviewPages]); - const { alignStartDateToTimeline } = useEventUtils( - reviewItems.all, - segmentDuration, - ); - const currentItems = useMemo(() => { const current = reviewItems[severity]; @@ -137,99 +142,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 +252,404 @@ 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 [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); + }} + /> +
+ ); + })} +
+
+ +
+ + ); +} diff --git a/web/src/views/events/DesktopRecordingView.tsx b/web/src/views/events/RecordingView.tsx similarity index 94% rename from web/src/views/events/DesktopRecordingView.tsx rename to web/src/views/events/RecordingView.tsx index 3ee05f729..a120ebb00 100644 --- a/web/src/views/events/DesktopRecordingView.tsx +++ b/web/src/views/events/RecordingView.tsx @@ -5,21 +5,21 @@ 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"; -type DesktopRecordingViewProps = { +type RecordingViewProps = { selectedReview: ReviewSegment; reviewItems: ReviewSegment[]; relevantPreviews?: Preview[]; }; -export default function DesktopRecordingView({ +export default function RecordingView({ selectedReview, reviewItems, relevantPreviews, -}: DesktopRecordingViewProps) { +}: RecordingViewProps) { const navigate = useNavigate(); const contentRef = useRef(null); @@ -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(