add live stream viewing to React prototype

It's a start. It can display several streams at once, which is nice.
There are lots of opportunities for improvement:

*   it doesn't keep the videos approximately in sync.
*   it accumulates extra buffering, drifting behind live. This is
    particularly noticeable when it's paused and played again; it can
    be several seconds before it jumps to after the break.
*   it always uses the sub stream rather main. I'd prefer it support
    "auto" (use main if the viewport is larger than the sub stream and
    there's sufficient bandwidth), "main", or "sub".
*   it has a kludgy heuristic where it throws away everything buffered 5
    seconds before the current timestamp. It should throw away
    everything before the current GOP instead, but I need to alter the
    API so it can easily know when that is.
*   it can't tell you when a camera connection is down. This needs an
    API change also.
*   it'd be nice to quickly double-click on a stream to view only it,
    then double-click again to go back to the multi-pane view.
*   it doesn't allow you to zoom in on part of the video. This would be
    nice particularly when viewing 4k video streams on small screens.
*   it has only four preconfigured layouts that subdivide a 16x9
    viewport. You have to choose every camera every time. It'd be nice
    to both allow more flexibility and have more memory.

React prototype: #111
live stream: #59
This commit is contained in:
Scott Lamb 2021-03-26 13:43:04 -07:00
parent 2215032a78
commit 0236ab8d64
13 changed files with 888 additions and 23 deletions

37
ui/package-lock.json generated
View File

@ -2123,6 +2123,28 @@
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.0.tgz",
"integrity": "sha512-wjtKehFAIARq2OxK8j3JrggNlEslJfNuSm2ArteIbKyRMts2g0a7KzTxfRVNUM+O0gnBJ2hNV8nWPOYBgI1sew=="
},
"@react-hook/latest": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@react-hook/latest/-/latest-1.0.3.tgz",
"integrity": "sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg=="
},
"@react-hook/passive-layout-effect": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@react-hook/passive-layout-effect/-/passive-layout-effect-1.2.1.tgz",
"integrity": "sha512-IwEphTD75liO8g+6taS+4oqz+nnroocNfWVHWz7j+N+ZO2vYrc6PV1q7GQhuahL0IOR7JccFTsFKQ/mb6iZWAg=="
},
"@react-hook/resize-observer": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@react-hook/resize-observer/-/resize-observer-1.2.0.tgz",
"integrity": "sha512-7Cpy0aaZ3xXlSabQ43aZcfgzwYSidrshEKGDqpfvdx7pHnGDGskr8m1Ajb6yamJUSdTRgqCemYQcwouCqMmZoQ==",
"requires": {
"@react-hook/latest": "^1.0.2",
"@react-hook/passive-layout-effect": "^1.2.0",
"@types/raf-schd": "^4.0.0",
"raf-schd": "^4.0.2",
"resize-observer-polyfill": "^1.5.1"
}
},
"@rollup/plugin-node-resolve": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-7.1.3.tgz",
@ -2683,6 +2705,11 @@
"resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.4.tgz",
"integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug=="
},
"@types/raf-schd": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/raf-schd/-/raf-schd-4.0.0.tgz",
"integrity": "sha512-EsXE+pu4MjOhU+H2Ut/8zOGUtictm87anwxOcNY1HjxkH8ipLR+SzYnCzT4lQvyKJdcMwIGxhb8w/KZhOy6vaQ=="
},
"@types/react": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.2.tgz",
@ -12978,6 +13005,11 @@
"performance-now": "^2.1.0"
}
},
"raf-schd": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.2.tgz",
"integrity": "sha512-VhlMZmGy6A6hrkJWHLNTGl5gtgMUm+xfGza6wbwnE914yeQ5Ybm18vgM734RZhMgfw4tacUrWseGZlpUrrakEQ=="
},
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -13600,6 +13632,11 @@
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8="
},
"resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
},
"resolve": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",

View File

