fix Multiview to work in Safari and Firefox

LiveCamera itself still doesn't work in Safari, but small steps.
This commit is contained in:
Scott Lamb 2021-03-29 11:49:33 -07:00
parent 64cfd6ed44
commit f92a23fd74
3 changed files with 151 additions and 106 deletions

View File

@ -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>
); );
}; };

View File

@ -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,21 +233,12 @@ 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>
<Box
sx={{
zIndex: 1,
position: "absolute",
height: "100%",
width: "100%",
}}
>
<Select <Select
value={props.cameraIndex == null ? undefined : props.cameraIndex} value={props.cameraIndex == null ? undefined : props.cameraIndex}
onChange={(e) => props.onSelect(e.target.value ?? null)} onChange={(e) => props.onSelect(e.target.value ?? null)}
@ -248,10 +259,10 @@ const Monoview = (props: MonoviewProps) => {
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
</Box> );
{props.cameraIndex !== null && return props.renderCamera(
props.renderCamera(props.cameras[props.cameraIndex])} props.cameraIndex === null ? null : props.cameras[props.cameraIndex],
</Box> chooser
); );
}; };

View File

@ -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} />
)}
/> />
); );
}; };