diff --git a/ui/package-lock.json b/ui/package-lock.json index ec3eeac..1bc373c 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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", diff --git a/ui/package.json b/ui/package.json index aed02e8..7c26c23 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/public/index.html b/ui/public/index.html index ff3401c..ca94e9b 100644 --- a/ui/public/index.html +++ b/ui/public/index.html @@ -41,6 +41,6 @@ -
+
diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f1a43ce..49daaf7 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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("list"); + const [multiviewLayoutIndex, setMultiviewLayoutIndex] = useState(0); const [session, setSession] = useState(null); const [cameras, setCameras] = useState(null); const [timeZoneName, setTimeZoneName] = useState(null); const [fetchSeq, setFetchSeq] = useState(0); - const [loginState, setLoginState] = useState("not-logged-in"); + const [loginState, setLoginState] = useState("unknown"); const [error, setError] = useState(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 = ( + + + + ); + activityMain = ( + + ); + break; + case "live": + activityMenu = ( + + ); + activityMain = ( + + ); + break; + } + } return ( <> { setLoginState("user-requested-login"); }} logout={logout} menuClick={toggleShowMenu} + activityMenuPart={activityMenu} /> + + + clickActivity("list")}> + + + + + + clickActivity("live")}> + + + + + + + - {error != null && ( + {error !== null && (

Error querying server

{error.message}
@@ -120,13 +204,7 @@ function App() {

)} - {cameras != null && cameras.length > 0 && ( - - )} + {activityMain} ); } diff --git a/ui/src/AppMenu.tsx b/ui/src/AppMenu.tsx index 1304b87..b8e0c86 100644 --- a/ui/src/AppMenu.tsx +++ b/ui/src/AppMenu.tsx @@ -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) { Moonfire NVR - {auth || ( + {props.activityMenuPart !== null && ( +
{props.activityMenuPart}
+ )} + {props.loginState !== "unknown" && props.loginState !== "logged-in" && ( )} - {auth && ( + {props.loginState === "logged-in" && (
({ 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) => {
void, + videoRef: React.RefObject + ) { + 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. + // + // 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) => { + if (this.buf.state !== "error") { + this.setPlaybackState({ state: "normal" }); + } + }; + + videoWaiting = (e: SyntheticEvent) => { + 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; + + 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 key attribute to avoid unnecessarily mounting + * and unmounting a camera. + */ +const LiveCamera = ({ camera }: LiveCameraProps) => { + const videoRef = React.useRef(null); + const [playbackState, setPlaybackState] = React.useState({ + state: "normal", + }); + + // Load the camera driver. + const [driver, setDriver] = React.useState(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 ; + } + return ( + + {showProgress && ( +
+ +
+ )} + {playbackState.state === "error" && ( +
+ {playbackState.message} +
+ )} +
+ ); +}; + +export default LiveCamera; diff --git a/ui/src/Live/Multiview.tsx b/ui/src/Live/Multiview.tsx new file mode 100644 index 0000000..a51d803 --- /dev/null +++ b/ui/src/Live/Multiview.tsx @@ -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 LAYOUTS. + 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 ( + + ); +}; + +/** + * The cameras selected for the multiview. + * This is always an array of length MAX_CAMERAS; 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; + +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 + * . 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(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 ( + + updateSelected({ selectedIndex: i, cameraIndex }) + } + /> + ); + }); + return ( +
+
+ {/* a 16x9 black png from png-pixel.com */} + +
+ {monoviews} +
+
+
+ ); +}; + +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 ( + + + + + {props.cameraIndex !== null && + props.renderCamera(props.cameras[props.cameraIndex])} + + ); +}; + +export default Multiview; diff --git a/ui/src/Live/index.tsx b/ui/src/Live/index.tsx new file mode 100644 index 0000000..9bcb4e8 --- /dev/null +++ b/ui/src/Live/index.tsx @@ -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 ( + } + /> + ); +}; + +export { MultiviewChooser } from "./Multiview"; +export default Live; diff --git a/ui/src/Live/parser.ts b/ui/src/Live/parser.ts new file mode 100644 index 0000000..9cdf8b6 --- /dev/null +++ b/ui/src/Live/parser.ts @@ -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 }, + }; +} diff --git a/ui/src/api.ts b/ui/src/api.ts index 204c4d7..344fa97 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -80,10 +80,10 @@ async function myfetch( /** Fetches an initialization segment. */ export async function init( - hash: string, + videoSampleEntryId: number, init: RequestInit ): Promise> { - const url = `/api/init/${hash}.mp4`; + const url = `/api/init/${videoSampleEntryId}.mp4`; const fetchRes = await myfetch(url, init); if (fetchRes.status !== "success") { return fetchRes; diff --git a/ui/src/index.css b/ui/src/index.css index bb09b04..d7fbbe7 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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 diff --git a/ui/src/setupTests.ts b/ui/src/setupTests.ts index ff7f676..acca2ee 100644 --- a/ui/src/setupTests.ts +++ b/ui/src/setupTests.ts @@ -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;