@ -9,6 +9,7 @@
"@material-ui/core": "^5.0.0-alpha.26",
"@material-ui/icons": "^5.0.0-alpha.26",
"@material-ui/lab": "^5.0.0-alpha.26",
"@react-hook/resize-observer": "^1.2.0",
"@types/jest": "^26.0.20",
"@types/node": "^14.14.22",
"@types/react": "^17.0.0",

View File

@ -41,6 +41,6 @@
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<div id="root" style="display: flex; flex-direction: column"></div>
</body>
</html>

View File

@ -9,26 +9,50 @@ import MoonfireMenu from "./AppMenu";
import Login from "./Login";
import { useSnackbars } from "./snackbars";
import { Camera, Session } from "./types";
import List from "./List";
import ListActivity from "./List";
import AppBar from "@material-ui/core/AppBar";
import LiveActivity, { MultiviewChooser } from "./Live";
import Drawer from "@material-ui/core/Drawer";
import List from "@material-ui/core/List";
import ListItem from "@material-ui/core/ListItem";
import ListItemText from "@material-ui/core/ListItemText";
import ListIcon from "@material-ui/icons/List";
import Videocam from "@material-ui/icons/Videocam";
import ListItemIcon from "@material-ui/core/ListItemIcon";
import FilterList from "@material-ui/icons/FilterList";
import IconButton from "@material-ui/core/IconButton";
type LoginState =
export type LoginState =
| "unknown"
| "logged-in"
| "not-logged-in"
| "server-requires-login"
| "user-requested-login";
type Activity = "list" | "live";
function App() {
const [showMenu, toggleShowMenu] = useReducer((m: boolean) => !m, true);
const [showMenu, toggleShowMenu] = useReducer((m: boolean) => !m, false);
const [showListSelectors, toggleShowListSelectors] = useReducer(
(m: boolean) => !m,
true
);
const [activity, setActivity] = useState<Activity>("list");
const [multiviewLayoutIndex, setMultiviewLayoutIndex] = useState(0);
const [session, setSession] = useState<Session | null>(null);
const [cameras, setCameras] = useState<Camera[] | null>(null);
const [timeZoneName, setTimeZoneName] = useState<string | null>(null);
const [fetchSeq, setFetchSeq] = useState(0);
const [loginState, setLoginState] = useState<LoginState>("not-logged-in");
const [loginState, setLoginState] = useState<LoginState>("unknown");
const [error, setError] = useState<api.FetchError | null>(null);
const needNewFetch = () => setFetchSeq((seq) => seq + 1);
const snackbars = useSnackbars();
const clickActivity = (activity: Activity) => {
toggleShowMenu();
setActivity(activity);
};
const onLoginSuccess = () => {
setLoginState("logged-in");
needNewFetch();
@ -85,19 +109,79 @@ function App() {
abort.abort();
};
}, [fetchSeq]);
let activityMenu = null;
let activityMain = null;
if (error === null && cameras !== null && cameras.length > 0) {
switch (activity) {
case "list":
activityMenu = (
<IconButton
aria-label="selectors"
onClick={toggleShowListSelectors}
color="inherit"
size="small"
>
<FilterList />
</IconButton>
);
activityMain = (
<ListActivity
cameras={cameras}
showSelectors={showListSelectors}
timeZoneName={timeZoneName!}
/>
);
break;
case "live":
activityMenu = (
<MultiviewChooser
layoutIndex={multiviewLayoutIndex}
onChoice={setMultiviewLayoutIndex}
/>
);
activityMain = (
<LiveActivity cameras={cameras} layoutIndex={multiviewLayoutIndex} />
);
break;
}
}
return (
<>
<AppBar position="static">
<MoonfireMenu
session={session}
loginState={loginState}
setSession={setSession}
requestLogin={() => {
setLoginState("user-requested-login");
}}
logout={logout}
menuClick={toggleShowMenu}
activityMenuPart={activityMenu}
/>
</AppBar>
<Drawer
variant="temporary"
open={showMenu}
onClose={toggleShowMenu}
ModalProps={{
keepMounted: true,
}}
>
<List>
<ListItem button key="list" onClick={() => clickActivity("list")}>
<ListItemIcon>
<ListIcon />
</ListItemIcon>
<ListItemText primary="List view" />
</ListItem>
<ListItem button key="live" onClick={() => clickActivity("live")}>
<ListItemIcon>
<Videocam />
</ListItemIcon>
<ListItemText primary="Live view (experimental)" />
</ListItem>
</List>
</Drawer>
<Login
onSuccess={onLoginSuccess}
open={
@ -110,7 +194,7 @@ function App() {
);
}}
/>
{error != null && (
{error !== null && (
<Container>
<h2>Error querying server</h2>
<pre>{error.message}</pre>
@ -120,13 +204,7 @@ function App() {
</p>
</Container>
)}
{cameras != null && cameras.length > 0 && (
<List
cameras={cameras}
showMenu={showMenu}
timeZoneName={timeZoneName!}
/>
)}
{activityMain}
</>
);
}

View File

@ -12,6 +12,7 @@ import Typography from "@material-ui/core/Typography";
import AccountCircle from "@material-ui/icons/AccountCircle";
import MenuIcon from "@material-ui/icons/Menu";
import React from "react";
import { LoginState } from "./App";
import { Session } from "./types";
const useStyles = makeStyles((theme: Theme) =>
@ -19,21 +20,24 @@ const useStyles = makeStyles((theme: Theme) =>
title: {
flexGrow: 1,
},
activity: {
marginRight: theme.spacing(2),
},
})
);
interface Props {
session: Session | null;
loginState: LoginState;
setSession: (session: Session | null) => void;
requestLogin: () => void;
logout: () => void;
menuClick?: () => void;
activityMenuPart: JSX.Element | null;
}
// https://material-ui.com/components/app-bar/
function MoonfireMenu(props: Props) {
const classes = useStyles();
const auth = props.session !== null;
const [
accountMenuAnchor,
setAccountMenuAnchor,
@ -68,12 +72,15 @@ function MoonfireMenu(props: Props) {
<Typography variant="h6" className={classes.title}>
Moonfire NVR
</Typography>
{auth || (
{props.activityMenuPart !== null && (
<div className={classes.activity}>{props.activityMenuPart}</div>
)}
{props.loginState !== "unknown" && props.loginState !== "logged-in" && (
<Button color="inherit" onClick={props.requestLogin}>
Log in
</Button>
)}
{auth && (
{props.loginState === "logged-in" && (
<div>
<IconButton
aria-label="account of current user"

View File

@ -73,10 +73,10 @@ const useStyles = makeStyles((theme: Theme) => ({
interface Props {
timeZoneName: string;
cameras: Camera[];
showMenu: boolean;
showSelectors: boolean;
}
const Main = ({ cameras, timeZoneName, showMenu }: Props) => {
const Main = ({ cameras, timeZoneName, showSelectors }: Props) => {
const classes = useStyles();
/**
@ -134,7 +134,7 @@ const Main = ({ cameras, timeZoneName, showMenu }: Props) => {
<div className={classes.root}>
<Box
className={classes.selectors}
sx={{ display: showMenu ? "block" : "none" }}
sx={{ display: showSelectors ? "block" : "none" }}
>
<StreamMultiSelector
cameras={cameras}

363
ui/src/Live/LiveCamera.tsx Normal file
View File

@ -0,0 +1,363 @@
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
import React, { SyntheticEvent } from "react";
import { Camera } from "../types";
import { Part, parsePart } from "./parser";
import * as api from "../api";
import Box from "@material-ui/core/Box";
import CircularProgress from "@material-ui/core/CircularProgress";
import Alert from "@material-ui/core/Alert";
interface LiveCameraProps {
camera: Camera;
}
interface BufferStateClosed {
state: "closed";
}
interface BufferStateOpen {
state: "open";
srcBuf: SourceBuffer;
busy: boolean;
mimeType: string;
videoSampleEntryId: number;
}
interface BufferStateError {
state: "error";
}
type BufferState = BufferStateClosed | BufferStateOpen | BufferStateError;
interface PlaybackStateNormal {
state: "normal";
}
interface PlaybackStateWaiting {
state: "waiting";
}
interface PlaybackStateError {
state: "error";
message: string;
}
type PlaybackState =
| PlaybackStateNormal
| PlaybackStateWaiting
| PlaybackStateError;
/**
* Drives a live camera.
* Implementation detail of LiveCamera which listens to various DOM events and
* drives the WebSocket feed and the MediaSource and SourceBuffers.
*/
class LiveCameraDriver {
constructor(
camera: Camera,
setPlaybackState: (state: PlaybackState) => void,
videoRef: React.RefObject<HTMLVideoElement>
) {
this.camera = camera;
this.setPlaybackState = setPlaybackState;
this.videoRef = videoRef;
this.src.addEventListener("sourceopen", this.onMediaSourceOpen);
}
onMediaSourceOpen = () => {
this.startStream("sourceopen");
};
startStream = (reason: string) => {
if (this.ws !== undefined) {
return;
}
console.log(`${this.camera.shortName}: starting stream: ${reason}`);
const loc = window.location;
const proto = loc.protocol === "https:" ? "wss" : "ws";
// TODO: switch between sub and main based on window size/bandwidth.
const url = `${proto}://${loc.host}/api/cameras/${this.camera.uuid}/sub/live.m4s`;
this.ws = new WebSocket(url);
this.ws.addEventListener("close", this.onWsClose);
this.ws.addEventListener("error", this.onWsError);
this.ws.addEventListener("message", this.onWsMessage);
};
error = (reason: string) => {
console.error(`${this.camera.shortName}: aborting due to ${reason}`);
this.stopStream(reason);
this.buf = { state: "error" };
this.src.endOfStream("network");
this.setPlaybackState({ state: "error", message: reason });
};
onWsClose = (e: CloseEvent) => {
this.error(`ws close: ${e.code} ${e.reason}`);
};
onWsError = (_e: Event) => {
this.error("ws error");
};
onWsMessage = async (e: MessageEvent) => {
let raw;
try {
raw = new Uint8Array(await e.data.arrayBuffer());
} catch (e) {
this.error(`error reading part: ${e.message}`);
return;
}
if (this.buf.state === "error") {
console.log("onWsMessage while in state error");
return;
}
let result = parsePart(raw);
if (result.status === "error") {
this.error("unparseable part");
return;
}
const part = result.part;
if (!MediaSource.isTypeSupported(part.mimeType)) {
this.error(`unsupported mime type ${part.mimeType}`);
return;
}
this.queue.push(part);
this.queuedBytes += part.body.byteLength;
if (this.buf.state === "closed") {
const srcBuf = this.src.addSourceBuffer(part.mimeType);
srcBuf.mode = "segments";
srcBuf.addEventListener("updateend", this.bufUpdateEnd);
srcBuf.addEventListener("error", this.bufEvent);
srcBuf.addEventListener("abort", this.bufEvent);
this.buf = {
state: "open",
srcBuf,
busy: true,
mimeType: part.mimeType,
videoSampleEntryId: part.videoSampleEntryId,
};
let initSegmentResult = await api.init(part.videoSampleEntryId, {});
if (initSegmentResult.status !== "success") {
this.error(`init segment fetch status ${initSegmentResult.status}`);
return;
}
srcBuf.appendBuffer(initSegmentResult.response);
return;
} else if (this.buf.state === "open") {
this.tryAppendPart(this.buf);
}
};
bufUpdateEnd = () => {
if (this.buf.state !== "open") {
console.error("bufUpdateEnd in state", this.buf.state);
return;
}
if (!this.buf.busy) {
this.error("bufUpdateEnd when not busy");
return;
}
this.buf.busy = false;
this.tryTrimBuffer();
this.tryAppendPart(this.buf);
};
tryAppendPart = (buf: BufferStateOpen) => {
if (buf.busy) {
return;
}
const part = this.queue.shift();
if (part === undefined) {
return;
}
this.queuedBytes -= part.body.byteLength;
if (
part.mimeType !== buf.mimeType ||
part.videoSampleEntryId !== buf.videoSampleEntryId
) {
this.error("Switching MIME type or videoSampleEntryId unimplemented");
return;
}
// Always put the new part at the end. SourceBuffer.mode "sequence" is
// supposed to generate timestamps automatically, but on Chrome 89.0.4389.90
// it doesn't appear to work as expected. So use SourceBuffer.mode
// "segments" and use the existing end as the timestampOffset.
const b = buf.srcBuf.buffered;
buf.srcBuf.timestampOffset = b.length > 0 ? b.end(b.length - 1) : 0;
try {
buf.srcBuf.appendBuffer(part.body);
} catch (e) {
// In particular, appendBuffer can throw QuotaExceededError.
// <https://developers.google.com/web/updates/2017/10/quotaexceedederror>
// tryTrimBuffer removes already-played stuff from the buffer to avoid
// this, but in theory even one GOP could be more than the total buffer
// size. At least report error properly.
this.error(`${e.name} while appending buffer`);
return;
}
buf.busy = true;
};
tryTrimBuffer = () => {
if (
this.buf.state !== "open" ||
this.buf.busy ||
this.buf.srcBuf.buffered.length === 0 ||
this.videoRef.current === null
) {
return;
}
const curTs = this.videoRef.current.currentTime;
// TODO: call out key frames in the part headers. The "- 5" here is a guess
// to avoid removing anything from the current GOP.
const firstTs = this.buf.srcBuf.buffered.start(0);
if (firstTs < curTs - 5) {
console.log(`${this.camera.shortName}: trimming ${firstTs}-${curTs}`);
this.buf.srcBuf.remove(firstTs, curTs - 5);
this.buf.busy = true;
}
};
bufEvent = (e: Event) => {
this.error(`bufEvent: ${e}`);
};
videoPlaying = (e: SyntheticEvent<HTMLVideoElement, Event>) => {
if (this.buf.state !== "error") {
this.setPlaybackState({ state: "normal" });
}
};
videoWaiting = (e: SyntheticEvent<HTMLVideoElement, Event>) => {
if (this.buf.state !== "error") {
this.setPlaybackState({ state: "waiting" });
}
};
stopStream = (reason: string) => {
if (this.ws === undefined) {
return;
}
console.log(`${this.camera.shortName}: stopping stream: ${reason}`);
const NORMAL_CLOSURE = 1000; // https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent
this.ws.close(NORMAL_CLOSURE);
this.ws.removeEventListener("close", this.onWsClose);
this.ws.removeEventListener("error", this.onWsError);
this.ws.removeEventListener("message", this.onWsMessage);
this.ws = undefined;
};
camera: Camera;
setPlaybackState: (state: PlaybackState) => void;
videoRef: React.RefObject<HTMLVideoElement>;
src = new MediaSource();
buf: BufferState = { state: "closed" };
queue: Part[] = [];
queuedBytes: number = 0;
/// The object URL for the HTML video element, not the WebSocket URL.
url = URL.createObjectURL(this.src);
ws?: WebSocket;
}
/**
* A live view of a camera.
* 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
* and unmounting a camera.
*/
const LiveCamera = ({ camera }: LiveCameraProps) => {
const videoRef = React.useRef<HTMLVideoElement>(null);
const [playbackState, setPlaybackState] = React.useState<PlaybackState>({
state: "normal",
});
// Load the camera driver.
const [driver, setDriver] = React.useState<LiveCameraDriver | null>(null);
React.useEffect(() => {
const d = new LiveCameraDriver(camera, setPlaybackState, videoRef);
setDriver(d);
return () => {
// Explictly stop the stream on unmount. There don't seem to be any DOM
// event handlers that run in this case. (In particular, the MediaSource's
// sourceclose doesn't run.)
d.stopStream("unmount or camera change");
};
}, [camera]);
// Display circular progress after 100 ms of waiting.
const [showProgress, setShowProgress] = React.useState(false);
React.useEffect(() => {
setShowProgress(false);
if (playbackState.state !== "waiting") {
return;
}
const timerId = setTimeout(() => setShowProgress(true), 100);
return () => clearTimeout(timerId);
}, [playbackState]);
if (driver === null) {
return <Box />;
}
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
ref={videoRef}
muted
autoPlay
src={driver.url}
onPause={() => driver.stopStream("pause")}
onPlay={() => driver.startStream("play")}
onPlaying={driver.videoPlaying}
onTimeUpdate={driver.tryTrimBuffer}
onWaiting={driver.videoWaiting}
/>
</Box>
);
};
export default LiveCamera;

258
ui/src/Live/Multiview.tsx Normal file
View File

@ -0,0 +1,258 @@
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
import Select from "@material-ui/core/Select";
import MenuItem from "@material-ui/core/MenuItem";
import React, { useReducer, useState } from "react";
import { Camera } from "../types";
import { makeStyles } from "@material-ui/core/styles";
import useResizeObserver from "@react-hook/resize-observer";
import Box from "@material-ui/core/Box";
export interface Layout {
className: string;
cameras: number;
}
// These class names must match useStyles rules (below).
const LAYOUTS: Layout[] = [
{ className: "solo", cameras: 1 },
{ className: "main-plus-five", cameras: 6 },
{ className: "two-by-two", cameras: 4 },
{ className: "three-by-three", cameras: 9 },
];
const MAX_CAMERAS = 9;
const useStyles = makeStyles((theme) => ({
root: {
flex: "1 0 0",
overflow: "hidden",
color: "white",
marginTop: theme.spacing(2),
},
mid: {
display: "none",
position: "relative",
padding: 0,
margin: 0,
"&.wider, &.wider img": {
height: "100%",
display: "inline-block",
},
"&.taller, &.taller img": {
width: "100%",
display: "inline-block",
},
"& img": {
objectFit: "contain",
},
},
inner: {
// match parent's size without influencing it.
overflow: "hidden",
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
display: "grid",
gridGap: "0px",
// These class names must match LAYOUTS (above).
"&.solo": {
gridTemplateColumns: "100%",
gridTemplateRows: "100%",
},
"&.two-by-two": {
gridTemplateColumns: "repeat(2, calc(100% / 2))",
gridTemplateRows: "repeat(2, calc(100% / 2))",
},
"&.main-plus-five, &.three-by-three": {
gridTemplateColumns: "repeat(3, calc(100% / 3))",
gridTemplateRows: "repeat(3, calc(100% / 3))",
},
"&.main-plus-five > div:nth-child(1)": {
gridColumn: "span 2",
gridRow: "span 2",
},
},
}));
export interface MultiviewProps {
cameras: Camera[];
layoutIndex: number;
renderCamera: (camera: Camera) => JSX.Element;
}
export interface MultiviewChooserProps {
/// An index into <tt>LAYOUTS</tt>.
layoutIndex: number;
onChoice: (selectedIndex: number) => void;
}
/**
* Chooses the layout for a Multiview.
* Styled for placement in the app menu bar.
*/
export const MultiviewChooser = (props: MultiviewChooserProps) => {
return (
<Select
id="layout"
value={props.layoutIndex}
onChange={(e) => props.onChoice(e.target.value)}
size="small"
sx={{
// Hacky attempt to style for the app menu.
color: "inherit",
"& svg": {
color: "inherit",
},
}}
>
{LAYOUTS.map((e, i) => (
<MenuItem key={e.className} value={i}>
{e.className}
</MenuItem>
))}
</Select>
);
};
/**
* The cameras selected for the multiview.
* This is always an array of length <tt>MAX_CAMERAS</tt>; only the first
* LAYOUTS[layoutIndex].cameras are currently visible. There are no duplicates;
* setting one element to a given camera unsets any others pointing to the same
* camera.
*/
type SelectedCameras = Array<number | null>;
interface SelectOp {
selectedIndex: number;
cameraIndex: number | null;
}
function selectedReducer(old: SelectedCameras, op: SelectOp): SelectedCameras {
let selected = [...old]; // shallow clone.
if (op.cameraIndex !== null) {
// de-dupe.
for (let i = 0; i < selected.length; i++) {
if (selected[i] === op.cameraIndex) {
selected[i] = null;
}
}
}
selected[op.selectedIndex] = op.cameraIndex ?? null;
return selected;
}
/**
* Presents one or more camera views in one of several layouts.
*
* The parent should arrange for the multiview's outer div to be as large
* as possible. Internally, multiview uses the largest possible aspect
* 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
* class to constrain the width or height respectively using a technique like
* <https://stackoverflow.com/a/14911949/23584>. The goal is to have the
* smoothest resizing by changing the DOM/CSS as little as possible.
*/
const Multiview = (props: MultiviewProps) => {
const [selected, updateSelected] = useReducer(
selectedReducer,
Array(MAX_CAMERAS).fill(null)
);
const [widerOrTaller, setWiderOrTaller] = useState("wider");
const outerRef = React.useRef<HTMLDivElement>(null);
useResizeObserver(outerRef, (entry: ResizeObserverEntry) => {
const w = entry.contentRect.width;
const h = entry.contentRect.height;
setWiderOrTaller((w * 9) / 16 > h ? "wider" : "taller");
});
const classes = useStyles();
const layout = LAYOUTS[props.layoutIndex];
const monoviews = selected.slice(0, layout.cameras).map((e, i) => {
// When a camera is selected, use the camera's index as the key.
// This allows swapping cameras' positions without tearing down their
// WebSocket connections and buffers.
//
// When no camera is selected, use the index within selected. (Actually,
// its negation, to disambiguate between the two cases.)
const key = e ?? -i;
return (
<Monoview
key={key}
cameras={props.cameras}
cameraIndex={e}
renderCamera={props.renderCamera}
onSelect={(cameraIndex) =>
updateSelected({ selectedIndex: i, cameraIndex })
}
/>
);
});
return (
<div className={classes.root} ref={outerRef}>
<div className={`${classes.mid} ${widerOrTaller}`}>
{/* a 16x9 black png from png-pixel.com */}
<img
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAJCAQAAACRI2S5AAAAEklEQVR42mNk+M+AFzCOKgADALyGCQGyq8YeAAAAAElFTkSuQmCC"
alt=""
/>
<div className={`${classes.inner} ${layout.className}`}>
{monoviews}
</div>
</div>
</div>
);
};
interface MonoviewProps {
cameras: Camera[];
cameraIndex: number | null;
onSelect: (cameraIndex: number | null) => void;
renderCamera: (camera: Camera) => JSX.Element;
}
/** A single pane of a Multiview, including its camera chooser. */
const Monoview = (props: MonoviewProps) => {
return (
<Box>
<Box
sx={{
zIndex: 1,
position: "absolute",
height: "100%",
width: "100%",
}}
>
<Select
value={props.cameraIndex == null ? undefined : props.cameraIndex}
onChange={(e) => props.onSelect(e.target.value ?? null)}
displayEmpty
size="small"
sx={{
// Restyle to fit over the video (or black).
backgroundColor: "rgba(255, 255, 255, 0.5)",
"& svg": {
color: "inherit",
},
}}
>
<MenuItem value={undefined}>(none)</MenuItem>
{props.cameras.map((e, i) => (
<MenuItem key={i} value={i}>
{e.shortName}
</MenuItem>
))}
</Select>
</Box>
{props.cameraIndex !== null &&
props.renderCamera(props.cameras[props.cameraIndex])}
</Box>
);
};
export default Multiview;

25
ui/src/Live/index.tsx Normal file
View File

@ -0,0 +1,25 @@
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
import { Camera } from "../types";
import LiveCamera from "./LiveCamera";
import Multiview from "./Multiview";
export interface LiveProps {
cameras: Camera[];
layoutIndex: number;
}
const Live = ({ cameras, layoutIndex }: LiveProps) => {
return (
<Multiview
layoutIndex={layoutIndex}
cameras={cameras}
renderCamera={(camera: Camera) => <LiveCamera camera={camera} />}
/>
);
};
export { MultiviewChooser } from "./Multiview";
export default Live;

74
ui/src/Live/parser.ts Normal file
View File

@ -0,0 +1,74 @@
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
export interface Part {
mimeType: string;
videoSampleEntryId: number;
body: Uint8Array;
}
interface ParseSuccess {
status: "success";
part: Part;
}
interface ParseError {
status: "error";
errorMessage: string;
}
const ASCII_DECODER = new TextDecoder("ascii");
const CR = "\r".charCodeAt(0);
const NL = "\n".charCodeAt(0);
type ParseResult = ParseSuccess | ParseError;
/// Parses a live stream message.
export function parsePart(raw: Uint8Array): ParseResult {
// Parse into headers and body.
const headers = new Headers();
let pos = 0;
while (true) {
const cr = raw.indexOf(CR, pos);
if (cr === -1 || raw.length === cr + 1 || raw[cr + 1] !== NL) {
return {
status: "error",
errorMessage: "header that never ends (no '\\r\\n')!",
};
}
const line = ASCII_DECODER.decode(raw.slice(pos, cr));
pos = cr + 2;
if (line.length === 0) {
break;
}
const colon = line.indexOf(":");
if (colon === -1 || line.length === colon + 1 || line[colon + 1] !== " ") {
return {
status: "error",
errorMessage: "invalid name/value separator (no ': ')!",
};
}
const name = line.substring(0, colon);
const value = line.substring(colon + 2);
headers.append(name, value);
}
const body = raw.slice(pos);
const mimeType = headers.get("Content-Type");
if (mimeType === null) {
return { status: "error", errorMessage: "no Content-Type" };
}
const videoSampleEntryIdStr = headers.get("X-Video-Sample-Entry-Id");
if (videoSampleEntryIdStr === null) {
return { status: "error", errorMessage: "no X-Video-Sample-Entry-Id" };
}
const videoSampleEntryId = parseInt(videoSampleEntryIdStr, 10);
if (isNaN(videoSampleEntryId)) {
return { status: "error", errorMessage: "invalid X-Video-Sample-Entry-Id" };
}
return {
status: "success",
part: { mimeType, videoSampleEntryId, body },
};
}

View File

@ -80,10 +80,10 @@ async function myfetch(
/** Fetches an initialization segment. */
export async function init(
hash: string,
videoSampleEntryId: number,
init: RequestInit
): Promise<FetchResult<ArrayBuffer>> {
const url = `/api/init/${hash}.mp4`;
const url = `/api/init/${videoSampleEntryId}.mp4`;
const fetchRes = await myfetch(url, init);
if (fetchRes.status !== "success") {
return fetchRes;

View File

@ -4,6 +4,13 @@
* SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
*/
html,
body,
#root {
width: 100%;
height: 100%;
}
@media (pointer: fine) {
/*
* The spacing defined at https://material.io/components/date-pickers#specs

View File

@ -7,3 +7,18 @@
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom";
import { TextDecoder } from "util";
// LiveCamera/parser.ts uses TextDecoder, which works fine from the browser
// but isn't available from node.js without a little help.
// https://create-react-app.dev/docs/running-tests/#initializing-test-environment
// https://stackoverflow.com/questions/51090515/global-functions-in-typescript-for-jest-testing#comment89270564_51091150
declare let global: any;
// TODO: There's likely an elegant way to add TextDecoder to global's type.
// Some promising links:
// https://www.typescriptlang.org/docs/handbook/declaration-merging.html#global-augmentation
// https://stackoverflow.com/a/62011156/23584
// https://github.com/facebook/create-react-app/issues/6553#issuecomment-475491096
global.TextDecoder = TextDecoder;