mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-01-12 15:33:22 -05:00
first draft of react-based list ui (#111)
This commit is contained in:
parent
2677184c58
commit
08c3246982
6
.github/workflows/check-license.py
vendored
6
.github/workflows/check-license.py
vendored
@ -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)
|
||||
|
||||
|
||||
|
129
ui/package-lock.json
generated
129
ui/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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<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 [error, setError] = useState<api.FetchError | null>(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 (
|
||||
<>
|
||||
<MoonfireMenu
|
||||
session={session}
|
||||
setSession={setSession}
|
||||
requestLogin={() => {
|
||||
setLoginState("user-requested-login");
|
||||
}}
|
||||
logout={logout}
|
||||
/>
|
||||
<AppBar position="static">
|
||||
<MoonfireMenu
|
||||
session={session}
|
||||
setSession={setSession}
|
||||
requestLogin={() => {
|
||||
setLoginState("user-requested-login");
|
||||
}}
|
||||
logout={logout}
|
||||
/>
|
||||
</AppBar>
|
||||
<Login
|
||||
onSuccess={onLoginSuccess}
|
||||
open={
|
||||
@ -113,6 +120,9 @@ function App() {
|
||||
</p>
|
||||
</Container>
|
||||
)}
|
||||
{cameras != null && cameras.length > 0 && (
|
||||
<List cameras={cameras} timeZoneName={timeZoneName!} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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 (
|
||||
<AppBar position="static">
|
||||
<>
|
||||
<Toolbar variant="dense">
|
||||
<IconButton edge="start" color="inherit" aria-label="menu">
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
aria-label="menu"
|
||||
onClick={props.menuClick}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" className={classes.title}>
|
||||
@ -101,7 +106,7 @@ function MoonfireMenu(props: Props) {
|
||||
</div>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
130
ui/src/List/StreamMultiSelector.tsx
Normal file
130
ui/src/List/StreamMultiSelector.tsx
Normal file
@ -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<Stream>;
|
||||
setSelected: (selected: Set<Stream>) => 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 <Checkbox className={classes.check} disabled />;
|
||||
}
|
||||
return (
|
||||
<Checkbox
|
||||
className={classes.check}
|
||||
size="small"
|
||||
checked={selected.has(s)}
|
||||
onChange={(_, checked: boolean) => setStream(s, checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<tr key={c.uuid}>
|
||||
<td onClick={() => toggleCamera(c)}>{c.shortName}</td>
|
||||
<td>{checkbox("main")}</td>
|
||||
<td>{checkbox("sub")}</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
padding: theme.spacing(1),
|
||||
marginBottom: theme.spacing(2),
|
||||
}}
|
||||
>
|
||||
<table className={classes.streamSelectorTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<td />
|
||||
<td onClick={() => toggleType("main")}>main</td>
|
||||
<td onClick={() => toggleType("sub")}>sub</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{cameraRows}</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default StreamMultiSelector;
|
339
ui/src/List/TimerangeSelector.tsx
Normal file
339
ui/src/List/TimerangeSelector.tsx
Normal file
@ -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<Stream>;
|
||||
timeZoneName: string;
|
||||
range90k: [number, number] | null;
|
||||
setRange90k: (range: [number, number] | null) => void;
|
||||
}
|
||||
|
||||
const MyTimePicker = (
|
||||
props: Pick<TimePickerProps, "value" | "onChange" | "disabled">
|
||||
) => (
|
||||
<TimePicker
|
||||
views={["hours", "minutes", "seconds"]}
|
||||
renderInput={(params) => <TextField size="small" {...params} />}
|
||||
inputFormat="HH:mm:ss"
|
||||
mask="__:__:__"
|
||||
ampm={false}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
/**
|
||||
* Combines the date-part of <tt>dayMillis</tt> and the time part of
|
||||
* <tt>time</tt>. If <tt>time</tt> 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<number>;
|
||||
};
|
||||
|
||||
type EndDayType = "same-day" | "other-day";
|
||||
|
||||
type DaysState = {
|
||||
allowed: AllowedDays | null;
|
||||
|
||||
/** [start, end] in same format as described for <tt>AllowedDays</tt>. */
|
||||
rangeMillis: [number, number] | null;
|
||||
endType: EndDayType;
|
||||
};
|
||||
|
||||
type DaysOpUpdateSelectedStreams = {
|
||||
op: "update-selected-streams";
|
||||
selectedStreams: Set<Stream>;
|
||||
};
|
||||
|
||||
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 <tt>AllowedDays</tt> from the given streams.
|
||||
* Returns null if there are no allowed days.
|
||||
*/
|
||||
function computeAllowedDayInfo(
|
||||
selectedStreams: Set<Stream>
|
||||
): AllowedDays | null {
|
||||
let minMillis = null;
|
||||
let maxMillis = null;
|
||||
let allMillis = new Set<number>();
|
||||
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<any>(
|
||||
new Date("1970-01-01T00:00:00")
|
||||
);
|
||||
const [endTime, setEndTime] = React.useState<any>(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 (
|
||||
<Card sx={{ padding: theme.spacing(1) }}>
|
||||
<div>
|
||||
<FormLabel component="legend">From</FormLabel>
|
||||
<StaticDatePicker
|
||||
displayStaticWrapperAs="desktop"
|
||||
value={startDate}
|
||||
shouldDisableDate={shouldDisableDate}
|
||||
maxDate={
|
||||
days.allowed === null ? today : new Date(days.allowed.maxMillis)
|
||||
}
|
||||
minDate={
|
||||
days.allowed === null ? today : new Date(days.allowed.minMillis)
|
||||
}
|
||||
onChange={(d: Date | null) => {
|
||||
updateDays({ op: "set-start-day", newStartDate: d });
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} variant="outlined" />}
|
||||
/>
|
||||
<MyTimePicker
|
||||
value={startTime}
|
||||
onChange={(newValue) => {
|
||||
console.log("start time onChange", newValue);
|
||||
if (newValue === null || isFinite((newValue as Date).getTime())) {
|
||||
setStartTime(newValue);
|
||||
}
|
||||
}}
|
||||
disabled={days.allowed === null}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel component="legend">To</FormLabel>
|
||||
<RadioGroup
|
||||
row
|
||||
value={days.endType}
|
||||
onChange={(e) => {
|
||||
updateDays({
|
||||
op: "set-end-type",
|
||||
newEndType: e.target.value as EndDayType,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormControlLabel
|
||||
value="same-day"
|
||||
control={<Radio size="small" />}
|
||||
label="Same day"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="other-day"
|
||||
control={<Radio size="small" />}
|
||||
label="Other day"
|
||||
/>
|
||||
</RadioGroup>
|
||||
<StaticDatePicker
|
||||
displayStaticWrapperAs="desktop"
|
||||
value={endDate}
|
||||
shouldDisableDate={(d: Date | null) =>
|
||||
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) => <TextField {...params} variant="outlined" />}
|
||||
/>
|
||||
<MyTimePicker
|
||||
value={endTime}
|
||||
onChange={(newValue) => {
|
||||
if (newValue === null || isFinite((newValue as Date).getTime())) {
|
||||
setEndTime(newValue);
|
||||
}
|
||||
}}
|
||||
disabled={days.allowed === null}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimerangeSelector;
|
118
ui/src/List/VideoList.tsx
Normal file
118
ui/src/List/VideoList.tsx
Normal file
@ -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 <tt>TableBody</tt> with a list of videos for a given
|
||||
* <tt>stream</tt> and <tt>range90k</tt>.
|
||||
*
|
||||
* The parent is responsible for creating the greater table.
|
||||
*
|
||||
* When one is clicked, calls <tt>setActiveRecording</tt>.
|
||||
*/
|
||||
const VideoList = ({
|
||||
stream,
|
||||
range90k,
|
||||
setActiveRecording,
|
||||
formatTime,
|
||||
}: Props) => {
|
||||
const snackbars = useSnackbars();
|
||||
const [
|
||||
response,
|
||||
setResponse,
|
||||
] = React.useState<api.FetchResult<api.RecordingsResponse> | 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 = (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
<CircularProgress />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
} else if (response.status === "error") {
|
||||
body = (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>Error: {response.status}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
} 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 (
|
||||
<TableRow
|
||||
key={r.startId}
|
||||
onClick={() => setActiveRecording([stream, r])}
|
||||
>
|
||||
<TableCell>{formatTime(r.startTime90k)}</TableCell>
|
||||
<TableCell>{formatTime(r.endTime90k)}</TableCell>
|
||||
<TableCell>
|
||||
{vse.width}x{vse.height}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{frameRateFmt.format(r.videoSamples / durationSec)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{sizeFmt.format(r.sampleFileBytes / 1048576)} MiB
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{sizeFmt.format((r.sampleFileBytes / durationSec) * 0.000008)} Mbps
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
}
|
||||
return <TableBody>{body}</TableBody>;
|
||||
};
|
||||
|
||||
export default VideoList;
|
165
ui/src/List/index.tsx
Normal file
165
ui/src/List/index.tsx
Normal file
@ -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 <tt>Stream</tt> object, which means it will
|
||||
* not be stable across top-level API fetches. Maybe an id would be better.
|
||||
*/
|
||||
const [selectedStreams, setSelectedStreams] = useState<Set<Stream>>(
|
||||
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(
|
||||
<React.Fragment key={`${s.camera.uuid}-${s.streamType}`}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className={classes.camera}>
|
||||
{s.camera.shortName} {s.streamType}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>start</TableCell>
|
||||
<TableCell>end</TableCell>
|
||||
<TableCell>resolution</TableCell>
|
||||
<TableCell>fps</TableCell>
|
||||
<TableCell>storage</TableCell>
|
||||
<TableCell>bitrate</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<VideoList
|
||||
stream={s}
|
||||
range90k={range90k}
|
||||
setActiveRecording={setActiveRecording}
|
||||
formatTime={formatTime}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
const closeModal = (event: {}, reason: string) => {
|
||||
console.log("closeModal", reason);
|
||||
setActiveRecording(null);
|
||||
};
|
||||
const recordingsTable = (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small" className={classes.videoTable}>
|
||||
{videoLists}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div className={classes.selectors}>
|
||||
<StreamMultiSelector
|
||||
cameras={cameras}
|
||||
selected={selectedStreams}
|
||||
setSelected={setSelectedStreams}
|
||||
/>
|
||||
<TimerangeSelector
|
||||
selectedStreams={selectedStreams}
|
||||
range90k={range90k}
|
||||
setRange90k={setRange90k}
|
||||
timeZoneName={timeZoneName}
|
||||
/>
|
||||
</div>
|
||||
{videoLists.length > 0 && recordingsTable}
|
||||
{activeRecording != null && (
|
||||
<Modal open onClose={closeModal}>
|
||||
<video
|
||||
controls
|
||||
preload="auto"
|
||||
autoPlay
|
||||
className={classes.video}
|
||||
src={api.recordingUrl(
|
||||
activeRecording[0].camera.uuid,
|
||||
activeRecording[0].streamType,
|
||||
activeRecording[1]
|
||||
)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Main;
|
117
ui/src/api.ts
117
ui/src/api.ts
@ -13,12 +13,14 @@
|
||||
|
||||
import { Camera, Session } from "./types";
|
||||
|
||||
interface FetchSuccess<T> {
|
||||
export type StreamType = "main" | "sub";
|
||||
|
||||
export interface FetchSuccess<T> {
|
||||
status: "success";
|
||||
response: T;
|
||||
}
|
||||
|
||||
interface FetchAborted {
|
||||
export interface FetchAborted {
|
||||
status: "aborted";
|
||||
}
|
||||
|
||||
@ -28,7 +30,7 @@ export interface FetchError {
|
||||
httpStatus?: number;
|
||||
}
|
||||
|
||||
type FetchResult<T> = FetchSuccess<T> | FetchAborted | FetchError;
|
||||
export type FetchResult<T> = FetchSuccess<T> | FetchAborted | FetchError;
|
||||
|
||||
async function myfetch(
|
||||
url: string,
|
||||
@ -124,21 +126,31 @@ async function json<T>(
|
||||
};
|
||||
}
|
||||
|
||||
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<ToplevelResponse>("/api/", init);
|
||||
const resp = await json<ToplevelResponse>("/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<RecordingsResponse>(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,
|
||||
});
|
||||
}
|
||||
|
50
ui/src/index.css
Normal file
50
ui/src/index.css
Normal file
@ -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 <DatePicker>'s className applied,
|
||||
* and it doesn't seem to be exposed for a global style override
|
||||
* <https://next.material-ui.com/customization/theme-components/#global-style-overrides>.
|
||||
*/
|
||||
.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;
|
||||
}
|
||||
}
|
@ -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(
|
||||
<CssBaseline />
|
||||
<ThemeProvider theme={theme}>
|
||||
<ErrorBoundary>
|
||||
<SnackbarProvider autoHideDuration={5000}>
|
||||
<App />
|
||||
</SnackbarProvider>
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<SnackbarProvider autoHideDuration={5000}>
|
||||
<App />
|
||||
</SnackbarProvider>
|
||||
</LocalizationProvider>
|
||||
</ErrorBoundary>
|
||||
</ThemeProvider>
|
||||
</StyledEngineProvider>
|
||||
|
@ -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 <tt>design/api.md</tt>.
|
||||
*/
|
||||
|
||||
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<StreamType, Stream>;
|
||||
}
|
||||
|
||||
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<string, Day>;
|
||||
}
|
||||
|
||||
export interface Day {
|
||||
totalDuration90k: number;
|
||||
startTime90k: number;
|
||||
endTime90k: number;
|
||||
}
|
||||
|
@ -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"]
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user