mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	Improve preview loading (#10406)
* Use skeleton for gif loading * cache gifs as well * Show skeleton when switching previews * Fix touch controls for mobile * Fix android mobile scrub logic * Cleanup
This commit is contained in:
		
							parent
							
								
									92255f771b
								
							
						
					
					
						commit
						09cf54c731
					
				| @ -210,7 +210,7 @@ http { | ||||
|             include proxy.conf; | ||||
|         } | ||||
| 
 | ||||
|         location ~* /api/.*\.(jpg|jpeg|png|webp)$ { | ||||
|         location ~* /api/.*\.(jpg|jpeg|png|webp|gif)$ { | ||||
|             rewrite ^/api/(.*)$ $1 break; | ||||
|             proxy_pass http://frigate_api; | ||||
|             include proxy.conf; | ||||
|  | ||||
| @ -1,18 +1,17 @@ | ||||
| import { baseUrl } from "@/api/baseUrl"; | ||||
| import TimeAgo from "../dynamic/TimeAgo"; | ||||
| import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; | ||||
| import { useCallback, useMemo } from "react"; | ||||
| import { useApiHost } from "@/api"; | ||||
| import { useCallback, useMemo, useState } from "react"; | ||||
| import useSWR from "swr"; | ||||
| import { FrigateConfig } from "@/types/frigateConfig"; | ||||
| import { ReviewSegment } from "@/types/review"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { Skeleton } from "../ui/skeleton"; | ||||
| 
 | ||||
| type AnimatedEventThumbnailProps = { | ||||
|   event: ReviewSegment; | ||||
| }; | ||||
| export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) { | ||||
|   const apiHost = useApiHost(); | ||||
|   const { data: config } = useSWR<FrigateConfig>("config"); | ||||
| 
 | ||||
|   // interaction
 | ||||
| @ -24,13 +23,15 @@ export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) { | ||||
| 
 | ||||
|   // image behavior
 | ||||
| 
 | ||||
|   const [loaded, setLoaded] = useState(false); | ||||
|   const [error, setError] = useState(0); | ||||
|   const imageUrl = useMemo(() => { | ||||
|     if (Date.now() / 1000 < event.start_time + 20) { | ||||
|       return `${apiHost}api/preview/${event.camera}/${event.start_time}/thumbnail.jpg`; | ||||
|     if (error > 0) { | ||||
|       return `${baseUrl}api/review/${event.id}/preview.gif?key=${error}`; | ||||
|     } | ||||
| 
 | ||||
|     return `${baseUrl}api/review/${event.id}/preview.gif`; | ||||
|   }, [apiHost, event]); | ||||
|   }, [error, event]); | ||||
| 
 | ||||
|   const aspectRatio = useMemo(() => { | ||||
|     if (!config) { | ||||
| @ -44,14 +45,22 @@ export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) { | ||||
|   return ( | ||||
|     <Tooltip> | ||||
|       <TooltipTrigger asChild> | ||||
|         <div | ||||
|           className="h-24 relative rounded bg-cover bg-no-repeat bg-center mr-4 cursor-pointer" | ||||
|           style={{ | ||||
|             backgroundImage: `url(${imageUrl})`, | ||||
|             aspectRatio: aspectRatio, | ||||
|           }} | ||||
|           onClick={onOpenReview} | ||||
|         > | ||||
|         <div className="h-24 relative"> | ||||
|           <img | ||||
|             className="size-full rounded object-cover object-center cursor-pointer" | ||||
|             src={imageUrl} | ||||
|             style={{ | ||||
|               aspectRatio: aspectRatio, | ||||
|             }} | ||||
|             onClick={onOpenReview} | ||||
|             onLoad={() => setLoaded(true)} | ||||
|             onError={() => { | ||||
|               if (error < 2) { | ||||
|                 setError(error + 1); | ||||
|               } | ||||
|             }} | ||||
|           /> | ||||
|           {!loaded && <Skeleton className="absolute inset-0" />} | ||||
|           <div className="absolute bottom-0 inset-x-0 h-6 bg-gradient-to-t from-slate-900/50 to-transparent rounded"> | ||||
|             <div className="w-full absolute left-1 bottom-0 text-xs text-white"> | ||||
|               <TimeAgo time={event.start_time * 1000} dense /> | ||||
|  | ||||
| @ -10,7 +10,7 @@ import { Recording } from "@/types/record"; | ||||
| import { Preview } from "@/types/preview"; | ||||
| import { DynamicPlayback } from "@/types/playback"; | ||||
| import PreviewPlayer, { PreviewController } from "./PreviewPlayer"; | ||||
| import { isDesktop, isMobile } from "react-device-detect"; | ||||
| import { isDesktop } from "react-device-detect"; | ||||
| import { LuPause, LuPlay } from "react-icons/lu"; | ||||
| import { | ||||
|   DropdownMenu, | ||||
| @ -152,6 +152,19 @@ export default function DynamicVideoPlayer({ | ||||
|     onKeyboardShortcut, | ||||
|   ); | ||||
| 
 | ||||
|   // mobile tap controls
 | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (isDesktop || !playerRef) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const callback = () => setControls(!controls); | ||||
|     playerRef.on("touchstart", callback); | ||||
| 
 | ||||
|     return () => playerRef.off("touchstart", callback); | ||||
|   }, [controls, playerRef]); | ||||
| 
 | ||||
|   // initial state
 | ||||
| 
 | ||||
|   const initialPlaybackSource = useMemo(() => { | ||||
| @ -238,14 +251,6 @@ export default function DynamicVideoPlayer({ | ||||
|             } | ||||
|           : undefined | ||||
|       } | ||||
|       onClick={ | ||||
|         isMobile | ||||
|           ? (e) => { | ||||
|               e.stopPropagation(); | ||||
|               setControls(!controls); | ||||
|             } | ||||
|           : undefined | ||||
|       } | ||||
|     > | ||||
|       <div className={`w-full relative ${isScrubbing ? "hidden" : "visible"}`}> | ||||
|         <VideoPlayer | ||||
| @ -255,9 +260,8 @@ export default function DynamicVideoPlayer({ | ||||
|             sources: [initialPlaybackSource], | ||||
|             aspectRatio: wideVideo ? undefined : "16:9", | ||||
|             controls: false, | ||||
|             nativeControlsForTouch: true, | ||||
|             nativeControlsForTouch: false, | ||||
|           }} | ||||
|           seekOptions={{ forward: 10, backward: 5 }} | ||||
|           onReady={(player) => { | ||||
|             setPlayerRef(player); | ||||
|           }} | ||||
|  | ||||
| @ -13,6 +13,7 @@ import { PreviewPlayback } from "@/types/playback"; | ||||
| import { isCurrentHour } from "@/utils/dateUtil"; | ||||
| import { baseUrl } from "@/api/baseUrl"; | ||||
| import { isAndroid } from "react-device-detect"; | ||||
| import { Skeleton } from "../ui/skeleton"; | ||||
| 
 | ||||
| type PreviewPlayerProps = { | ||||
|   className?: string; | ||||
| @ -119,6 +120,7 @@ function PreviewVideoPlayer({ | ||||
| 
 | ||||
|   // initial state
 | ||||
| 
 | ||||
|   const [loaded, setLoaded] = useState(false); | ||||
|   const initialPreview = useMemo(() => { | ||||
|     return cameraPreviews.find( | ||||
|       (preview) => | ||||
| @ -152,6 +154,7 @@ function PreviewVideoPlayer({ | ||||
|         Math.round(preview.start) >= timeRange.start && | ||||
|         Math.floor(preview.end) <= timeRange.end, | ||||
|     ); | ||||
|     setLoaded(false); | ||||
|     setCurrentPreview(preview); | ||||
| 
 | ||||
|     controller.newPlayback({ | ||||
| @ -186,6 +189,8 @@ function PreviewVideoPlayer({ | ||||
|         disableRemotePlayback | ||||
|         onSeeked={onPreviewSeeked} | ||||
|         onLoadedData={() => { | ||||
|           setLoaded(true); | ||||
| 
 | ||||
|           if (controller) { | ||||
|             controller.previewReady(); | ||||
|           } else { | ||||
| @ -201,6 +206,7 @@ function PreviewVideoPlayer({ | ||||
|           <source src={currentPreview.src} type={currentPreview.type} /> | ||||
|         )} | ||||
|       </video> | ||||
|       {!loaded && <Skeleton className="absolute inset-0" />} | ||||
|       {cameraPreviews && !currentPreview && ( | ||||
|         <div className="absolute inset-x-0 top-1/2 -y-translate-1/2 bg-black text-white rounded-2xl align-center text-center"> | ||||
|           No Preview Found | ||||
| @ -270,9 +276,15 @@ class PreviewVideoController extends PreviewController { | ||||
|         if (isAndroid) { | ||||
|           const currentTs = | ||||
|             this.previewRef.current.currentTime + this.preview.start; | ||||
|           this.previewRef.current.currentTime = | ||||
|             this.previewRef.current.currentTime + | ||||
|             (this.timeToSeek - currentTs) / 2; | ||||
|           const diff = this.timeToSeek - currentTs; | ||||
| 
 | ||||
|           if (diff < 30) { | ||||
|             this.previewRef.current.currentTime = | ||||
|               this.previewRef.current.currentTime + diff / 2; | ||||
|           } else { | ||||
|             this.previewRef.current.currentTime = | ||||
|               this.timeToSeek - this.preview.start; | ||||
|           } | ||||
|         } else { | ||||
|           this.previewRef.current.currentTime = | ||||
|             this.timeToSeek - this.preview.start; | ||||
|  | ||||
| @ -117,7 +117,7 @@ export default function LiveDashboardView({ | ||||
|       {events && events.length > 0 && ( | ||||
|         <ScrollArea> | ||||
|           <TooltipProvider> | ||||
|             <div className="flex"> | ||||
|             <div className="flex gap-4 items-center"> | ||||
|               {events.map((event) => { | ||||
|                 return <AnimatedEventThumbnail key={event.id} event={event} />; | ||||
|               })} | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user