diff --git a/web/src/components/player/DynamicVideoPlayer.tsx b/web/src/components/player/DynamicVideoPlayer.tsx index e5a9e36c1..a4309c6dd 100644 --- a/web/src/components/player/DynamicVideoPlayer.tsx +++ b/web/src/components/player/DynamicVideoPlayer.tsx @@ -406,7 +406,7 @@ export class DynamicVideoController { } } - onPlayerTimeUpdate(listener: (timestamp: number) => void) { + onPlayerTimeUpdate(listener: ((timestamp: number) => void) | undefined) { this.onPlaybackTimestamp = listener; } diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 3d601e290..dff127439 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -6,9 +6,13 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review"; import EventView from "@/views/events/EventView"; -import RecordingView from "@/views/events/RecordingView"; +import { + DesktopRecordingView, + MobileRecordingView, +} from "@/views/events/RecordingView"; import axios from "axios"; import { useCallback, useMemo, useState } from "react"; +import { isMobile } from "react-device-detect"; import useSWR from "swr"; import useSWRInfinite from "swr/infinite"; @@ -183,6 +187,10 @@ export default function Events() { // selected items const selectedData = useMemo(() => { + if (!config) { + return undefined; + } + if (!selectedReviewId) { return undefined; } @@ -191,6 +199,8 @@ export default function Events() { return undefined; } + const allCameras = reviewFilter?.cameras ?? Object.keys(config.cameras); + const allReviews = reviewPages.flat(); const selectedReview = allReviews.find( (item) => item.id == selectedReviewId, @@ -202,11 +212,9 @@ export default function Events() { return { selected: selectedReview, - cameraSegments: allReviews.filter( - (seg) => seg.camera == selectedReview.camera, - ), - cameraPreviews: allPreviews?.filter( - (seg) => seg.camera == selectedReview.camera, + allCameras: allCameras, + cameraSegments: allReviews.filter((seg) => + allCameras.includes(seg.camera), ), }; @@ -219,11 +227,24 @@ export default function Events() { } if (selectedData) { + if (isMobile) { + return ( + + ); + } + return ( - ); } else { diff --git a/web/src/views/events/RecordingView.tsx b/web/src/views/events/RecordingView.tsx index 739f1cd28..a8176c788 100644 --- a/web/src/views/events/RecordingView.tsx +++ b/web/src/views/events/RecordingView.tsx @@ -4,22 +4,193 @@ import DynamicVideoPlayer, { import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; import { Button } from "@/components/ui/button"; import { Preview } from "@/types/preview"; -import { ReviewSegment } from "@/types/review"; +import { ReviewSegment, ReviewSeverity } from "@/types/review"; import { getChunkedTimeDay } from "@/utils/timelineUtil"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { IoMdArrowRoundBack } from "react-icons/io"; import { useNavigate } from "react-router-dom"; -type RecordingViewProps = { +type DesktopRecordingViewProps = { + startCamera: string; + startTime: number; + severity: ReviewSeverity; + reviewItems: ReviewSegment[]; + allCameras: string[]; + allPreviews?: Preview[]; +}; +export function DesktopRecordingView({ + startCamera, + startTime, + severity, + reviewItems, + allCameras, + allPreviews, +}: DesktopRecordingViewProps) { + const navigate = useNavigate(); + const contentRef = useRef(null); + + // controller state + + const [playerReady, setPlayerReady] = useState(false); + const [mainCamera, setMainCamera] = useState(startCamera); + const videoPlayersRef = useRef<{ [camera: string]: DynamicVideoController }>( + {}, + ); + + // timeline time + + const timeRange = useMemo(() => getChunkedTimeDay(startTime), [startTime]); + const [selectedRangeIdx, setSelectedRangeIdx] = useState( + timeRange.ranges.findIndex((chunk) => { + return chunk.start <= startTime && chunk.end >= startTime; + }), + ); + + // 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 < timeRange.ranges.length - 1 + ) { + setSelectedRangeIdx(selectedRangeIdx + 1); + } else if (selectedRangeIdx > 0) { + setSelectedRangeIdx(selectedRangeIdx - 1); + } + }); + } + }, [selectedRangeIdx, timeRange, videoPlayersRef, playerReady]); + + // scrubbing and timeline state + + const [scrubbing, setScrubbing] = useState(false); + const [currentTime, setCurrentTime] = useState(startTime); + + useEffect(() => { + if (scrubbing) { + Object.values(videoPlayersRef.current).forEach((controller) => { + controller.scrubToTimestamp(currentTime); + }); + } + }, [currentTime, scrubbing]); + + useEffect(() => { + if (!scrubbing) { + videoPlayersRef.current[mainCamera]?.seekToTimestamp(currentTime, true); + } + + // we only want to seek when user stops scrubbing + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scrubbing]); + + const onSelectCamera = useCallback( + (newCam: string) => { + videoPlayersRef.current[mainCamera].onPlayerTimeUpdate(undefined); + videoPlayersRef.current[mainCamera].scrubToTimestamp(currentTime); + videoPlayersRef.current[newCam].seekToTimestamp(currentTime, true); + setMainCamera(newCam); + }, + [currentTime, mainCamera], + ); + + return ( +
+ + +
+ {allCameras.map((cam) => { + if (cam == mainCamera) { + return ( +
+ { + videoPlayersRef.current[cam] = controller; + setPlayerReady(true); + controller.onPlayerTimeUpdate((timestamp: number) => { + setCurrentTime(timestamp); + }); + + controller.seekToTimestamp(startTime, true); + }} + /> +
+ ); + } + + return ( +
onSelectCamera(cam)} + > + { + videoPlayersRef.current[cam] = controller; + setPlayerReady(true); + controller.scrubToTimestamp(startTime); + }} + /> +
+ ); + })} +
+ +
+ setScrubbing(scrubbing)} + /> +
+
+ ); +} + +type MobileRecordingViewProps = { selectedReview: ReviewSegment; reviewItems: ReviewSegment[]; relevantPreviews?: Preview[]; }; -export default function RecordingView({ +export function MobileRecordingView({ selectedReview, reviewItems, relevantPreviews, -}: RecordingViewProps) { +}: MobileRecordingViewProps) { const navigate = useNavigate(); const contentRef = useRef(null); @@ -82,15 +253,12 @@ export default function RecordingView({ return (
- -
+
-
+