first draft of react-based list ui (#111)

This commit is contained in:
Scott Lamb
2021-03-05 16:56:51 -08:00
parent 2677184c58
commit 08c3246982
14 changed files with 1081 additions and 75 deletions

View File

@@ -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!} />
)}
</>
);
}

View File

@@ -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>
</>
);
}

View 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;

View 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
View 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
View 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;

View File

@@ -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
View 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;
}
}

View File

@@ -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>

View File

@@ -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;
}