mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	Fix motion review (#10329)
* Break preview only video player out * Simplify * Load after current preview changes * Clear out waiting for seek state * Start at correct time of hour * Fix layout for tall video
This commit is contained in:
		
							parent
							
								
									ea5cb4fd8b
								
							
						
					
					
						commit
						3d539c93eb
					
				
							
								
								
									
										217
									
								
								web/src/components/player/PreviewVideoPlayer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										217
									
								
								web/src/components/player/PreviewVideoPlayer.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -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<FrigateConfig>("config"); | ||||||
|  | 
 | ||||||
|  |   // controlling playback
 | ||||||
|  | 
 | ||||||
|  |   const previewRef = useRef<HTMLVideoElement | null>(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 ( | ||||||
|  |     <div | ||||||
|  |       className={`relative w-full ${className ?? ""} ${onClick ? "cursor-pointer" : ""}`} | ||||||
|  |       onClick={onClick} | ||||||
|  |     > | ||||||
|  |       <video | ||||||
|  |         ref={previewRef} | ||||||
|  |         className={`size-full rounded-2xl bg-black`} | ||||||
|  |         preload="auto" | ||||||
|  |         autoPlay | ||||||
|  |         playsInline | ||||||
|  |         muted | ||||||
|  |         disableRemotePlayback | ||||||
|  |         onSeeked={onPreviewSeeked} | ||||||
|  |         onLoadedData={() => { | ||||||
|  |           if (controller) { | ||||||
|  |             controller.previewReady(); | ||||||
|  |           } else { | ||||||
|  |             previewRef.current?.pause(); | ||||||
|  |           } | ||||||
|  |         }} | ||||||
|  |       > | ||||||
|  |         {currentPreview != undefined && ( | ||||||
|  |           <source src={currentPreview.src} type={currentPreview.type} /> | ||||||
|  |         )} | ||||||
|  |       </video> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export class PreviewVideoController { | ||||||
|  |   // main state
 | ||||||
|  |   public camera = ""; | ||||||
|  |   private previewRef: MutableRefObject<HTMLVideoElement | null>; | ||||||
|  |   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<HTMLVideoElement | null>, | ||||||
|  |   ) { | ||||||
|  |     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(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -7,3 +7,8 @@ export type DynamicPlayback = { | |||||||
|   preview: Preview | undefined; |   preview: Preview | undefined; | ||||||
|   timeRange: { end: number; start: number }; |   timeRange: { end: number; start: number }; | ||||||
| }; | }; | ||||||
|  | 
 | ||||||
|  | export type PreviewPlayback = { | ||||||
|  |   preview: Preview | undefined; | ||||||
|  |   timeRange: { end: number; start: number }; | ||||||
|  | }; | ||||||
|  | |||||||
| @ -2,9 +2,6 @@ import Logo from "@/components/Logo"; | |||||||
| import NewReviewData from "@/components/dynamic/NewReviewData"; | import NewReviewData from "@/components/dynamic/NewReviewData"; | ||||||
| import ReviewActionGroup from "@/components/filter/ReviewActionGroup"; | import ReviewActionGroup from "@/components/filter/ReviewActionGroup"; | ||||||
| import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup"; | import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup"; | ||||||
| import DynamicVideoPlayer, { |  | ||||||
|   DynamicVideoController, |  | ||||||
| } from "@/components/player/DynamicVideoPlayer"; |  | ||||||
| import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer"; | import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer"; | ||||||
| import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; | import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; | ||||||
| import ActivityIndicator from "@/components/indicators/activity-indicator"; | import ActivityIndicator from "@/components/indicators/activity-indicator"; | ||||||
| @ -36,6 +33,9 @@ import { MdCircle } from "react-icons/md"; | |||||||
| import useSWR from "swr"; | import useSWR from "swr"; | ||||||
| import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline"; | import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline"; | ||||||
| import { Button } from "@/components/ui/button"; | import { Button } from "@/components/ui/button"; | ||||||
|  | import PreviewVideoPlayer, { | ||||||
|  |   PreviewVideoController, | ||||||
|  | } from "@/components/player/PreviewVideoPlayer"; | ||||||
| 
 | 
 | ||||||
| type EventViewProps = { | type EventViewProps = { | ||||||
|   reviews?: ReviewSegment[]; |   reviews?: ReviewSegment[]; | ||||||
| @ -531,7 +531,6 @@ function MotionReview({ | |||||||
| }: MotionReviewProps) { | }: MotionReviewProps) { | ||||||
|   const segmentDuration = 30; |   const segmentDuration = 30; | ||||||
|   const { data: config } = useSWR<FrigateConfig>("config"); |   const { data: config } = useSWR<FrigateConfig>("config"); | ||||||
|   const [playerReady, setPlayerReady] = useState(false); |  | ||||||
| 
 | 
 | ||||||
|   const reviewCameras = useMemo(() => { |   const reviewCameras = useMemo(() => { | ||||||
|     if (!config) { |     if (!config) { | ||||||
| @ -552,7 +551,7 @@ function MotionReview({ | |||||||
|     return cameras.sort((a, b) => a.ui.order - b.ui.order); |     return cameras.sort((a, b) => a.ui.order - b.ui.order); | ||||||
|   }, [config, filter]); |   }, [config, filter]); | ||||||
| 
 | 
 | ||||||
|   const videoPlayersRef = useRef<{ [camera: string]: DynamicVideoController }>( |   const videoPlayersRef = useRef<{ [camera: string]: PreviewVideoController }>( | ||||||
|     {}, |     {}, | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
| @ -593,27 +592,6 @@ function MotionReview({ | |||||||
| 
 | 
 | ||||||
|   // move to next clip
 |   // 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(() => { |   useEffect(() => { | ||||||
|     if ( |     if ( | ||||||
|       currentTime > currentTimeRange.end + 60 || |       currentTime > currentTimeRange.end + 60 || | ||||||
| @ -624,6 +602,9 @@ function MotionReview({ | |||||||
|       ); |       ); | ||||||
| 
 | 
 | ||||||
|       if (index != -1) { |       if (index != -1) { | ||||||
|  |         Object.values(videoPlayersRef.current).forEach((controller) => { | ||||||
|  |           controller.setNewPreviewStartTime(currentTime); | ||||||
|  |         }); | ||||||
|         setSelectedRangeIdx(index); |         setSelectedRangeIdx(index); | ||||||
|       } |       } | ||||||
|       return; |       return; | ||||||
| @ -656,17 +637,14 @@ function MotionReview({ | |||||||
|               grow = "aspect-video"; |               grow = "aspect-video"; | ||||||
|             } |             } | ||||||
|             return ( |             return ( | ||||||
|               <DynamicVideoPlayer |               <PreviewVideoPlayer | ||||||
|                 key={camera.name} |                 key={camera.name} | ||||||
|                 className={`${grow}`} |                 className={`${grow}`} | ||||||
|                 camera={camera.name} |                 camera={camera.name} | ||||||
|                 timeRange={currentTimeRange} |                 timeRange={currentTimeRange} | ||||||
|                 cameraPreviews={relevantPreviews || []} |                 cameraPreviews={relevantPreviews || []} | ||||||
|                 previewOnly |  | ||||||
|                 preloadRecordings={false} |  | ||||||
|                 onControllerReady={(controller) => { |                 onControllerReady={(controller) => { | ||||||
|                   videoPlayersRef.current[camera.name] = controller; |                   videoPlayersRef.current[camera.name] = controller; | ||||||
|                   setPlayerReady(true); |  | ||||||
|                 }} |                 }} | ||||||
|                 onClick={() => |                 onClick={() => | ||||||
|                   onSelectReview(`motion,${camera.name},${currentTime}`, false) |                   onSelectReview(`motion,${camera.name},${currentTime}`, false) | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user