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; |             include proxy.conf; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         location ~* /api/.*\.(jpg|jpeg|png|webp)$ { |         location ~* /api/.*\.(jpg|jpeg|png|webp|gif)$ { | ||||||
|             rewrite ^/api/(.*)$ $1 break; |             rewrite ^/api/(.*)$ $1 break; | ||||||
|             proxy_pass http://frigate_api; |             proxy_pass http://frigate_api; | ||||||
|             include proxy.conf; |             include proxy.conf; | ||||||
|  | |||||||
| @ -1,18 +1,17 @@ | |||||||
| import { baseUrl } from "@/api/baseUrl"; | import { baseUrl } from "@/api/baseUrl"; | ||||||
| import TimeAgo from "../dynamic/TimeAgo"; | import TimeAgo from "../dynamic/TimeAgo"; | ||||||
| import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; | import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; | ||||||
| import { useCallback, useMemo } from "react"; | import { useCallback, useMemo, useState } from "react"; | ||||||
| import { useApiHost } from "@/api"; |  | ||||||
| import useSWR from "swr"; | import useSWR from "swr"; | ||||||
| import { FrigateConfig } from "@/types/frigateConfig"; | import { FrigateConfig } from "@/types/frigateConfig"; | ||||||
| import { ReviewSegment } from "@/types/review"; | import { ReviewSegment } from "@/types/review"; | ||||||
| import { useNavigate } from "react-router-dom"; | import { useNavigate } from "react-router-dom"; | ||||||
|  | import { Skeleton } from "../ui/skeleton"; | ||||||
| 
 | 
 | ||||||
| type AnimatedEventThumbnailProps = { | type AnimatedEventThumbnailProps = { | ||||||
|   event: ReviewSegment; |   event: ReviewSegment; | ||||||
| }; | }; | ||||||
| export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) { | export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) { | ||||||
|   const apiHost = useApiHost(); |  | ||||||
|   const { data: config } = useSWR<FrigateConfig>("config"); |   const { data: config } = useSWR<FrigateConfig>("config"); | ||||||
| 
 | 
 | ||||||
|   // interaction
 |   // interaction
 | ||||||
| @ -24,13 +23,15 @@ export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) { | |||||||
| 
 | 
 | ||||||
|   // image behavior
 |   // image behavior
 | ||||||
| 
 | 
 | ||||||
|  |   const [loaded, setLoaded] = useState(false); | ||||||
|  |   const [error, setError] = useState(0); | ||||||
|   const imageUrl = useMemo(() => { |   const imageUrl = useMemo(() => { | ||||||
|     if (Date.now() / 1000 < event.start_time + 20) { |     if (error > 0) { | ||||||
|       return `${apiHost}api/preview/${event.camera}/${event.start_time}/thumbnail.jpg`; |       return `${baseUrl}api/review/${event.id}/preview.gif?key=${error}`; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return `${baseUrl}api/review/${event.id}/preview.gif`; |     return `${baseUrl}api/review/${event.id}/preview.gif`; | ||||||
|   }, [apiHost, event]); |   }, [error, event]); | ||||||
| 
 | 
 | ||||||
|   const aspectRatio = useMemo(() => { |   const aspectRatio = useMemo(() => { | ||||||
|     if (!config) { |     if (!config) { | ||||||
| @ -44,14 +45,22 @@ export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) { | |||||||
|   return ( |   return ( | ||||||
|     <Tooltip> |     <Tooltip> | ||||||
|       <TooltipTrigger asChild> |       <TooltipTrigger asChild> | ||||||
|         <div |         <div className="h-24 relative"> | ||||||
|           className="h-24 relative rounded bg-cover bg-no-repeat bg-center mr-4 cursor-pointer" |           <img | ||||||
|           style={{ |             className="size-full rounded object-cover object-center cursor-pointer" | ||||||
|             backgroundImage: `url(${imageUrl})`, |             src={imageUrl} | ||||||
|             aspectRatio: aspectRatio, |             style={{ | ||||||
|           }} |               aspectRatio: aspectRatio, | ||||||
|           onClick={onOpenReview} |             }} | ||||||
|         > |             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="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"> |             <div className="w-full absolute left-1 bottom-0 text-xs text-white"> | ||||||
|               <TimeAgo time={event.start_time * 1000} dense /> |               <TimeAgo time={event.start_time * 1000} dense /> | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ import { Recording } from "@/types/record"; | |||||||
| import { Preview } from "@/types/preview"; | import { Preview } from "@/types/preview"; | ||||||
| import { DynamicPlayback } from "@/types/playback"; | import { DynamicPlayback } from "@/types/playback"; | ||||||
| import PreviewPlayer, { PreviewController } from "./PreviewPlayer"; | 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 { LuPause, LuPlay } from "react-icons/lu"; | ||||||
| import { | import { | ||||||
|   DropdownMenu, |   DropdownMenu, | ||||||
| @ -152,6 +152,19 @@ export default function DynamicVideoPlayer({ | |||||||
|     onKeyboardShortcut, |     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
 |   // initial state
 | ||||||
| 
 | 
 | ||||||
|   const initialPlaybackSource = useMemo(() => { |   const initialPlaybackSource = useMemo(() => { | ||||||
| @ -238,14 +251,6 @@ export default function DynamicVideoPlayer({ | |||||||
|             } |             } | ||||||
|           : undefined |           : undefined | ||||||
|       } |       } | ||||||
|       onClick={ |  | ||||||
|         isMobile |  | ||||||
|           ? (e) => { |  | ||||||
|               e.stopPropagation(); |  | ||||||
|               setControls(!controls); |  | ||||||
|             } |  | ||||||
|           : undefined |  | ||||||
|       } |  | ||||||
|     > |     > | ||||||
|       <div className={`w-full relative ${isScrubbing ? "hidden" : "visible"}`}> |       <div className={`w-full relative ${isScrubbing ? "hidden" : "visible"}`}> | ||||||
|         <VideoPlayer |         <VideoPlayer | ||||||
| @ -255,9 +260,8 @@ export default function DynamicVideoPlayer({ | |||||||
|             sources: [initialPlaybackSource], |             sources: [initialPlaybackSource], | ||||||
|             aspectRatio: wideVideo ? undefined : "16:9", |             aspectRatio: wideVideo ? undefined : "16:9", | ||||||
|             controls: false, |             controls: false, | ||||||
|             nativeControlsForTouch: true, |             nativeControlsForTouch: false, | ||||||
|           }} |           }} | ||||||
|           seekOptions={{ forward: 10, backward: 5 }} |  | ||||||
|           onReady={(player) => { |           onReady={(player) => { | ||||||
|             setPlayerRef(player); |             setPlayerRef(player); | ||||||
|           }} |           }} | ||||||
|  | |||||||
| @ -13,6 +13,7 @@ import { PreviewPlayback } from "@/types/playback"; | |||||||
| import { isCurrentHour } from "@/utils/dateUtil"; | import { isCurrentHour } from "@/utils/dateUtil"; | ||||||
| import { baseUrl } from "@/api/baseUrl"; | import { baseUrl } from "@/api/baseUrl"; | ||||||
| import { isAndroid } from "react-device-detect"; | import { isAndroid } from "react-device-detect"; | ||||||
|  | import { Skeleton } from "../ui/skeleton"; | ||||||
| 
 | 
 | ||||||
| type PreviewPlayerProps = { | type PreviewPlayerProps = { | ||||||
|   className?: string; |   className?: string; | ||||||
| @ -119,6 +120,7 @@ function PreviewVideoPlayer({ | |||||||
| 
 | 
 | ||||||
|   // initial state
 |   // initial state
 | ||||||
| 
 | 
 | ||||||
|  |   const [loaded, setLoaded] = useState(false); | ||||||
|   const initialPreview = useMemo(() => { |   const initialPreview = useMemo(() => { | ||||||
|     return cameraPreviews.find( |     return cameraPreviews.find( | ||||||
|       (preview) => |       (preview) => | ||||||
| @ -152,6 +154,7 @@ function PreviewVideoPlayer({ | |||||||
|         Math.round(preview.start) >= timeRange.start && |         Math.round(preview.start) >= timeRange.start && | ||||||
|         Math.floor(preview.end) <= timeRange.end, |         Math.floor(preview.end) <= timeRange.end, | ||||||
|     ); |     ); | ||||||
|  |     setLoaded(false); | ||||||
|     setCurrentPreview(preview); |     setCurrentPreview(preview); | ||||||
| 
 | 
 | ||||||
|     controller.newPlayback({ |     controller.newPlayback({ | ||||||
| @ -186,6 +189,8 @@ function PreviewVideoPlayer({ | |||||||
|         disableRemotePlayback |         disableRemotePlayback | ||||||
|         onSeeked={onPreviewSeeked} |         onSeeked={onPreviewSeeked} | ||||||
|         onLoadedData={() => { |         onLoadedData={() => { | ||||||
|  |           setLoaded(true); | ||||||
|  | 
 | ||||||
|           if (controller) { |           if (controller) { | ||||||
|             controller.previewReady(); |             controller.previewReady(); | ||||||
|           } else { |           } else { | ||||||
| @ -201,6 +206,7 @@ function PreviewVideoPlayer({ | |||||||
|           <source src={currentPreview.src} type={currentPreview.type} /> |           <source src={currentPreview.src} type={currentPreview.type} /> | ||||||
|         )} |         )} | ||||||
|       </video> |       </video> | ||||||
|  |       {!loaded && <Skeleton className="absolute inset-0" />} | ||||||
|       {cameraPreviews && !currentPreview && ( |       {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"> |         <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 |           No Preview Found | ||||||
| @ -270,9 +276,15 @@ class PreviewVideoController extends PreviewController { | |||||||
|         if (isAndroid) { |         if (isAndroid) { | ||||||
|           const currentTs = |           const currentTs = | ||||||
|             this.previewRef.current.currentTime + this.preview.start; |             this.previewRef.current.currentTime + this.preview.start; | ||||||
|           this.previewRef.current.currentTime = |           const diff = this.timeToSeek - currentTs; | ||||||
|             this.previewRef.current.currentTime + | 
 | ||||||
|             (this.timeToSeek - currentTs) / 2; |           if (diff < 30) { | ||||||
|  |             this.previewRef.current.currentTime = | ||||||
|  |               this.previewRef.current.currentTime + diff / 2; | ||||||
|  |           } else { | ||||||
|  |             this.previewRef.current.currentTime = | ||||||
|  |               this.timeToSeek - this.preview.start; | ||||||
|  |           } | ||||||
|         } else { |         } else { | ||||||
|           this.previewRef.current.currentTime = |           this.previewRef.current.currentTime = | ||||||
|             this.timeToSeek - this.preview.start; |             this.timeToSeek - this.preview.start; | ||||||
|  | |||||||
| @ -117,7 +117,7 @@ export default function LiveDashboardView({ | |||||||
|       {events && events.length > 0 && ( |       {events && events.length > 0 && ( | ||||||
|         <ScrollArea> |         <ScrollArea> | ||||||
|           <TooltipProvider> |           <TooltipProvider> | ||||||
|             <div className="flex"> |             <div className="flex gap-4 items-center"> | ||||||
|               {events.map((event) => { |               {events.map((event) => { | ||||||
|                 return <AnimatedEventThumbnail key={event.id} event={event} />; |                 return <AnimatedEventThumbnail key={event.id} event={event} />; | ||||||
|               })} |               })} | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user