diff --git a/web/src/components/player/PreviewVideoPlayer.tsx b/web/src/components/player/PreviewVideoPlayer.tsx new file mode 100644 index 000000000..8f3f170d2 --- /dev/null +++ b/web/src/components/player/PreviewVideoPlayer.tsx @@ -0,0 +1,217 @@ +import { + MutableRefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { Preview } from "@/types/preview"; +import { PreviewPlayback } from "@/types/playback"; + +type PreviewVideoPlayerProps = { + className?: string; + camera: string; + timeRange: { start: number; end: number }; + cameraPreviews: Preview[]; + onControllerReady: (controller: PreviewVideoController) => void; + onClick?: () => void; +}; +export default function PreviewVideoPlayer({ + className, + camera, + timeRange, + cameraPreviews, + onControllerReady, + onClick, +}: PreviewVideoPlayerProps) { + const { data: config } = useSWR("config"); + + // controlling playback + + const previewRef = useRef(null); + const controller = useMemo(() => { + if (!config || !previewRef.current) { + return undefined; + } + + return new PreviewVideoController(camera, previewRef); + // we only care when preview is ready + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [camera, config, previewRef.current]); + + useEffect(() => { + if (!controller) { + return; + } + + if (controller) { + onControllerReady(controller); + } + // we only want to fire once when players are ready + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controller]); + + // initial state + + const initialPreview = useMemo(() => { + return cameraPreviews.find( + (preview) => + preview.camera == camera && + Math.round(preview.start) >= timeRange.start && + Math.floor(preview.end) <= timeRange.end, + ); + + // we only want to calculate this once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const [currentPreview, setCurrentPreview] = useState(initialPreview); + + const onPreviewSeeked = useCallback(() => { + if (!controller) { + return; + } + + controller.finishedSeeking(); + }, [controller]); + + useEffect(() => { + if (!controller) { + return; + } + + const preview = cameraPreviews.find( + (preview) => + preview.camera == camera && + Math.round(preview.start) >= timeRange.start && + Math.floor(preview.end) <= timeRange.end, + ); + setCurrentPreview(preview); + + controller.newPlayback({ + preview, + timeRange, + }); + + // we only want this to change when recordings update + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controller, timeRange]); + + useEffect(() => { + if (!currentPreview || !previewRef.current) { + return; + } + + previewRef.current.load(); + }, [currentPreview, previewRef]); + + return ( +
+ +
+ ); +} + +export class PreviewVideoController { + // main state + public camera = ""; + private previewRef: MutableRefObject; + private timeRange: { start: number; end: number } | undefined = undefined; + + // preview + private preview: Preview | undefined = undefined; + private timeToSeek: number | undefined = undefined; + private seeking = false; + + constructor( + camera: string, + previewRef: MutableRefObject, + ) { + this.camera = camera; + this.previewRef = previewRef; + } + + newPlayback(newPlayback: PreviewPlayback) { + this.preview = newPlayback.preview; + this.seeking = false; + + this.timeRange = newPlayback.timeRange; + } + + scrubToTimestamp(time: number) { + if (!this.preview || !this.timeRange) { + return; + } + + if (time < this.preview.start || time > this.preview.end) { + return; + } + + if (this.seeking) { + this.timeToSeek = time; + } else { + if (this.previewRef.current) { + this.previewRef.current.currentTime = Math.max( + 0, + time - this.preview.start, + ); + this.seeking = true; + } + } + } + + setNewPreviewStartTime(time: number) { + this.timeToSeek = time; + } + + finishedSeeking() { + if (!this.previewRef.current || !this.preview) { + return; + } + + if ( + this.timeToSeek && + this.timeToSeek != this.previewRef.current?.currentTime + ) { + this.previewRef.current.currentTime = + this.timeToSeek - this.preview.start; + } else { + this.seeking = false; + } + } + + previewReady() { + this.seeking = false; + this.previewRef.current?.pause(); + + if (this.timeToSeek) { + this.finishedSeeking(); + } + } +} diff --git a/web/src/types/playback.ts b/web/src/types/playback.ts index 29e3cd597..ea1d901b7 100644 --- a/web/src/types/playback.ts +++ b/web/src/types/playback.ts @@ -7,3 +7,8 @@ export type DynamicPlayback = { preview: Preview | undefined; timeRange: { end: number; start: number }; }; + +export type PreviewPlayback = { + preview: Preview | undefined; + timeRange: { end: number; start: number }; +}; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index b0820ba85..d801aa487 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -2,9 +2,6 @@ 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"; @@ -36,6 +33,9 @@ import { MdCircle } from "react-icons/md"; import useSWR from "swr"; import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline"; import { Button } from "@/components/ui/button"; +import PreviewVideoPlayer, { + PreviewVideoController, +} from "@/components/player/PreviewVideoPlayer"; type EventViewProps = { reviews?: ReviewSegment[]; @@ -531,7 +531,6 @@ function MotionReview({ }: MotionReviewProps) { const segmentDuration = 30; const { data: config } = useSWR("config"); - const [playerReady, setPlayerReady] = useState(false); const reviewCameras = useMemo(() => { if (!config) { @@ -552,7 +551,7 @@ function MotionReview({ return cameras.sort((a, b) => a.ui.order - b.ui.order); }, [config, filter]); - const videoPlayersRef = useRef<{ [camera: string]: DynamicVideoController }>( + const videoPlayersRef = useRef<{ [camera: string]: PreviewVideoController }>( {}, ); @@ -593,27 +592,6 @@ function MotionReview({ // 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") { - if (selectedRangeIdx < timeRangeSegments.ranges.length - 1) { - setSelectedRangeIdx(selectedRangeIdx + 1); - } - } - }); - } - }, [selectedRangeIdx, timeRangeSegments, videoPlayersRef, playerReady]); - useEffect(() => { if ( currentTime > currentTimeRange.end + 60 || @@ -624,6 +602,9 @@ function MotionReview({ ); if (index != -1) { + Object.values(videoPlayersRef.current).forEach((controller) => { + controller.setNewPreviewStartTime(currentTime); + }); setSelectedRangeIdx(index); } return; @@ -656,17 +637,14 @@ function MotionReview({ grow = "aspect-video"; } return ( - { videoPlayersRef.current[camera.name] = controller; - setPlayerReady(true); }} onClick={() => onSelectReview(`motion,${camera.name},${currentTime}`, false)