mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-11-28 05:04:15 -05:00
first draft of react-based list ui (#111)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user