mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	refactor(web): responsive images on content size, throttle AutoUpdatingCameraImage
This commit is contained in:
		
							parent
							
								
									75a01f657e
								
							
						
					
					
						commit
						2ec921593e
					
				| @ -26,7 +26,7 @@ export default function App() { | ||||
|     <Config.Provider value={config}> | ||||
|       <div className="md:flex flex-col md:flex-row md:min-h-screen w-full bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white"> | ||||
|         <Sidebar /> | ||||
|         <div className="p-4 min-w-0"> | ||||
|         <div className="flex-auto p-4 lg:pl-8 lg:pr-8 min-w-0"> | ||||
|           <Router> | ||||
|             <CameraMap path="/cameras/:camera/editor" /> | ||||
|             <Camera path="/cameras/:camera" /> | ||||
| @ -39,5 +39,4 @@ export default function App() { | ||||
|       </div> | ||||
|     </Config.Provider> | ||||
|   ); | ||||
|   return; | ||||
| } | ||||
|  | ||||
| @ -1,7 +1,6 @@ | ||||
| import { h } from 'preact'; | ||||
| import Box from './components/Box'; | ||||
| import Button from './components/Button'; | ||||
| import CameraImage from './components/CameraImage'; | ||||
| import Heading from './components/Heading'; | ||||
| import Switch from './components/Switch'; | ||||
| import { route } from 'preact-router'; | ||||
| @ -253,7 +252,7 @@ ${Object.keys(objectMaskPoints) | ||||
| 
 | ||||
|       <Box className="space-y-4"> | ||||
|         <div className="relative"> | ||||
|           <CameraImage imageRef={imageRef} camera={camera} /> | ||||
|           <img ref={imageRef} src={`${apiHost}/api/${camera}/latest.jpg`} /> | ||||
|           <EditableMask | ||||
|             onChange={handleUpdateEditable} | ||||
|             points={'subkey' in editing ? editing.set[editing.key][editing.subkey] : editing.set[editing.key]} | ||||
|  | ||||
| @ -1,4 +1,6 @@ | ||||
| import { h } from 'preact'; | ||||
| import Box from './components/Box'; | ||||
| import Button from './components/Button'; | ||||
| import Heading from './components/Heading'; | ||||
| import Link from './components/Link'; | ||||
| import { ApiHost, Config } from './context'; | ||||
| @ -39,59 +41,73 @@ export default function Debug() { | ||||
|   const cameraNames = Object.keys(cameras); | ||||
|   const cameraDataKeys = Object.keys(cameras[cameraNames[0]]); | ||||
| 
 | ||||
|   const handleCopyConfig = useCallback(async () => { | ||||
|     await window.navigator.clipboard.writeText(JSON.stringify(config, null, 2)); | ||||
|   }, [config]); | ||||
| 
 | ||||
|   return ( | ||||
|     <div> | ||||
|     <div class="space-y-4"> | ||||
|       <Heading> | ||||
|         Debug <span className="text-sm">{service.version}</span> | ||||
|       </Heading> | ||||
|       <Table className="w-full"> | ||||
|         <Thead> | ||||
|           <Tr> | ||||
|             <Th>detector</Th> | ||||
|             {detectorDataKeys.map((name) => ( | ||||
|               <Th>{name.replace('_', ' ')}</Th> | ||||
|             ))} | ||||
|           </Tr> | ||||
|         </Thead> | ||||
|         <Tbody> | ||||
|           {detectorNames.map((detector, i) => ( | ||||
|             <Tr index={i}> | ||||
|               <Td>{detector}</Td> | ||||
| 
 | ||||
|       <Box> | ||||
|         <Table className="w-full"> | ||||
|           <Thead> | ||||
|             <Tr> | ||||
|               <Th>detector</Th> | ||||
|               {detectorDataKeys.map((name) => ( | ||||
|                 <Td key={`${name}-${detector}`}>{detectors[detector][name]}</Td> | ||||
|                 <Th>{name.replace('_', ' ')}</Th> | ||||
|               ))} | ||||
|             </Tr> | ||||
|           ))} | ||||
|         </Tbody> | ||||
|       </Table> | ||||
| 
 | ||||
|       <Table className="w-full"> | ||||
|         <Thead> | ||||
|           <Tr> | ||||
|             <Th>camera</Th> | ||||
|             {cameraDataKeys.map((name) => ( | ||||
|               <Th>{name.replace('_', ' ')}</Th> | ||||
|           </Thead> | ||||
|           <Tbody> | ||||
|             {detectorNames.map((detector, i) => ( | ||||
|               <Tr index={i}> | ||||
|                 <Td>{detector}</Td> | ||||
|                 {detectorDataKeys.map((name) => ( | ||||
|                   <Td key={`${name}-${detector}`}>{detectors[detector][name]}</Td> | ||||
|                 ))} | ||||
|               </Tr> | ||||
|             ))} | ||||
|           </Tr> | ||||
|         </Thead> | ||||
|         <Tbody> | ||||
|           {cameraNames.map((camera, i) => ( | ||||
|             <Tr index={i}> | ||||
|               <Td> | ||||
|                 <Link href={`/cameras/${camera}`}>{camera}</Link> | ||||
|               </Td> | ||||
|           </Tbody> | ||||
|         </Table> | ||||
|       </Box> | ||||
| 
 | ||||
|       <Box> | ||||
|         <Table className="w-full"> | ||||
|           <Thead> | ||||
|             <Tr> | ||||
|               <Th>camera</Th> | ||||
|               {cameraDataKeys.map((name) => ( | ||||
|                 <Td key={`${name}-${camera}`}>{cameras[camera][name]}</Td> | ||||
|                 <Th>{name.replace('_', ' ')}</Th> | ||||
|               ))} | ||||
|             </Tr> | ||||
|           ))} | ||||
|         </Tbody> | ||||
|       </Table> | ||||
|           </Thead> | ||||
|           <Tbody> | ||||
|             {cameraNames.map((camera, i) => ( | ||||
|               <Tr index={i}> | ||||
|                 <Td> | ||||
|                   <Link href={`/cameras/${camera}`}>{camera}</Link> | ||||
|                 </Td> | ||||
|                 {cameraDataKeys.map((name) => ( | ||||
|                   <Td key={`${name}-${camera}`}>{cameras[camera][name]}</Td> | ||||
|                 ))} | ||||
|               </Tr> | ||||
|             ))} | ||||
|           </Tbody> | ||||
|         </Table> | ||||
|       </Box> | ||||
| 
 | ||||
|       <Heading size="sm">Config</Heading> | ||||
|       <pre className="font-mono overflow-y-scroll overflow-x-scroll max-h-96 rounded bg-white dark:bg-gray-900"> | ||||
|         {JSON.stringify(config, null, 2)} | ||||
|       </pre> | ||||
|       <Box className="relative"> | ||||
|         <Heading size="sm">Config</Heading> | ||||
|         <Button className="absolute top-4 right-8" onClick={handleCopyConfig}> | ||||
|           Copy to Clipboard | ||||
|         </Button> | ||||
|         <pre className="overflow-auto font-mono text-gray-900 dark:text-gray-100 rounded bg-gray-100 dark:bg-gray-800 p-2 max-h-96"> | ||||
|           {JSON.stringify(config, null, 2)} | ||||
|         </pre> | ||||
|       </Box> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| @ -23,7 +23,7 @@ export default function Events({ url } = {}) { | ||||
|   const searchKeys = Array.from(searchParams.keys()); | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="space-y-4"> | ||||
|     <div className="space-y-4 w-full"> | ||||
|       <Heading>Events</Heading> | ||||
| 
 | ||||
|       {searchKeys.length ? ( | ||||
| @ -43,7 +43,7 @@ export default function Events({ url } = {}) { | ||||
|       ) : null} | ||||
| 
 | ||||
|       <Box className="min-w-0 overflow-auto"> | ||||
|         <Table> | ||||
|         <Table className="w-full"> | ||||
|           <Thead> | ||||
|             <Tr> | ||||
|               <Th></Th> | ||||
|  | ||||
| @ -1,20 +1,29 @@ | ||||
| import { h } from 'preact'; | ||||
| import CameraImage from './CameraImage'; | ||||
| import { ApiHost, Config } from '../context'; | ||||
| import { useCallback, useEffect, useContext, useState } from 'preact/hooks'; | ||||
| import { useCallback, useState } from 'preact/hooks'; | ||||
| 
 | ||||
| export default function AutoUpdatingCameraImage({ camera, searchParams }) { | ||||
|   const apiHost = useContext(ApiHost); | ||||
| const MIN_LOAD_TIMEOUT_MS = 200; | ||||
| 
 | ||||
| export default function AutoUpdatingCameraImage({ camera, searchParams, showFps = true }) { | ||||
|   const [key, setKey] = useState(Date.now()); | ||||
|   useEffect(() => { | ||||
|     const timeoutId = setTimeout(() => { | ||||
|       setKey(Date.now()); | ||||
|     }, 500); | ||||
|     return () => { | ||||
|       clearTimeout(timeoutId); | ||||
|     }; | ||||
|   }, [key, searchParams]); | ||||
|   const [fps, setFps] = useState(0); | ||||
| 
 | ||||
|   return <CameraImage camera={camera} searchParams={`cache=${key}&${searchParams}`} />; | ||||
|   const handleLoad = useCallback(() => { | ||||
|     const loadTime = Date.now() - key; | ||||
|     setFps((1000 / Math.max(loadTime, MIN_LOAD_TIMEOUT_MS)).toFixed(1)); | ||||
|     setTimeout( | ||||
|       () => { | ||||
|         setKey(Date.now()); | ||||
|       }, | ||||
|       loadTime > MIN_LOAD_TIMEOUT_MS ? 1 : MIN_LOAD_TIMEOUT_MS | ||||
|     ); | ||||
|   }, [key, searchParams, setFps]); | ||||
| 
 | ||||
|   return ( | ||||
|     <div> | ||||
|       <CameraImage camera={camera} onload={handleLoad} searchParams={`cache=${key}&${searchParams}`} /> | ||||
|       {showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| @ -1,38 +1,60 @@ | ||||
| import { h } from 'preact'; | ||||
| import { ApiHost, Config } from '../context'; | ||||
| import { useCallback, useEffect, useContext, useState } from 'preact/hooks'; | ||||
| import { useCallback, useEffect, useContext, useMemo, useRef, useState } from 'preact/hooks'; | ||||
| 
 | ||||
| export default function CameraImage({ camera, searchParams = '', imageRef }) { | ||||
| export default function CameraImage({ camera, onload, searchParams = '' }) { | ||||
|   const config = useContext(Config); | ||||
|   const apiHost = useContext(ApiHost); | ||||
|   const [availableWidth, setAvailableWidth] = useState(0); | ||||
|   const [loadedSrc, setLoadedSrc] = useState(null); | ||||
|   const containerRef = useRef(null); | ||||
| 
 | ||||
|   const { name, width, height } = config.cameras[camera]; | ||||
| 
 | ||||
|   const aspectRatio = width / height; | ||||
|   const innerWidth = parseInt(window.innerWidth, 10); | ||||
| 
 | ||||
|   const responsiveWidths = [640, 768, 1024, 1280]; | ||||
|   if (innerWidth > responsiveWidths[responsiveWidths.length - 1]) { | ||||
|     responsiveWidths.push(innerWidth); | ||||
|   } | ||||
|   const resizeObserver = useMemo(() => { | ||||
|     return new ResizeObserver((entries) => { | ||||
|       window.requestAnimationFrame(() => { | ||||
|         if (Array.isArray(entries) && entries.length) { | ||||
|           setAvailableWidth(entries[0].contentRect.width); | ||||
|         } | ||||
|       }); | ||||
|     }); | ||||
|   }, [setAvailableWidth, width]); | ||||
| 
 | ||||
|   const src = `${apiHost}/api/${camera}/latest.jpg`; | ||||
|   const { srcset, sizes } = responsiveWidths.reduce( | ||||
|     (memo, w, i) => { | ||||
|       memo.srcset.push(`${src}?h=${Math.ceil(w / aspectRatio)}&${searchParams} ${w}w`); | ||||
|       memo.sizes.push(`(max-width: ${w}) ${Math.ceil((w / innerWidth) * 100)}vw`); | ||||
|       return memo; | ||||
|   useEffect(() => { | ||||
|     if (!containerRef.current) { | ||||
|       return; | ||||
|     } | ||||
|     resizeObserver.observe(containerRef.current); | ||||
|   }, [resizeObserver, containerRef.current]); | ||||
| 
 | ||||
|   const scaledHeight = useMemo(() => Math.min(Math.ceil(availableWidth / aspectRatio), height), [ | ||||
|     availableWidth, | ||||
|     aspectRatio, | ||||
|     height, | ||||
|   ]); | ||||
| 
 | ||||
|   const img = useMemo(() => new Image(), [camera]); | ||||
|   img.onload = useCallback( | ||||
|     (event) => { | ||||
|       const src = event.path[0].currentSrc; | ||||
|       setLoadedSrc(src); | ||||
|       onload && onload(event); | ||||
|     }, | ||||
|     { srcset: [], sizes: [] } | ||||
|     [searchParams, onload] | ||||
|   ); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (!scaledHeight) { | ||||
|       return; | ||||
|     } | ||||
|     img.src = `${apiHost}/api/${name}/latest.jpg?h=${scaledHeight}${searchParams ? `&${searchParams}` : ''}`; | ||||
|   }, [apiHost, name, img, searchParams, scaledHeight]); | ||||
| 
 | ||||
|   return ( | ||||
|     <img | ||||
|       className="w-full" | ||||
|       srcset={srcset.join(', ')} | ||||
|       sizes={sizes.join(', ')} | ||||
|       src={`${srcset[srcset.length - 1]}`} | ||||
|       alt={name} | ||||
|       ref={imageRef} | ||||
|     /> | ||||
|     <div ref={containerRef}> | ||||
|       {loadedSrc ? <img width={scaledHeight * aspectRatio} height={scaledHeight} src={loadedSrc} alt={name} /> : null} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user