diff --git a/.github/workflows/check-license.py b/.github/workflows/check-license.py index 0d885ea..d85f74d 100755 --- a/.github/workflows/check-license.py +++ b/.github/workflows/check-license.py @@ -50,7 +50,7 @@ def has_license(f): def file_has_license(filename): with open(filename, 'r') as f: return has_license(f) - + def main(args): if not args: @@ -59,8 +59,8 @@ def main(args): missing = [f for f in args if FILENAME_MATCHER.match(f) and not file_has_license(f)] if missing: - print('The following files are missing expected copyright/license headers:') - print('\n'.join(missing)) + print('The following files are missing expected copyright/license headers:', file=sys.stderr) + print('\n'.join(missing), file=sys.stderr) sys.exit(1) diff --git a/ui/package-lock.json b/ui/package-lock.json index fb08629..ec3eeac 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1879,17 +1879,17 @@ } }, "@material-ui/core": { - "version": "5.0.0-alpha.25", - "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-5.0.0-alpha.25.tgz", - "integrity": "sha512-TtU3m3qw0FKtrDHCPmakJ25/20bGPRkLn8qjDkyCUYfg7kG4RLIwgiI0fKnF5PCDu46qL+s233+nVwAh8Iq+qw==", + "version": "5.0.0-alpha.26", + "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-5.0.0-alpha.26.tgz", + "integrity": "sha512-etGxHyZn8REebM8SCcRl0ZaJmm3G6+I7oZqvuZf3Gg+SB03To8SgOkc0BXqteNjt+Z4Etz+xv1iy4RU+rFzGFA==", "requires": { "@babel/runtime": "^7.4.4", "@material-ui/styled-engine": "5.0.0-alpha.25", - "@material-ui/styles": "5.0.0-alpha.25", - "@material-ui/system": "5.0.0-alpha.25", + "@material-ui/styles": "5.0.0-alpha.26", + "@material-ui/system": "5.0.0-alpha.26", "@material-ui/types": "5.1.7", - "@material-ui/unstyled": "5.0.0-alpha.25", - "@material-ui/utils": "5.0.0-alpha.25", + "@material-ui/unstyled": "5.0.0-alpha.26", + "@material-ui/utils": "5.0.0-alpha.26", "@popperjs/core": "^2.4.4", "@types/react-transition-group": "^4.2.0", "clsx": "^1.0.4", @@ -1898,28 +1898,53 @@ "prop-types": "^15.7.2", "react-is": "^16.8.0 || ^17.0.0", "react-transition-group": "^4.4.0" + }, + "dependencies": { + "@material-ui/system": { + "version": "5.0.0-alpha.26", + "resolved": "https://registry.npmjs.org/@material-ui/system/-/system-5.0.0-alpha.26.tgz", + "integrity": "sha512-IiuXiNe1f/CFE4ybNGPu15rB3u3CuT6FKzMnBIQA4mezDOyXw+6vHm7FiBb3uk2LQMvTCJo6zael/QpdHHdwOA==", + "requires": { + "@babel/runtime": "^7.4.4", + "@material-ui/utils": "5.0.0-alpha.26", + "csstype": "^3.0.2", + "prop-types": "^15.7.2" + } + }, + "@material-ui/utils": { + "version": "5.0.0-alpha.26", + "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-5.0.0-alpha.26.tgz", + "integrity": "sha512-h2SAkucZU+SlC14b3Vo3YzTD9dJQKFPVET6RvRhK/9Ommf+V4dPc6otaTMu5ES696BDqRfY7G2aeaTtkrd2bdg==", + "requires": { + "@babel/runtime": "^7.4.4", + "@types/prop-types": "^15.7.3", + "@types/react-is": "^16.7.1 || ^17.0.0", + "prop-types": "^15.7.2", + "react-is": "^16.8.0 || ^17.0.0" + } + } } }, "@material-ui/icons": { - "version": "5.0.0-alpha.24", - "resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-5.0.0-alpha.24.tgz", - "integrity": "sha512-b9WnCARLzy3tRmSShn5iV6EYpWNLmrv9P+1ebAGxkHYvOi7ZHsk6NeL61FNx+sJ9nSrLnk+N7rTYI0h03XViPA==", + "version": "5.0.0-alpha.26", + "resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-5.0.0-alpha.26.tgz", + "integrity": "sha512-omrMgRa90Uip1TW5KRvAoTHvdNkVgnVp8z5rF3ez1q8rgI+RgqLxyHE8ezpbN8LvWvrGU97OXDSDbcsrRUjUxw==", "requires": { "@babel/runtime": "^7.4.4" } }, "@material-ui/lab": { - "version": "5.0.0-alpha.25", - "resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-5.0.0-alpha.25.tgz", - "integrity": "sha512-J5hYwWvVdJP8XRXz8JJs3Imn1xBQqQCCLnbxF59yHgdeGLsnw3RfJulnsxMSlSwk321hDnBlt4XmNf+scvLT0g==", + "version": "5.0.0-alpha.26", + "resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-5.0.0-alpha.26.tgz", + "integrity": "sha512-WqdLxN6v/gBOVKOwjrs+L9spmPQHRI+MMMtYoaHNY2t870OGuR8/jBxKaZVyq8G60bKHiGq8cg5ppHkQibQmyQ==", "requires": { "@babel/runtime": "^7.4.4", "@date-io/date-fns": "^2.10.6", "@date-io/dayjs": "^2.10.6", "@date-io/luxon": "^2.10.6", "@date-io/moment": "^2.10.6", - "@material-ui/system": "5.0.0-alpha.25", - "@material-ui/utils": "5.0.0-alpha.25", + "@material-ui/system": "5.0.0-alpha.26", + "@material-ui/utils": "5.0.0-alpha.26", "clsx": "^1.0.4", "prop-types": "^15.7.2", "react-is": "^16.8.0 || ^17.0.0", @@ -1938,14 +1963,14 @@ } }, "@material-ui/styles": { - "version": "5.0.0-alpha.25", - "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-5.0.0-alpha.25.tgz", - "integrity": "sha512-VODyi/JT0njmkcyRKiYPacKQmhS11bidg6AMTgAU5KjdmchFPRIl5lwIHFYeRtVEsHX9/9tE0pPlCJIhVYAYsw==", + "version": "5.0.0-alpha.26", + "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-5.0.0-alpha.26.tgz", + "integrity": "sha512-HL1NdzIruDkRMoPhb6qRDGYO4Bn3xM/VwE3pfZPmKyjOz7tCvtqHR5uzdI34EcodgfM/Hm/HSDB69CBTpxBt4A==", "requires": { "@babel/runtime": "^7.4.4", "@emotion/hash": "^0.8.0", "@material-ui/types": "5.1.7", - "@material-ui/utils": "5.0.0-alpha.25", + "@material-ui/utils": "5.0.0-alpha.26", "clsx": "^1.0.4", "csstype": "^3.0.2", "hoist-non-react-statics": "^3.3.2", @@ -1958,15 +1983,29 @@ "jss-plugin-rule-value-function": "^10.0.3", "jss-plugin-vendor-prefixer": "^10.0.3", "prop-types": "^15.7.2" + }, + "dependencies": { + "@material-ui/utils": { + "version": "5.0.0-alpha.26", + "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-5.0.0-alpha.26.tgz", + "integrity": "sha512-h2SAkucZU+SlC14b3Vo3YzTD9dJQKFPVET6RvRhK/9Ommf+V4dPc6otaTMu5ES696BDqRfY7G2aeaTtkrd2bdg==", + "requires": { + "@babel/runtime": "^7.4.4", + "@types/prop-types": "^15.7.3", + "@types/react-is": "^16.7.1 || ^17.0.0", + "prop-types": "^15.7.2", + "react-is": "^16.8.0 || ^17.0.0" + } + } } }, "@material-ui/system": { - "version": "5.0.0-alpha.25", - "resolved": "https://registry.npmjs.org/@material-ui/system/-/system-5.0.0-alpha.25.tgz", - "integrity": "sha512-4TrsBfB9MMsUj8o1jGRBI4Zv1SSASDwg1lfad5SYtlGhL+0LiSDO/XhVvOvYaRi749lguux2rlrVZpIut0jgMA==", + "version": "5.0.0-alpha.26", + "resolved": "https://registry.npmjs.org/@material-ui/system/-/system-5.0.0-alpha.26.tgz", + "integrity": "sha512-IiuXiNe1f/CFE4ybNGPu15rB3u3CuT6FKzMnBIQA4mezDOyXw+6vHm7FiBb3uk2LQMvTCJo6zael/QpdHHdwOA==", "requires": { "@babel/runtime": "^7.4.4", - "@material-ui/utils": "5.0.0-alpha.25", + "@material-ui/utils": "5.0.0-alpha.26", "csstype": "^3.0.2", "prop-types": "^15.7.2" } @@ -1977,21 +2016,35 @@ "integrity": "sha512-OSpB0gEKZm5h4izTLyipb34PkfazpvusgQMDTmFkSuqcKoChTshfGejEYX6uaZ+4m5xlT5qzihE6eKA+JnjELg==" }, "@material-ui/unstyled": { - "version": "5.0.0-alpha.25", - "resolved": "https://registry.npmjs.org/@material-ui/unstyled/-/unstyled-5.0.0-alpha.25.tgz", - "integrity": "sha512-LqAXDhSk2GC3C3SX0U5V3U0WA6gAS5Ayrozo9Br2XtxfEDdPTDmR1R7csT+DruyP3vGBs4k4ILIUz8KfC/kjww==", + "version": "5.0.0-alpha.26", + "resolved": "https://registry.npmjs.org/@material-ui/unstyled/-/unstyled-5.0.0-alpha.26.tgz", + "integrity": "sha512-6xoakfiqtT1/KOcN3QPc5Avf8llNn2eklpKiEY4Jut6wIw5DHlGYi4jlMe6e7SvxUXTVdz0S55zR3GCLlKfcPg==", "requires": { "@babel/runtime": "^7.4.4", - "@material-ui/utils": "5.0.0-alpha.25", + "@material-ui/utils": "5.0.0-alpha.26", "clsx": "^1.0.4", "prop-types": "^15.7.2", "react-is": "^16.8.0 || ^17.0.0" + }, + "dependencies": { + "@material-ui/utils": { + "version": "5.0.0-alpha.26", + "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-5.0.0-alpha.26.tgz", + "integrity": "sha512-h2SAkucZU+SlC14b3Vo3YzTD9dJQKFPVET6RvRhK/9Ommf+V4dPc6otaTMu5ES696BDqRfY7G2aeaTtkrd2bdg==", + "requires": { + "@babel/runtime": "^7.4.4", + "@types/prop-types": "^15.7.3", + "@types/react-is": "^16.7.1 || ^17.0.0", + "prop-types": "^15.7.2", + "react-is": "^16.8.0 || ^17.0.0" + } + } } }, "@material-ui/utils": { - "version": "5.0.0-alpha.25", - "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-5.0.0-alpha.25.tgz", - "integrity": "sha512-wgW1ua+ncAU6lrE2bFhd9iU1xmpxj1KL+YRJa0PoFpFgLNVpSTCVHynMlRAERNP+CteazFHj5upkigqJpV406Q==", + "version": "5.0.0-alpha.26", + "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-5.0.0-alpha.26.tgz", + "integrity": "sha512-h2SAkucZU+SlC14b3Vo3YzTD9dJQKFPVET6RvRhK/9Ommf+V4dPc6otaTMu5ES696BDqRfY7G2aeaTtkrd2bdg==", "requires": { "@babel/runtime": "^7.4.4", "@types/prop-types": "^15.7.3", @@ -2066,9 +2119,9 @@ } }, "@popperjs/core": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.8.3.tgz", - "integrity": "sha512-olsVs3lo8qKycPoWAUv4bMyoTGVXsEpLR9XxGk3LJR5Qa92a1Eg/Fj1ATdhwtC/6VMaKtsz1nSAeheD2B2Ru9A==" + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.0.tgz", + "integrity": "sha512-wjtKehFAIARq2OxK8j3JrggNlEslJfNuSm2ArteIbKyRMts2g0a7KzTxfRVNUM+O0gnBJ2hNV8nWPOYBgI1sew==" }, "@rollup/plugin-node-resolve": { "version": "7.1.3", @@ -5181,6 +5234,16 @@ "whatwg-url": "^8.0.0" } }, + "date-fns": { + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.18.0.tgz", + "integrity": "sha512-NYyAg4wRmGVU4miKq5ivRACOODdZRY3q5WLmOJSq8djyzftYphU7dTHLcEtLqEvfqMKQ0jVv91P4BAwIjsXIcw==" + }, + "date-fns-tz": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-1.1.3.tgz", + "integrity": "sha512-mD26WkejWz842RggjFrKsY6ehGgyBQSJ209mn83/vsjhgQ5WbdVvBzJ0CuosnGdklDxOvOppQ/wn1UgvTOPKPw==" + }, "debug": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", diff --git a/ui/package.json b/ui/package.json index 9b95597..aed02e8 100644 --- a/ui/package.json +++ b/ui/package.json @@ -5,14 +5,16 @@ "dependencies": { "@emotion/react": "^11.1.5", "@emotion/styled": "^11.1.5", - "@material-ui/core": "^5.0.0-alpha.25", - "@material-ui/icons": "^5.0.0-alpha.24", - "@material-ui/lab": "^5.0.0-alpha.25", + "@fontsource/roboto": "^4.2.1", + "@material-ui/core": "^5.0.0-alpha.26", + "@material-ui/icons": "^5.0.0-alpha.26", + "@material-ui/lab": "^5.0.0-alpha.26", "@types/jest": "^26.0.20", "@types/node": "^14.14.22", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", - "@fontsource/roboto": "^4.2.1", + "date-fns": "^2.18.0", + "date-fns-tz": "^1.1.3", "gzipper": "^4.4.0", "react": "^17.0.1", "react-dom": "^17.0.1", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index a1c9198..64ba8ad 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -8,7 +8,9 @@ import * as api from "./api"; import MoonfireMenu from "./AppMenu"; import Login from "./Login"; import { useSnackbars } from "./snackbars"; -import { Session } from "./types"; +import { Camera, Session } from "./types"; +import List from "./List"; +import AppBar from "@material-ui/core/AppBar"; type LoginState = | "logged-in" @@ -18,6 +20,8 @@ type LoginState = function App() { 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 [error, setError] = useState(null); @@ -71,6 +75,8 @@ function App() { resp.response.session === undefined ? "not-logged-in" : "logged-in" ); setSession(resp.response.session || null); + setCameras(resp.response.cameras); + setTimeZoneName(resp.response.timeZoneName); } }; console.debug("Toplevel fetch num", fetchSeq); @@ -80,17 +86,18 @@ function App() { abort.abort(); }; }, [fetchSeq]); - return ( <> - { - setLoginState("user-requested-login"); - }} - logout={logout} - /> + + { + setLoginState("user-requested-login"); + }} + logout={logout} + /> + )} + {cameras != null && cameras.length > 0 && ( + + )} ); } diff --git a/ui/src/AppMenu.tsx b/ui/src/AppMenu.tsx index 97251a8..c7f95b7 100644 --- a/ui/src/AppMenu.tsx +++ b/ui/src/AppMenu.tsx @@ -2,7 +2,6 @@ // 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 AppBar from "@material-ui/core/AppBar"; import Button from "@material-ui/core/Button"; import IconButton from "@material-ui/core/IconButton"; import Menu from "@material-ui/core/Menu"; @@ -28,6 +27,7 @@ interface Props { setSession: (session: Session | null) => void; requestLogin: () => void; logout: () => void; + menuClick?: () => void; } // https://material-ui.com/components/app-bar/ @@ -56,9 +56,14 @@ function MoonfireMenu(props: Props) { }; return ( - + <> - + @@ -101,7 +106,7 @@ function MoonfireMenu(props: Props) { )} - + ); } diff --git a/ui/src/List/StreamMultiSelector.tsx b/ui/src/List/StreamMultiSelector.tsx new file mode 100644 index 0000000..60c59c7 --- /dev/null +++ b/ui/src/List/StreamMultiSelector.tsx @@ -0,0 +1,130 @@ +// 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 Card from "@material-ui/core/Card"; +import { Camera, Stream, StreamType } from "../types"; +import Checkbox from "@material-ui/core/Checkbox"; +import { makeStyles, useTheme } from "@material-ui/core/styles"; + +interface Props { + cameras: Camera[]; + selected: Set; + setSelected: (selected: Set) => void; +} + +const useStyles = makeStyles({ + streamSelectorTable: { + fontSize: "0.9rem", + "& td:first-child": { + paddingRight: "3px", + }, + "& td:not(:first-child)": { + textAlign: "center", + }, + }, + check: { + padding: "3px", + }, + "@media (pointer: fine)": { + check: { + padding: "0px", + }, + }, +}); + +/** Returns a table which allows selecting zero or more streams. */ +const StreamMultiSelector = ({ cameras, selected, setSelected }: Props) => { + const theme = useTheme(); + const classes = useStyles(); + const setStream = (s: Stream, checked: boolean) => { + console.log("toggle", s.camera.shortName, s.streamType); + const updated = new Set(selected); + if (checked) { + updated.add(s); + } else { + updated.delete(s); + } + setSelected(updated); + }; + const toggleType = (st: StreamType) => { + let updated = new Set(selected); + let foundAny = false; + for (const s of selected) { + if (s.streamType === st) { + updated.delete(s); + foundAny = true; + } + } + if (!foundAny) { + for (const c of cameras) { + if (c.streams[st] !== undefined) { + updated.add(c.streams[st]); + } + } + } + setSelected(updated); + }; + const toggleCamera = (c: Camera) => { + const updated = new Set(selected); + let foundAny = false; + for (const st in c.streams) { + const s = c.streams[st as StreamType]; + if (selected.has(s)) { + updated.delete(s); + foundAny = true; + } + } + if (!foundAny) { + for (const st in c.streams) { + updated.add(c.streams[st as StreamType]); + } + } + setSelected(updated); + }; + + const cameraRows = cameras.map((c) => { + function checkbox(st: StreamType) { + const s = c.streams[st]; + if (s === undefined) { + return ; + } + return ( + setStream(s, checked)} + /> + ); + } + return ( + + toggleCamera(c)}>{c.shortName} + {checkbox("main")} + {checkbox("sub")} + + ); + }); + return ( + + + + + + + + + {cameraRows} +
+ toggleType("main")}>main toggleType("sub")}>sub
+
+ ); +}; + +export default StreamMultiSelector; diff --git a/ui/src/List/TimerangeSelector.tsx b/ui/src/List/TimerangeSelector.tsx new file mode 100644 index 0000000..97e8106 --- /dev/null +++ b/ui/src/List/TimerangeSelector.tsx @@ -0,0 +1,339 @@ +// 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 { Stream } from "../types"; +import StaticDatePicker from "@material-ui/lab/StaticDatePicker"; +import React, { useEffect } from "react"; +import { zonedTimeToUtc } from "date-fns-tz"; +import { addDays, addMilliseconds, differenceInMilliseconds } from "date-fns"; +import startOfDay from "date-fns/startOfDay"; +import Card from "@material-ui/core/Card"; +import { useTheme } from "@material-ui/core/styles"; +import TextField from "@material-ui/core/TextField"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import FormLabel from "@material-ui/core/FormLabel"; +import Radio from "@material-ui/core/Radio"; +import RadioGroup from "@material-ui/core/RadioGroup"; +import TimePicker, { TimePickerProps } from "@material-ui/lab/TimePicker"; + +interface Props { + selectedStreams: Set; + timeZoneName: string; + range90k: [number, number] | null; + setRange90k: (range: [number, number] | null) => void; +} + +const MyTimePicker = ( + props: Pick +) => ( + } + inputFormat="HH:mm:ss" + mask="__:__:__" + ampm={false} + {...props} + /> +); + +/** + * Combines the date-part of dayMillis and the time part of + * time. If time is null, assume it reaches the end of the + * day. + */ +const combine = (dayMillis: number, time: Date | null) => { + const start = new Date(dayMillis); + if (time === null) { + return addDays(start, 1); + } + return addMilliseconds( + start, + differenceInMilliseconds(time, startOfDay(time)) + ); +}; + +/** + * Allowed days to select (ones with video). + * + * These are stored in a funny format: number of milliseconds since epoch of + * the start of the given day in the browser's time zone. This is because + * (a) Date objects are always in the local time zone and date-fn rolls with + * that, and (b) Date objects don't work well in a set. Javascript's + * "same-value-zero algorithm" means that two different Date objects never + * compare the same. + */ +type AllowedDays = { + minMillis: number; + maxMillis: number; + allMillis: Set; +}; + +type EndDayType = "same-day" | "other-day"; + +type DaysState = { + allowed: AllowedDays | null; + + /** [start, end] in same format as described for AllowedDays. */ + rangeMillis: [number, number] | null; + endType: EndDayType; +}; + +type DaysOpUpdateSelectedStreams = { + op: "update-selected-streams"; + selectedStreams: Set; +}; + +type DaysOpSetStartDay = { + op: "set-start-day"; + newStartDate: Date | null; +}; + +type DaysOpSetEndDay = { + op: "set-end-day"; + newEndDate: Date; +}; + +type DaysOpSetEndDayType = { + op: "set-end-type"; + newEndType: EndDayType; +}; + +type DaysOp = + | DaysOpUpdateSelectedStreams + | DaysOpSetStartDay + | DaysOpSetEndDay + | DaysOpSetEndDayType; + +/** + * Computes an AllowedDays from the given streams. + * Returns null if there are no allowed days. + */ +function computeAllowedDayInfo( + selectedStreams: Set +): AllowedDays | null { + let minMillis = null; + let maxMillis = null; + let allMillis = new Set(); + for (const s of selectedStreams) { + for (const d in s.days) { + const t = new Date(d + "T00:00:00").getTime(); + if (minMillis === null || t < minMillis) { + minMillis = t; + } + if (maxMillis === null || t > maxMillis) { + maxMillis = t; + } + allMillis.add(t); + } + } + if (minMillis === null || maxMillis === null) { + return null; + } + return { + minMillis, + maxMillis, + allMillis, + }; +} + +const toMillis = (d: Date) => startOfDay(d).getTime(); + +function daysStateReducer(old: DaysState, op: DaysOp): DaysState { + let state = { ...old }; + + function updateStart(newStart: number) { + if ( + state.rangeMillis === null || + state.endType === "same-day" || + state.rangeMillis[1] < newStart + ) { + state.rangeMillis = [newStart, newStart]; + } else { + state.rangeMillis[0] = newStart; + } + } + + switch (op.op) { + case "update-selected-streams": + state.allowed = computeAllowedDayInfo(op.selectedStreams); + if (state.allowed === null) { + state.rangeMillis = null; + } else if (state.rangeMillis === null) { + state.rangeMillis = [state.allowed.maxMillis, state.allowed.maxMillis]; + } else { + if (state.rangeMillis[0] < state.allowed.minMillis) { + updateStart(state.allowed.minMillis); + } + if (state.rangeMillis[1] > state.allowed.maxMillis) { + state.rangeMillis[1] = state.allowed.maxMillis; + } + } + break; + case "set-start-day": + if (op.newStartDate === null) { + state.rangeMillis = null; + } else { + const millis = toMillis(op.newStartDate); + if (state.allowed === null || state.allowed.minMillis > millis) { + console.error("Invalid start day selection ", op.newStartDate); + } else { + updateStart(millis); + } + } + break; + case "set-end-day": + const millis = toMillis(op.newEndDate); + if ( + state.rangeMillis === null || + state.allowed === null || + state.allowed.maxMillis < millis + ) { + console.error("Invalid end day selection ", op.newEndDate); + } else { + state.rangeMillis[1] = millis; + } + break; + case "set-end-type": + state.endType = op.newEndType; + if (state.endType === "same-day" && state.rangeMillis !== null) { + state.rangeMillis[1] = state.rangeMillis[0]; + } + break; + } + return state; +} + +const TimerangeSelector = ({ + selectedStreams, + timeZoneName, + range90k, + setRange90k, +}: Props) => { + const theme = useTheme(); + const [days, updateDays] = React.useReducer(daysStateReducer, { + allowed: null, + rangeMillis: null, + endType: "same-day", + }); + const [startTime, setStartTime] = React.useState( + new Date("1970-01-01T00:00:00") + ); + const [endTime, setEndTime] = React.useState(null); + + useEffect( + () => updateDays({ op: "update-selected-streams", selectedStreams }), + [selectedStreams] + ); + const shouldDisableDate = (date: Date | null) => { + return ( + days.allowed === null || + !days.allowed.allMillis.has(startOfDay(date!).getTime()) + ); + }; + + // Update range90k to reflect the selected options. + useEffect(() => { + if (days.rangeMillis === null) { + setRange90k(null); + return; + } + const start = combine(days.rangeMillis[0], startTime); + const end = combine(days.rangeMillis[1], endTime); + setRange90k([ + zonedTimeToUtc(start, timeZoneName).getTime() * 90, + zonedTimeToUtc(end, timeZoneName).getTime() * 90, + ]); + }, [days, startTime, endTime, timeZoneName, setRange90k]); + + const today = new Date(); + + let startDate = null; + let endDate = null; + if (days.rangeMillis !== null) { + startDate = new Date(days.rangeMillis[0]); + endDate = new Date(days.rangeMillis[1]); + } + return ( + +
+ From + { + updateDays({ op: "set-start-day", newStartDate: d }); + }} + renderInput={(params) => } + /> + { + console.log("start time onChange", newValue); + if (newValue === null || isFinite((newValue as Date).getTime())) { + setStartTime(newValue); + } + }} + disabled={days.allowed === null} + /> +
+
+ To + { + updateDays({ + op: "set-end-type", + newEndType: e.target.value as EndDayType, + }); + }} + > + } + label="Same day" + /> + } + label="Other day" + /> + + + days.endType !== "other-day" || shouldDisableDate(d) + } + maxDate={ + startDate === null ? today : new Date(days.allowed!.maxMillis) + } + minDate={startDate === null ? today : startDate} + onChange={(d: Date | null) => { + updateDays({ op: "set-end-day", newEndDate: d! }); + }} + renderInput={(params) => } + /> + { + if (newValue === null || isFinite((newValue as Date).getTime())) { + setEndTime(newValue); + } + }} + disabled={days.allowed === null} + /> +
+
+ ); +}; + +export default TimerangeSelector; diff --git a/ui/src/List/VideoList.tsx b/ui/src/List/VideoList.tsx new file mode 100644 index 0000000..7c2b1f8 --- /dev/null +++ b/ui/src/List/VideoList.tsx @@ -0,0 +1,118 @@ +// 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 CircularProgress from "@material-ui/core/CircularProgress"; +import React from "react"; +import * as api from "../api"; +import { useSnackbars } from "../snackbars"; +import { Stream } from "../types"; +import TableBody from "@material-ui/core/TableBody"; +import TableCell from "@material-ui/core/TableCell"; +import TableRow from "@material-ui/core/TableRow"; + +interface Props { + stream: Stream; + range90k: [number, number] | null; + setActiveRecording: (recording: [Stream, api.Recording] | null) => void; + formatTime: (time90k: number) => string; +} + +const frameRateFmt = new Intl.NumberFormat([], { + maximumFractionDigits: 0, +}); + +const sizeFmt = new Intl.NumberFormat([], { + maximumFractionDigits: 1, +}); + +/** + * Creates a TableBody with a list of videos for a given + * stream and range90k. + * + * The parent is responsible for creating the greater table. + * + * When one is clicked, calls setActiveRecording. + */ +const VideoList = ({ + stream, + range90k, + setActiveRecording, + formatTime, +}: Props) => { + const snackbars = useSnackbars(); + const [ + response, + setResponse, + ] = React.useState | null>(null); + const [showLoading, setShowLoading] = React.useState(false); + React.useEffect(() => { + const abort = new AbortController(); + const doFetch = async (signal: AbortSignal, range90k: [number, number]) => { + const req: api.RecordingsRequest = { + cameraUuid: stream.camera.uuid, + stream: stream.streamType, + startTime90k: range90k[0], + endTime90k: range90k[1], + }; + setResponse(await api.recordings(req, { signal })); + }; + if (range90k != null) { + doFetch(abort.signal, range90k); + const timeout = setTimeout(() => setShowLoading(true), 1000); + return () => { + abort.abort(); + clearTimeout(timeout); + }; + } + }, [range90k, snackbars, stream]); + + let body = null; + if (response === null) { + if (showLoading) { + body = ( + + + + + + ); + } + } else if (response.status === "error") { + body = ( + + Error: {response.status} + + ); + } else if (response.status === "success") { + const resp = response.response; + body = resp.recordings.map((r: api.Recording) => { + const vse = resp.videoSampleEntries[r.videoSampleEntryId]; + const durationSec = (r.endTime90k - r.startTime90k) / 90000; + return ( + setActiveRecording([stream, r])} + > + {formatTime(r.startTime90k)} + {formatTime(r.endTime90k)} + + {vse.width}x{vse.height} + + + {frameRateFmt.format(r.videoSamples / durationSec)} + + + {sizeFmt.format(r.sampleFileBytes / 1048576)} MiB + + + {sizeFmt.format((r.sampleFileBytes / durationSec) * 0.000008)} Mbps + + + ); + }); + } + return {body}; +}; + +export default VideoList; diff --git a/ui/src/List/index.tsx b/ui/src/List/index.tsx new file mode 100644 index 0000000..3385879 --- /dev/null +++ b/ui/src/List/index.tsx @@ -0,0 +1,165 @@ +// 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, { useMemo, useState } from "react"; +import { Camera, Stream } from "../types"; +import * as api from "../api"; +import VideoList from "./VideoList"; +import { makeStyles, Theme } from "@material-ui/core/styles"; +import Modal from "@material-ui/core/Modal"; +import format from "date-fns/format"; +import utcToZonedTime from "date-fns-tz/utcToZonedTime"; +import Table from "@material-ui/core/Table"; +import TableCell from "@material-ui/core/TableCell"; +import TableContainer from "@material-ui/core/TableContainer"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import Paper from "@material-ui/core/Paper"; +import StreamMultiSelector from "./StreamMultiSelector"; +import TimerangeSelector from "./TimerangeSelector"; + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + display: "flex", + [theme.breakpoints.down("md")]: { + flexDirection: "column", + }, + margin: theme.spacing(2), + }, + selectors: { + [theme.breakpoints.up("md")]: { + marginRight: theme.spacing(2), + }, + [theme.breakpoints.down("md")]: { + marginBottom: theme.spacing(2), + }, + }, + video: { + objectFit: "contain", + width: "100%", + height: "100%", + background: "#000", + }, + camera: { + background: theme.palette.primary.light, + color: theme.palette.primary.contrastText, + }, + videoTable: { + flexGrow: 1, + "& .MuiTableBody-root:not(:last-child):after": { + content: "''", + display: "block", + height: theme.spacing(2), + }, + }, +})); + +interface Props { + timeZoneName: string; + + cameras: Camera[]; +} + +const Main = ({ cameras, timeZoneName }: Props) => { + const classes = useStyles(); + + /** + * Selected streams to display and use for selecting time ranges. + * This currently uses the Stream object, which means it will + * not be stable across top-level API fetches. Maybe an id would be better. + */ + const [selectedStreams, setSelectedStreams] = useState>( + new Set() + ); + + /** Selected time range. */ + const [range90k, setRange90k] = useState<[number, number] | null>(null); + + const [activeRecording, setActiveRecording] = useState< + [Stream, api.Recording] | null + >(null); + const formatTime = useMemo(() => { + return (time90k: number) => { + return format( + utcToZonedTime(new Date(time90k / 90), timeZoneName), + "d MMM yyyy HH:mm:ss" + ); + }; + }, [timeZoneName]); + + let videoLists = []; + for (const s of selectedStreams) { + videoLists.push( + + + + + {s.camera.shortName} {s.streamType} + + + + start + end + resolution + fps + storage + bitrate + + + + + ); + } + const closeModal = (event: {}, reason: string) => { + console.log("closeModal", reason); + setActiveRecording(null); + }; + const recordingsTable = ( + + + {videoLists} +
+
+ ); + return ( +
+
+ + +
+ {videoLists.length > 0 && recordingsTable} + {activeRecording != null && ( + + + )} +
+ ); +}; + +export default Main; diff --git a/ui/src/api.ts b/ui/src/api.ts index c344472..d9b48ea 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -13,12 +13,14 @@ import { Camera, Session } from "./types"; -interface FetchSuccess { +export type StreamType = "main" | "sub"; + +export interface FetchSuccess { status: "success"; response: T; } -interface FetchAborted { +export interface FetchAborted { status: "aborted"; } @@ -28,7 +30,7 @@ export interface FetchError { httpStatus?: number; } -type FetchResult = FetchSuccess | FetchAborted | FetchError; +export type FetchResult = FetchSuccess | FetchAborted | FetchError; async function myfetch( url: string, @@ -124,21 +126,31 @@ async function json( }; } -export type ToplevelResponse = { +export interface ToplevelResponse { timeZoneName: string; cameras: Camera[]; session: Session | undefined; -}; +} /** Fetches the top-level API data. */ export async function toplevel(init: RequestInit) { - return await json("/api/", init); + const resp = await json("/api/?days=true", init); + if (resp.status === "success") { + resp.response.cameras.forEach((c) => { + for (const key in c.streams) { + const s = c.streams[key as StreamType]; + s.camera = c; + s.streamType = key as StreamType; + } + }); + } + return resp; } -export type LoginRequest = { +export interface LoginRequest { username: string; password: string; -}; +} /** Logs in. */ export async function login(req: LoginRequest, init: RequestInit) { @@ -152,9 +164,9 @@ export async function login(req: LoginRequest, init: RequestInit) { }); } -export type LogoutRequest = { +export interface LogoutRequest { csrf: string; -}; +} /** Logs out. */ export async function logout(req: LogoutRequest, init: RequestInit) { @@ -167,3 +179,88 @@ export async function logout(req: LogoutRequest, init: RequestInit) { ...init, }); } + +export interface Recording { + startId: number; + endId?: number; + firstUncommited?: number; + growing?: boolean; + openId: number; + startTime90k: number; + endTime90k: number; + videoSampleEntryId: number; + videoSamples: number; + sampleFileBytes: number; +} + +export interface VideoSampleEntry { + width: number; + height: number; + pixelHSpacing?: number; + pixelVSpacing?: number; +} + +export interface RecordingsRequest { + cameraUuid: string; + stream: StreamType; + startTime90k?: number; + endTime90k?: number; + split90k?: number; +} + +export interface RecordingsResponse { + recordings: Recording[]; + videoSampleEntries: { [id: number]: VideoSampleEntry }; +} + +function withQuery(baseUrl: string, params: { [key: string]: any }): string { + const p = new URLSearchParams(); + for (const k in params) { + const v = params[k]; + if (v !== undefined) { + p.append(k, v.toString()); + } + } + const ps = p.toString(); + return ps !== "" ? `${baseUrl}?${ps}` : baseUrl; +} + +export async function recordings(req: RecordingsRequest, init: RequestInit) { + const p = new URLSearchParams(); + if (req.startTime90k !== undefined) { + p.append("startTime90k", req.startTime90k.toString()); + } + if (req.endTime90k !== undefined) { + p.append("endTime90k", req.endTime90k.toString()); + } + if (req.split90k !== undefined) { + p.append("split90k", req.split90k.toString()); + } + const url = withQuery( + `/api/cameras/${req.cameraUuid}/${req.stream}/recordings`, + { + startTime90k: req.startTime90k, + endTime90k: req.endTime90k, + split90k: req.split90k, + } + ); + return await json(url, init); +} + +export function recordingUrl( + cameraUuid: string, + stream: StreamType, + r: Recording +): string { + let s = `${r.startId}`; + if (r.endId !== undefined) { + s += `-${r.endId}`; + } + if (r.firstUncommited !== undefined) { + s += `@${r.openId}`; + } + return withQuery(`/api/cameras/${cameraUuid}/${stream}/view.mp4`, { + s, + ts: true, + }); +} diff --git a/ui/src/index.css b/ui/src/index.css new file mode 100644 index 0000000..bb09b04 --- /dev/null +++ b/ui/src/index.css @@ -0,0 +1,50 @@ +/* + * 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 + */ + +@media (pointer: fine) { + /* + * The spacing defined at https://material.io/components/date-pickers#specs + * seems plenty big enough (on desktop). Not sure why material-ui wants + * to make it bigger but that doesn't work well with our layout. + * + * Defining this here (in a global .css file) is the first way I could find + * to override properties of .MuiPickerStaticWrapper-root. It's unfortunately + * a _parent_ of the element that gets the 's className applied, + * and it doesn't seem to be exposed for a global style override + * . + */ + .MuiPickersStaticWrapper-root { + min-width: 256px !important; + } + + /* Increased specificity here so it doesn't apply to the popup time picker. */ + .MuiPickersStaticWrapper-root .MuiPickerView-root { + width: 256px !important; + } + + .MuiPickersCalendar-root { + min-height: 160px !important; + } + + .MuiPickersCalendar-weekDayLabel { + width: 32px !important; + height: 32px !important; + margin: 0 !important; + } + + .MuiPickersCalendar-week { + margin: 0 !important; + } + + .MuiPickersDay-dayWithMargin { + margin: 0 !important; + } + + .MuiPickersDay-root { + width: 32px !important; + height: 32px !important; + } +} diff --git a/ui/src/index.tsx b/ui/src/index.tsx index 63003d4..b38b7c7 100644 --- a/ui/src/index.tsx +++ b/ui/src/index.tsx @@ -5,12 +5,15 @@ import CssBaseline from "@material-ui/core/CssBaseline"; import { ThemeProvider, createMuiTheme } from "@material-ui/core/styles"; import StyledEngineProvider from "@material-ui/core/StyledEngineProvider"; +import LocalizationProvider from "@material-ui/lab/LocalizationProvider"; import "@fontsource/roboto"; import React from "react"; import ReactDOM from "react-dom"; import App from "./App"; import ErrorBoundary from "./ErrorBoundary"; import { SnackbarProvider } from "./snackbars"; +import AdapterDateFns from "@material-ui/lab/AdapterDateFns"; +import "./index.css"; const theme = createMuiTheme({ palette: { @@ -29,9 +32,11 @@ ReactDOM.render( - - - + + + + + diff --git a/ui/src/types.ts b/ui/src/types.ts index 9ac800b..b065d25 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -2,6 +2,13 @@ // 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 +/** + * @file Types from the Moonfire NVR API. + * See descriptions in design/api.md. + */ + +export type StreamType = "main" | "sub"; + export interface Session { username: string; csrf: string; @@ -10,4 +17,24 @@ export interface Session { export interface Camera { uuid: string; shortName: string; + description: string; + streams: Record; +} + +export interface Stream { + camera: Camera; // back-reference added within api.ts. + streamType: StreamType; // likewise. + retainBytes: number; + minStartTime90k: number; + maxEndTime90k: number; + totalDuration90k: number; + totalSampleFileBytes: number; + fsBytes: number; + days: Record; +} + +export interface Day { + totalDuration90k: number; + startTime90k: number; + endTime90k: number; } diff --git a/ui/tsconfig.json b/ui/tsconfig.json index a273b0c..d7175ed 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -1,11 +1,8 @@ { "compilerOptions": { "target": "es5", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], + "downlevelIteration": true, "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, @@ -20,7 +17,5 @@ "noEmit": true, "jsx": "react-jsx" }, - "include": [ - "src" - ] + "include": ["src"] }