mirror of
				https://github.com/scottlamb/moonfire-nvr.git
				synced 2025-10-29 15:55:01 -04:00 
			
		
		
		
	fix Multiview to work in Safari and Firefox
LiveCamera itself still doesn't work in Safari, but small steps.
This commit is contained in:
		
							parent
							
								
									64cfd6ed44
								
							
						
					
					
						commit
						f92a23fd74
					
				| @ -11,7 +11,8 @@ import CircularProgress from "@material-ui/core/CircularProgress"; | |||||||
| import Alert from "@material-ui/core/Alert"; | import Alert from "@material-ui/core/Alert"; | ||||||
| 
 | 
 | ||||||
| interface LiveCameraProps { | interface LiveCameraProps { | ||||||
|   camera: Camera; |   camera: Camera | null; | ||||||
|  |   chooser: JSX.Element; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| interface BufferStateClosed { | interface BufferStateClosed { | ||||||
| @ -274,11 +275,15 @@ class LiveCameraDriver { | |||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * A live view of a camera. |  * A live view of a camera. | ||||||
|  |  * | ||||||
|  |  * The caller is currently expected to put this into a 16x9 block. | ||||||
|  |  * | ||||||
|  * Note there's a significant setup cost to creating a LiveCamera, so the parent |  * Note there's a significant setup cost to creating a LiveCamera, so the parent | ||||||
|  * should use React's <tt>key</tt> attribute to avoid unnecessarily mounting |  * should use React's <tt>key</tt> attribute to avoid unnecessarily mounting | ||||||
|  * and unmounting a camera. |  * and unmounting a camera. | ||||||
|  |  * | ||||||
|  */ |  */ | ||||||
| const LiveCamera = ({ camera }: LiveCameraProps) => { | const LiveCamera = ({ camera, chooser }: LiveCameraProps) => { | ||||||
|   const videoRef = React.useRef<HTMLVideoElement>(null); |   const videoRef = React.useRef<HTMLVideoElement>(null); | ||||||
|   const [playbackState, setPlaybackState] = React.useState<PlaybackState>({ |   const [playbackState, setPlaybackState] = React.useState<PlaybackState>({ | ||||||
|     state: "normal", |     state: "normal", | ||||||
| @ -287,6 +292,10 @@ const LiveCamera = ({ camera }: LiveCameraProps) => { | |||||||
|   // Load the camera driver.
 |   // Load the camera driver.
 | ||||||
|   const [driver, setDriver] = React.useState<LiveCameraDriver | null>(null); |   const [driver, setDriver] = React.useState<LiveCameraDriver | null>(null); | ||||||
|   React.useEffect(() => { |   React.useEffect(() => { | ||||||
|  |     if (camera === null) { | ||||||
|  |       setDriver(null); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|     const d = new LiveCameraDriver(camera, setPlaybackState, videoRef); |     const d = new LiveCameraDriver(camera, setPlaybackState, videoRef); | ||||||
|     setDriver(d); |     setDriver(d); | ||||||
|     return () => { |     return () => { | ||||||
| @ -308,43 +317,10 @@ const LiveCamera = ({ camera }: LiveCameraProps) => { | |||||||
|     return () => clearTimeout(timerId); |     return () => clearTimeout(timerId); | ||||||
|   }, [playbackState]); |   }, [playbackState]); | ||||||
| 
 | 
 | ||||||
|   if (driver === null) { |   const videoElement = | ||||||
|     return <Box />; |     driver === null ? ( | ||||||
|   } |       <video /> | ||||||
|   return ( |     ) : ( | ||||||
|     <Box |  | ||||||
|       sx={{ |  | ||||||
|         "& video": { width: "100%", height: "100%", objectFit: "contain" }, |  | ||||||
|         "& .progress-overlay": { |  | ||||||
|           position: "absolute", |  | ||||||
|           display: "flex", |  | ||||||
|           alignItems: "center", |  | ||||||
|           justifyContent: "center", |  | ||||||
|           height: "100%", |  | ||||||
|           width: "100%", |  | ||||||
|           zIndex: 1, |  | ||||||
|         }, |  | ||||||
|         "& .alert-overlay": { |  | ||||||
|           position: "absolute", |  | ||||||
|           display: "flex", |  | ||||||
|           height: "100%", |  | ||||||
|           width: "100%", |  | ||||||
|           alignItems: "flex-end", |  | ||||||
|           zIndex: 1, |  | ||||||
|           p: 1, |  | ||||||
|         }, |  | ||||||
|       }} |  | ||||||
|     > |  | ||||||
|       {showProgress && ( |  | ||||||
|         <div className="progress-overlay"> |  | ||||||
|           <CircularProgress /> |  | ||||||
|         </div> |  | ||||||
|       )} |  | ||||||
|       {playbackState.state === "error" && ( |  | ||||||
|         <div className="alert-overlay"> |  | ||||||
|           <Alert severity="error">{playbackState.message}</Alert> |  | ||||||
|         </div> |  | ||||||
|       )} |  | ||||||
|       <video |       <video | ||||||
|         ref={videoRef} |         ref={videoRef} | ||||||
|         muted |         muted | ||||||
| @ -356,6 +332,62 @@ const LiveCamera = ({ camera }: LiveCameraProps) => { | |||||||
|         onTimeUpdate={driver.tryTrimBuffer} |         onTimeUpdate={driver.tryTrimBuffer} | ||||||
|         onWaiting={driver.videoWaiting} |         onWaiting={driver.videoWaiting} | ||||||
|       /> |       /> | ||||||
|  |     ); | ||||||
|  |   return ( | ||||||
|  |     <Box | ||||||
|  |       sx={{ | ||||||
|  |         width: "100%", | ||||||
|  |         height: "100%", | ||||||
|  |         position: "relative", | ||||||
|  |         "& video": { | ||||||
|  |           width: "100%", | ||||||
|  |           height: "100%", | ||||||
|  | 
 | ||||||
|  |           // It'd be nice to use "contain" here so non-16x9 videos display
 | ||||||
|  |           // with letterboxing rather than by being stretched. Unfortunately
 | ||||||
|  |           // Firefox 87.0 doesn't honor the PixelAspectRatioBox of anamorphic
 | ||||||
|  |           // sub streams. For now, make anamorphic 16x9 sub streams display
 | ||||||
|  |           // correctly (at the expense of non-16x9 streams).
 | ||||||
|  |           // TODO: adjust width/height dynamically to handle the letterboxing
 | ||||||
|  |           // on non-16x9 streams.
 | ||||||
|  |           objectFit: "fill", | ||||||
|  |         }, | ||||||
|  |         "& .controls": { | ||||||
|  |           position: "absolute", | ||||||
|  |           width: "100%", | ||||||
|  |           height: "100%", | ||||||
|  |           zIndex: 1, | ||||||
|  |         }, | ||||||
|  |         "& .progress-overlay": { | ||||||
|  |           position: "absolute", | ||||||
|  |           display: "flex", | ||||||
|  |           alignItems: "center", | ||||||
|  |           justifyContent: "center", | ||||||
|  |           width: "100%", | ||||||
|  |           height: "100%", | ||||||
|  |         }, | ||||||
|  |         "& .alert-overlay": { | ||||||
|  |           position: "absolute", | ||||||
|  |           display: "flex", | ||||||
|  |           width: "100%", | ||||||
|  |           height: "100%", | ||||||
|  |           alignItems: "flex-end", | ||||||
|  |           p: 1, | ||||||
|  |         }, | ||||||
|  |       }} | ||||||
|  |     > | ||||||
|  |       <div className="controls">{chooser}</div> | ||||||
|  |       {showProgress && ( | ||||||
|  |         <div className="progress-overlay"> | ||||||
|  |           <CircularProgress /> | ||||||
|  |         </div> | ||||||
|  |       )} | ||||||
|  |       {playbackState.state === "error" && ( | ||||||
|  |         <div className="alert-overlay"> | ||||||
|  |           <Alert severity="error">{playbackState.message}</Alert> | ||||||
|  |         </div> | ||||||
|  |       )} | ||||||
|  |       {videoElement} | ||||||
|     </Box> |     </Box> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -8,7 +8,6 @@ import React, { useReducer, useState } from "react"; | |||||||
| import { Camera } from "../types"; | import { Camera } from "../types"; | ||||||
| import { makeStyles } from "@material-ui/core/styles"; | import { makeStyles } from "@material-ui/core/styles"; | ||||||
| import useResizeObserver from "@react-hook/resize-observer"; | import useResizeObserver from "@react-hook/resize-observer"; | ||||||
| import Box from "@material-ui/core/Box"; |  | ||||||
| 
 | 
 | ||||||
| export interface Layout { | export interface Layout { | ||||||
|   className: string; |   className: string; | ||||||
| @ -27,36 +26,34 @@ const MAX_CAMERAS = 9; | |||||||
| const useStyles = makeStyles((theme) => ({ | const useStyles = makeStyles((theme) => ({ | ||||||
|   root: { |   root: { | ||||||
|     flex: "1 0 0", |     flex: "1 0 0", | ||||||
|     overflow: "hidden", |  | ||||||
|     color: "white", |     color: "white", | ||||||
|     marginTop: theme.spacing(2), |     marginTop: theme.spacing(2), | ||||||
|   }, |     overflow: "hidden", | ||||||
|   mid: { | 
 | ||||||
|     display: "none", |     "& .mid": { | ||||||
|     position: "relative", |       position: "relative", | ||||||
|     padding: 0, |       aspectRatio: "16 / 9", | ||||||
|     margin: 0, |       display: "inline-block", | ||||||
|     "&.wider, &.wider img": { |     }, | ||||||
|  | 
 | ||||||
|  |     // Set the width based on the height.
 | ||||||
|  |     "& .mid.wider": { | ||||||
|       height: "100%", |       height: "100%", | ||||||
|       display: "inline-block", |  | ||||||
|     }, |     }, | ||||||
|     "&.taller, &.taller img": { | 
 | ||||||
|  |     // Set the height based on the width.
 | ||||||
|  |     "& .mid.taller": { | ||||||
|       width: "100%", |       width: "100%", | ||||||
|       display: "inline-block", |  | ||||||
|     }, |  | ||||||
|     "& img": { |  | ||||||
|       objectFit: "contain", |  | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
|   inner: { |   inner: { | ||||||
|     // match parent's size without influencing it.
 |     // match parent's size without influencing it.
 | ||||||
|     overflow: "hidden", |  | ||||||
|     position: "absolute", |     position: "absolute", | ||||||
|     top: 0, |     width: "100%", | ||||||
|     bottom: 0, |     height: "100%", | ||||||
|     left: 0, |  | ||||||
|     right: 0, |  | ||||||
| 
 | 
 | ||||||
|  |     backgroundColor: "#000", | ||||||
|  |     overflow: "hidden", | ||||||
|     display: "grid", |     display: "grid", | ||||||
|     gridGap: "0px", |     gridGap: "0px", | ||||||
| 
 | 
 | ||||||
| @ -83,7 +80,7 @@ const useStyles = makeStyles((theme) => ({ | |||||||
| export interface MultiviewProps { | export interface MultiviewProps { | ||||||
|   cameras: Camera[]; |   cameras: Camera[]; | ||||||
|   layoutIndex: number; |   layoutIndex: number; | ||||||
|   renderCamera: (camera: Camera) => JSX.Element; |   renderCamera: (camera: Camera | null, chooser: JSX.Element) => JSX.Element; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export interface MultiviewChooserProps { | export interface MultiviewChooserProps { | ||||||
| @ -155,8 +152,7 @@ function selectedReducer(old: SelectedCameras, op: SelectOp): SelectedCameras { | |||||||
|  * as possible. Internally, multiview uses the largest possible aspect |  * as possible. Internally, multiview uses the largest possible aspect | ||||||
|  * ratio-constrained section of it. It uses a ResizeObserver to determine if |  * ratio-constrained section of it. It uses a ResizeObserver to determine if | ||||||
|  * the outer div is wider or taller than 16x9, and then sets an appropriate CSS |  * the outer div is wider or taller than 16x9, and then sets an appropriate CSS | ||||||
|  * class to constrain the width or height respectively using a technique like |  * class to constrain the width or height respectively. The goal is to have the | ||||||
|  * <https://stackoverflow.com/a/14911949/23584>. The goal is to have the
 |  | ||||||
|  * smoothest resizing by changing the DOM/CSS as little as possible. |  * smoothest resizing by changing the DOM/CSS as little as possible. | ||||||
|  */ |  */ | ||||||
| const Multiview = (props: MultiviewProps) => { | const Multiview = (props: MultiviewProps) => { | ||||||
| @ -164,12 +160,41 @@ const Multiview = (props: MultiviewProps) => { | |||||||
|     selectedReducer, |     selectedReducer, | ||||||
|     Array(MAX_CAMERAS).fill(null) |     Array(MAX_CAMERAS).fill(null) | ||||||
|   ); |   ); | ||||||
|   const [widerOrTaller, setWiderOrTaller] = useState("wider"); |   const [widerOrTaller, setWiderOrTaller] = useState(""); | ||||||
|   const outerRef = React.useRef<HTMLDivElement>(null); |   const outerRef = React.useRef<HTMLDivElement>(null); | ||||||
|  |   const midRef = React.useRef<HTMLDivElement>(null); | ||||||
|  | 
 | ||||||
|  |   // Keep a constant 16x9 aspect ratio. Chrome 89.0.4389.90 supports the
 | ||||||
|  |   // "aspect-ratio" CSS property and seems to behave in a predictable way.
 | ||||||
|  |   // Intuition suggests using that is more performant than extra DOM
 | ||||||
|  |   // manipulations. Firefox 87.0 doesn't support aspect-ratio. Emulating it
 | ||||||
|  |   // with an <img> child doesn't work well either for using a (flex item)
 | ||||||
|  |   // ancestor's (calculated) height to compute
 | ||||||
|  |   // the <img>'s width and then the parent's width. There are some open bugs
 | ||||||
|  |   // that look related, eg:
 | ||||||
|  |   // https://bugzilla.mozilla.org/show_bug.cgi?id=1349738
 | ||||||
|  |   // https://bugzilla.mozilla.org/show_bug.cgi?id=1690423
 | ||||||
|  |   // so when there's no "aspect-ratio", just calculate everything here.
 | ||||||
|  |   const aspectRatioSupported = CSS.supports("aspect-ratio: 16 / 9"); | ||||||
|   useResizeObserver(outerRef, (entry: ResizeObserverEntry) => { |   useResizeObserver(outerRef, (entry: ResizeObserverEntry) => { | ||||||
|     const w = entry.contentRect.width; |     const w = entry.contentRect.width; | ||||||
|     const h = entry.contentRect.height; |     const h = entry.contentRect.height; | ||||||
|     setWiderOrTaller((w * 9) / 16 > h ? "wider" : "taller"); |     const hFromW = (w * 9) / 16; | ||||||
|  |     if (aspectRatioSupported) { | ||||||
|  |       setWiderOrTaller(hFromW > h ? "wider" : "taller"); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     const mid = midRef.current; | ||||||
|  |     if (mid === null) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     if (hFromW > h) { | ||||||
|  |       mid.style.width = `${(h * 16) / 9}px`; | ||||||
|  |       mid.style.height = `${h}px`; | ||||||
|  |     } else { | ||||||
|  |       mid.style.width = `${w}px`; | ||||||
|  |       mid.style.height = `${hFromW}px`; | ||||||
|  |     } | ||||||
|   }); |   }); | ||||||
|   const classes = useStyles(); |   const classes = useStyles(); | ||||||
|   const layout = LAYOUTS[props.layoutIndex]; |   const layout = LAYOUTS[props.layoutIndex]; | ||||||
| @ -195,12 +220,7 @@ const Multiview = (props: MultiviewProps) => { | |||||||
|   }); |   }); | ||||||
|   return ( |   return ( | ||||||
|     <div className={classes.root} ref={outerRef}> |     <div className={classes.root} ref={outerRef}> | ||||||
|       <div className={`${classes.mid} ${widerOrTaller}`}> |       <div className={`mid ${widerOrTaller}`} ref={midRef}> | ||||||
|         {/* a 16x9 black png from png-pixel.com */} |  | ||||||
|         <img |  | ||||||
|           src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAJCAQAAACRI2S5AAAAEklEQVR42mNk+M+AFzCOKgADALyGCQGyq8YeAAAAAElFTkSuQmCC" |  | ||||||
|           alt="" |  | ||||||
|         /> |  | ||||||
|         <div className={`${classes.inner} ${layout.className}`}> |         <div className={`${classes.inner} ${layout.className}`}> | ||||||
|           {monoviews} |           {monoviews} | ||||||
|         </div> |         </div> | ||||||
| @ -213,45 +233,36 @@ interface MonoviewProps { | |||||||
|   cameras: Camera[]; |   cameras: Camera[]; | ||||||
|   cameraIndex: number | null; |   cameraIndex: number | null; | ||||||
|   onSelect: (cameraIndex: number | null) => void; |   onSelect: (cameraIndex: number | null) => void; | ||||||
|   renderCamera: (camera: Camera) => JSX.Element; |   renderCamera: (camera: Camera | null, chooser: JSX.Element) => JSX.Element; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /** A single pane of a Multiview, including its camera chooser. */ | /** A single pane of a Multiview, including its camera chooser. */ | ||||||
| const Monoview = (props: MonoviewProps) => { | const Monoview = (props: MonoviewProps) => { | ||||||
|   return ( |   const chooser = ( | ||||||
|     <Box> |     <Select | ||||||
|       <Box |       value={props.cameraIndex == null ? undefined : props.cameraIndex} | ||||||
|         sx={{ |       onChange={(e) => props.onSelect(e.target.value ?? null)} | ||||||
|           zIndex: 1, |       displayEmpty | ||||||
|           position: "absolute", |       size="small" | ||||||
|           height: "100%", |       sx={{ | ||||||
|           width: "100%", |         // Restyle to fit over the video (or black).
 | ||||||
|         }} |         backgroundColor: "rgba(255, 255, 255, 0.5)", | ||||||
|       > |         "& svg": { | ||||||
|         <Select |           color: "inherit", | ||||||
|           value={props.cameraIndex == null ? undefined : props.cameraIndex} |         }, | ||||||
|           onChange={(e) => props.onSelect(e.target.value ?? null)} |       }} | ||||||
|           displayEmpty |     > | ||||||
|           size="small" |       <MenuItem value={undefined}>(none)</MenuItem> | ||||||
|           sx={{ |       {props.cameras.map((e, i) => ( | ||||||
|             // Restyle to fit over the video (or black).
 |         <MenuItem key={i} value={i}> | ||||||
|             backgroundColor: "rgba(255, 255, 255, 0.5)", |           {e.shortName} | ||||||
|             "& svg": { |         </MenuItem> | ||||||
|               color: "inherit", |       ))} | ||||||
|             }, |     </Select> | ||||||
|           }} |   ); | ||||||
|         > |   return props.renderCamera( | ||||||
|           <MenuItem value={undefined}>(none)</MenuItem> |     props.cameraIndex === null ? null : props.cameras[props.cameraIndex], | ||||||
|           {props.cameras.map((e, i) => ( |     chooser | ||||||
|             <MenuItem key={i} value={i}> |  | ||||||
|               {e.shortName} |  | ||||||
|             </MenuItem> |  | ||||||
|           ))} |  | ||||||
|         </Select> |  | ||||||
|       </Box> |  | ||||||
|       {props.cameraIndex !== null && |  | ||||||
|         props.renderCamera(props.cameras[props.cameraIndex])} |  | ||||||
|     </Box> |  | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -16,7 +16,9 @@ const Live = ({ cameras, layoutIndex }: LiveProps) => { | |||||||
|     <Multiview |     <Multiview | ||||||
|       layoutIndex={layoutIndex} |       layoutIndex={layoutIndex} | ||||||
|       cameras={cameras} |       cameras={cameras} | ||||||
|       renderCamera={(camera: Camera) => <LiveCamera camera={camera} />} |       renderCamera={(camera: Camera | null, chooser: JSX.Element) => ( | ||||||
|  |         <LiveCamera camera={camera} chooser={chooser} /> | ||||||
|  |       )} | ||||||
|     /> |     /> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user