mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-02-03 18:06:02 -05: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,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
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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