visual improvements to list UI, tests
In particular there are fewer reflows while loading.
This commit is contained in:
parent
1a5e01adef
commit
93b0db4c28
|
@ -59,7 +59,7 @@ const StreamMultiSelector = ({ cameras, selected, setSelected }: Props) => {
|
||||||
if (!foundAny) {
|
if (!foundAny) {
|
||||||
for (const c of cameras) {
|
for (const c of cameras) {
|
||||||
if (c.streams[st] !== undefined) {
|
if (c.streams[st] !== undefined) {
|
||||||
updated.add(c.streams[st]);
|
updated.add(c.streams[st as StreamType]!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -69,7 +69,7 @@ const StreamMultiSelector = ({ cameras, selected, setSelected }: Props) => {
|
||||||
const updated = new Set(selected);
|
const updated = new Set(selected);
|
||||||
let foundAny = false;
|
let foundAny = false;
|
||||||
for (const st in c.streams) {
|
for (const st in c.streams) {
|
||||||
const s = c.streams[st as StreamType];
|
const s = c.streams[st as StreamType]!;
|
||||||
if (selected.has(s)) {
|
if (selected.has(s)) {
|
||||||
updated.delete(s);
|
updated.delete(s);
|
||||||
foundAny = true;
|
foundAny = true;
|
||||||
|
@ -77,7 +77,7 @@ const StreamMultiSelector = ({ cameras, selected, setSelected }: Props) => {
|
||||||
}
|
}
|
||||||
if (!foundAny) {
|
if (!foundAny) {
|
||||||
for (const st in c.streams) {
|
for (const st in c.streams) {
|
||||||
updated.add(c.streams[st as StreamType]);
|
updated.add(c.streams[st as StreamType]!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSelected(updated);
|
setSelected(updated);
|
||||||
|
|
|
@ -0,0 +1,176 @@
|
||||||
|
// 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 { screen } from "@testing-library/react";
|
||||||
|
import { utcToZonedTime } from "date-fns-tz";
|
||||||
|
import format from "date-fns/format";
|
||||||
|
import { rest } from "msw";
|
||||||
|
import { setupServer } from "msw/node";
|
||||||
|
import { Recording, VideoSampleEntry } from "../api";
|
||||||
|
import { renderWithCtx } from "../testutil";
|
||||||
|
import { Camera, Stream } from "../types";
|
||||||
|
import VideoList from "./VideoList";
|
||||||
|
|
||||||
|
const TEST_CAMERA: Camera = {
|
||||||
|
uuid: "c7278ba0-a001-420c-911e-fff4e33f6916",
|
||||||
|
shortName: "test-camera",
|
||||||
|
description: "",
|
||||||
|
streams: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_STREAM: Stream = {
|
||||||
|
camera: TEST_CAMERA,
|
||||||
|
streamType: "main",
|
||||||
|
retainBytes: 0,
|
||||||
|
minStartTime90k: 0,
|
||||||
|
maxEndTime90k: 0,
|
||||||
|
totalDuration90k: 0,
|
||||||
|
totalSampleFileBytes: 0,
|
||||||
|
fsBytes: 0,
|
||||||
|
days: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_RANGE1: [number, number] = [
|
||||||
|
145747836000000, // 2021-04-26T00:00:00:00000-07:00
|
||||||
|
145755612000000, // 2021-04-27T00:00:00:00000-07:00
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEST_RANGE2: [number, number] = [
|
||||||
|
145755612000000, // 2021-04-27T00:00:00:00000-07:00
|
||||||
|
145763388000000, // 2021-04-28T00:00:00:00000-07:00
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEST_RECORDINGS1: Recording[] = [
|
||||||
|
{
|
||||||
|
startId: 42,
|
||||||
|
openId: 1,
|
||||||
|
startTime90k: 145750542570000, // 2021-04-26T08:21:13:00000-07:00
|
||||||
|
endTime90k: 145750548150000, // 2021-04-26T08:22:15:00000-07:00
|
||||||
|
videoSampleEntryId: 4,
|
||||||
|
videoSamples: 1860,
|
||||||
|
sampleFileBytes: 248000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEST_RECORDINGS2: Recording[] = [
|
||||||
|
{
|
||||||
|
startId: 42,
|
||||||
|
openId: 1,
|
||||||
|
startTime90k: 145757651670000, // 2021-04-27T06:17:43:00000-07:00
|
||||||
|
endTime90k: 145757656980000, // 2021-04-27T06:18:42:00000-07:00
|
||||||
|
videoSampleEntryId: 4,
|
||||||
|
videoSamples: 1860,
|
||||||
|
sampleFileBytes: 248000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEST_VIDEO_SAMPLE_ENTRIES: { [id: number]: VideoSampleEntry } = {
|
||||||
|
4: {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function TestFormat(time90k: number) {
|
||||||
|
return format(
|
||||||
|
utcToZonedTime(new Date(time90k / 90), "America/Los_Angeles"),
|
||||||
|
"d MMM yyyy HH:mm:ss"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer(
|
||||||
|
rest.get("/api/cameras/:camera/:streamType/recordings", (req, res, ctx) => {
|
||||||
|
const p = req.url.searchParams;
|
||||||
|
const range90k = [
|
||||||
|
parseInt(p.get("startTime90k")!, 10),
|
||||||
|
parseInt(p.get("endTime90k")!, 10),
|
||||||
|
];
|
||||||
|
if (range90k[0] === 42) {
|
||||||
|
return res(ctx.status(503), ctx.text("server error"));
|
||||||
|
}
|
||||||
|
if (range90k[0] === TEST_RANGE1[0]) {
|
||||||
|
return res(
|
||||||
|
ctx.json({
|
||||||
|
recordings: TEST_RECORDINGS1,
|
||||||
|
videoSampleEntries: TEST_VIDEO_SAMPLE_ENTRIES,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return res(
|
||||||
|
ctx.delay(2000), // 2 second delay
|
||||||
|
ctx.json({
|
||||||
|
recordings: TEST_RECORDINGS2,
|
||||||
|
videoSampleEntries: TEST_VIDEO_SAMPLE_ENTRIES,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||||
|
afterEach(() => server.resetHandlers());
|
||||||
|
afterAll(() => server.close());
|
||||||
|
|
||||||
|
test("load", async () => {
|
||||||
|
renderWithCtx(
|
||||||
|
<table>
|
||||||
|
<VideoList
|
||||||
|
stream={TEST_STREAM}
|
||||||
|
range90k={TEST_RANGE1}
|
||||||
|
setActiveRecording={() => {}}
|
||||||
|
formatTime={TestFormat}
|
||||||
|
/>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
expect(await screen.findByText(/26 Apr 2021 08:21:13/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// This test may be slightly flaky because it uses real timers. It looks like
|
||||||
|
// msw specifically avoids using test timers:
|
||||||
|
// https://github.com/mswjs/msw/pull/243
|
||||||
|
test("slow replace", async () => {
|
||||||
|
const { rerender } = renderWithCtx(
|
||||||
|
<table>
|
||||||
|
<VideoList
|
||||||
|
stream={TEST_STREAM}
|
||||||
|
range90k={TEST_RANGE1}
|
||||||
|
setActiveRecording={() => {}}
|
||||||
|
formatTime={TestFormat}
|
||||||
|
/>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
expect(await screen.findByText(/26 Apr 2021 08:21:13/)).toBeInTheDocument();
|
||||||
|
rerender(
|
||||||
|
<table>
|
||||||
|
<VideoList
|
||||||
|
stream={TEST_STREAM}
|
||||||
|
range90k={TEST_RANGE2}
|
||||||
|
setActiveRecording={() => {}}
|
||||||
|
formatTime={TestFormat}
|
||||||
|
/>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
|
||||||
|
// The first results don't go away immediately.
|
||||||
|
expect(screen.getByText(/26 Apr 2021 08:21:13/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// A loading indicator should show up after a second.
|
||||||
|
expect(await screen.findByRole("progressbar")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Then the second query result should show up.
|
||||||
|
expect(await screen.findByText(/27 Apr 2021 06:17:43/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("error", async () => {
|
||||||
|
renderWithCtx(
|
||||||
|
<table>
|
||||||
|
<VideoList
|
||||||
|
stream={TEST_STREAM}
|
||||||
|
range90k={[42, 64]}
|
||||||
|
setActiveRecording={() => {}}
|
||||||
|
formatTime={TestFormat}
|
||||||
|
/>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent(/server error/);
|
||||||
|
});
|
|
@ -2,14 +2,15 @@
|
||||||
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
// 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
|
// 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 React from "react";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
import { useSnackbars } from "../snackbars";
|
import { useSnackbars } from "../snackbars";
|
||||||
import { Stream } from "../types";
|
import { Stream } from "../types";
|
||||||
import TableBody from "@material-ui/core/TableBody";
|
import TableBody from "@material-ui/core/TableBody";
|
||||||
import TableCell from "@material-ui/core/TableCell";
|
import TableCell from "@material-ui/core/TableCell";
|
||||||
import TableRow from "@material-ui/core/TableRow";
|
import TableRow, { TableRowProps } from "@material-ui/core/TableRow";
|
||||||
|
import Skeleton from "@material-ui/core/Skeleton";
|
||||||
|
import Alert from "@material-ui/core/Alert";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
stream: Stream;
|
stream: Stream;
|
||||||
|
@ -26,13 +27,60 @@ const sizeFmt = new Intl.NumberFormat([], {
|
||||||
maximumFractionDigits: 1,
|
maximumFractionDigits: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
/**
|
||||||
|
* The range to display.
|
||||||
|
* During loading, this can differ from the requested range.
|
||||||
|
*/
|
||||||
|
range90k: [number, number];
|
||||||
|
response: { status: "skeleton" } | api.FetchResult<api.RecordingsResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowProps extends TableRowProps {
|
||||||
|
start: React.ReactNode;
|
||||||
|
end: React.ReactNode;
|
||||||
|
resolution: React.ReactNode;
|
||||||
|
fps: React.ReactNode;
|
||||||
|
storage: React.ReactNode;
|
||||||
|
bitrate: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Row = ({
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
resolution,
|
||||||
|
fps,
|
||||||
|
storage,
|
||||||
|
bitrate,
|
||||||
|
...rest
|
||||||
|
}: RowProps) => (
|
||||||
|
<TableRow {...rest}>
|
||||||
|
<TableCell align="right">{start}</TableCell>
|
||||||
|
<TableCell align="right">{end}</TableCell>
|
||||||
|
<TableCell align="right" className="opt">
|
||||||
|
{resolution}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" className="opt">
|
||||||
|
{fps}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" className="opt">
|
||||||
|
{storage}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">{bitrate}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a <tt>TableBody</tt> with a list of videos for a given
|
* Creates a <tt>TableHeader</tt> and <tt>TableBody</tt> with a list of videos
|
||||||
* <tt>stream</tt> and <tt>range90k</tt>.
|
* for a given <tt>stream</tt> and <tt>range90k</tt>.
|
||||||
|
*
|
||||||
|
* Attempts to minimize reflows while loading. It leaves the existing content
|
||||||
|
* (either nothing or a previous range) for a while before displaying a
|
||||||
|
* skeleton.
|
||||||
*
|
*
|
||||||
* The parent is responsible for creating the greater table.
|
* The parent is responsible for creating the greater table.
|
||||||
*
|
*
|
||||||
* When one is clicked, calls <tt>setActiveRecording</tt>.
|
* When a video is clicked, calls <tt>setActiveRecording</tt>.
|
||||||
*/
|
*/
|
||||||
const VideoList = ({
|
const VideoList = ({
|
||||||
stream,
|
stream,
|
||||||
|
@ -41,89 +89,104 @@ const VideoList = ({
|
||||||
formatTime,
|
formatTime,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const snackbars = useSnackbars();
|
const snackbars = useSnackbars();
|
||||||
const [
|
const [state, setState] = React.useState<State | null>(null);
|
||||||
response,
|
|
||||||
setResponse,
|
|
||||||
] = React.useState<api.FetchResult<api.RecordingsResponse> | null>(null);
|
|
||||||
const [showLoading, setShowLoading] = React.useState(false);
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
const doFetch = async (signal: AbortSignal, range90k: [number, number]) => {
|
const doFetch = async (
|
||||||
|
signal: AbortSignal,
|
||||||
|
timerId: ReturnType<typeof setTimeout>,
|
||||||
|
range90k: [number, number]
|
||||||
|
) => {
|
||||||
const req: api.RecordingsRequest = {
|
const req: api.RecordingsRequest = {
|
||||||
cameraUuid: stream.camera.uuid,
|
cameraUuid: stream.camera.uuid,
|
||||||
stream: stream.streamType,
|
stream: stream.streamType,
|
||||||
startTime90k: range90k[0],
|
startTime90k: range90k[0],
|
||||||
endTime90k: range90k[1],
|
endTime90k: range90k[1],
|
||||||
};
|
};
|
||||||
let r = await api.recordings(req, { signal });
|
let response = await api.recordings(req, { signal });
|
||||||
if (r.status === "success") {
|
if (response.status === "success") {
|
||||||
// Sort recordings in descending order by start time.
|
// Sort recordings in descending order by start time.
|
||||||
r.response.recordings.sort((a, b) => b.startId - a.startId);
|
response.response.recordings.sort((a, b) => b.startId - a.startId);
|
||||||
}
|
}
|
||||||
setResponse(r);
|
clearTimeout(timerId);
|
||||||
|
setState({ range90k, response });
|
||||||
};
|
};
|
||||||
if (range90k !== null) {
|
if (range90k !== null) {
|
||||||
doFetch(abort.signal, range90k);
|
const timerId = setTimeout(
|
||||||
const timeout = setTimeout(() => setShowLoading(true), 1000);
|
() => setState({ range90k, response: { status: "skeleton" } }),
|
||||||
|
1000
|
||||||
|
);
|
||||||
|
doFetch(abort.signal, timerId, range90k);
|
||||||
return () => {
|
return () => {
|
||||||
abort.abort();
|
abort.abort();
|
||||||
clearTimeout(timeout);
|
clearTimeout(timerId);
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
setResponse(null);
|
|
||||||
}
|
}
|
||||||
}, [range90k, snackbars, stream]);
|
}, [range90k, snackbars, stream]);
|
||||||
|
|
||||||
let body = null;
|
if (state === null) {
|
||||||
if (response === null) {
|
return null;
|
||||||
if (showLoading) {
|
}
|
||||||
body = (
|
let body;
|
||||||
<TableRow>
|
if (state.response.status === "skeleton") {
|
||||||
<TableCell colSpan={6}>
|
body = (
|
||||||
<CircularProgress />
|
<Row
|
||||||
</TableCell>
|
role="progressbar"
|
||||||
</TableRow>
|
start={<Skeleton />}
|
||||||
);
|
end={<Skeleton />}
|
||||||
}
|
resolution={<Skeleton />}
|
||||||
} else if (response.status === "error") {
|
fps={<Skeleton />}
|
||||||
|
storage={<Skeleton />}
|
||||||
|
bitrate={<Skeleton />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (state.response.status === "error") {
|
||||||
body = (
|
body = (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6}>Error: {response.status}</TableCell>
|
<TableCell colSpan={6}>
|
||||||
|
<Alert severity="error">{state.response.message}</Alert>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
} else if (response.status === "success") {
|
} else if (state.response.status === "success") {
|
||||||
const resp = response.response;
|
const resp = state.response.response;
|
||||||
body = resp.recordings.map((r: api.Recording) => {
|
body = resp.recordings.map((r: api.Recording) => {
|
||||||
const vse = resp.videoSampleEntries[r.videoSampleEntryId];
|
const vse = resp.videoSampleEntries[r.videoSampleEntryId];
|
||||||
const durationSec = (r.endTime90k - r.startTime90k) / 90000;
|
const durationSec = (r.endTime90k - r.startTime90k) / 90000;
|
||||||
|
const rate = (r.sampleFileBytes / durationSec) * 0.000008;
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<Row
|
||||||
key={r.startId}
|
key={r.startId}
|
||||||
|
className="recording"
|
||||||
onClick={() => setActiveRecording([stream, r])}
|
onClick={() => setActiveRecording([stream, r])}
|
||||||
>
|
start={formatTime(Math.max(r.startTime90k, state.range90k[0]))}
|
||||||
<TableCell align="right">
|
end={formatTime(Math.min(r.endTime90k, state.range90k[1]))}
|
||||||
{formatTime(Math.max(r.startTime90k, range90k![0]))}
|
resolution={`${vse.width}x${vse.height}`}
|
||||||
</TableCell>
|
fps={frameRateFmt.format(r.videoSamples / durationSec)}
|
||||||
<TableCell align="right">
|
storage={`${sizeFmt.format(r.sampleFileBytes / 1048576)} MiB`}
|
||||||
{formatTime(Math.min(r.endTime90k, range90k![1]))}
|
bitrate={`${sizeFmt.format(rate)} Mbps`}
|
||||||
</TableCell>
|
/>
|
||||||
<TableCell className="opt" align="right">
|
|
||||||
{vse.width}x{vse.height}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="opt" align="right">
|
|
||||||
{frameRateFmt.format(r.videoSamples / durationSec)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="opt" align="right">
|
|
||||||
{sizeFmt.format(r.sampleFileBytes / 1048576)} MiB
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="right">
|
|
||||||
{sizeFmt.format((r.sampleFileBytes / durationSec) * 0.000008)} Mbps
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return <TableBody>{body}</TableBody>;
|
return (
|
||||||
|
<TableBody>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="streamHeader">
|
||||||
|
{stream.camera.shortName} {stream.streamType}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<Row
|
||||||
|
start="start"
|
||||||
|
end="end"
|
||||||
|
resolution="resolution"
|
||||||
|
fps="fps"
|
||||||
|
storage="storage"
|
||||||
|
bitrate="bitrate"
|
||||||
|
/>
|
||||||
|
{body}
|
||||||
|
</TableBody>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default VideoList;
|
export default VideoList;
|
||||||
|
|
|
@ -11,10 +11,7 @@ import Modal from "@material-ui/core/Modal";
|
||||||
import format from "date-fns/format";
|
import format from "date-fns/format";
|
||||||
import utcToZonedTime from "date-fns-tz/utcToZonedTime";
|
import utcToZonedTime from "date-fns-tz/utcToZonedTime";
|
||||||
import Table from "@material-ui/core/Table";
|
import Table from "@material-ui/core/Table";
|
||||||
import TableCell from "@material-ui/core/TableCell";
|
|
||||||
import TableContainer from "@material-ui/core/TableContainer";
|
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 Paper from "@material-ui/core/Paper";
|
||||||
import StreamMultiSelector from "./StreamMultiSelector";
|
import StreamMultiSelector from "./StreamMultiSelector";
|
||||||
import TimerangeSelector from "./TimerangeSelector";
|
import TimerangeSelector from "./TimerangeSelector";
|
||||||
|
@ -36,19 +33,20 @@ const useStyles = makeStyles((theme: Theme) => ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
background: "#000",
|
background: "#000",
|
||||||
},
|
},
|
||||||
camera: {
|
|
||||||
background: theme.palette.primary.light,
|
|
||||||
color: theme.palette.primary.contrastText,
|
|
||||||
},
|
|
||||||
videoTable: {
|
videoTable: {
|
||||||
|
flexGrow: 1,
|
||||||
width: "max-content",
|
width: "max-content",
|
||||||
height: "max-content",
|
height: "max-content",
|
||||||
|
"& .streamHeader": {
|
||||||
|
background: theme.palette.primary.light,
|
||||||
|
color: theme.palette.primary.contrastText,
|
||||||
|
},
|
||||||
"& .MuiTableBody-root:not(:last-child):after": {
|
"& .MuiTableBody-root:not(:last-child):after": {
|
||||||
content: "''",
|
content: "''",
|
||||||
display: "block",
|
display: "block",
|
||||||
height: theme.spacing(2),
|
height: theme.spacing(2),
|
||||||
},
|
},
|
||||||
"& tbody tr": {
|
"& tbody .recording": {
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
},
|
},
|
||||||
"& .opt": {
|
"& .opt": {
|
||||||
|
@ -95,35 +93,13 @@ const Main = ({ cameras, timeZoneName }: Props) => {
|
||||||
let videoLists = [];
|
let videoLists = [];
|
||||||
for (const s of selectedStreams) {
|
for (const s of selectedStreams) {
|
||||||
videoLists.push(
|
videoLists.push(
|
||||||
<React.Fragment key={`${s.camera.uuid}-${s.streamType}`}>
|
<VideoList
|
||||||
<TableHead>
|
key={`${s.camera.uuid}-${s.streamType}`}
|
||||||
<TableRow>
|
stream={s}
|
||||||
<TableCell colSpan={6} className={classes.camera}>
|
range90k={range90k}
|
||||||
{s.camera.shortName} {s.streamType}
|
setActiveRecording={setActiveRecording}
|
||||||
</TableCell>
|
formatTime={formatTime}
|
||||||
</TableRow>
|
/>
|
||||||
<TableRow>
|
|
||||||
<TableCell align="right">start</TableCell>
|
|
||||||
<TableCell align="right">end</TableCell>
|
|
||||||
<TableCell className="opt" align="right">
|
|
||||||
resolution
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="opt" align="right">
|
|
||||||
fps
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="opt" align="right">
|
|
||||||
storage
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="right">bitrate</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<VideoList
|
|
||||||
stream={s}
|
|
||||||
range90k={range90k}
|
|
||||||
setActiveRecording={setActiveRecording}
|
|
||||||
formatTime={formatTime}
|
|
||||||
/>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const closeModal = (event: {}, reason: string) => {
|
const closeModal = (event: {}, reason: string) => {
|
||||||
|
|
|
@ -140,7 +140,7 @@ export async function toplevel(init: RequestInit) {
|
||||||
if (resp.status === "success") {
|
if (resp.status === "success") {
|
||||||
resp.response.cameras.forEach((c) => {
|
resp.response.cameras.forEach((c) => {
|
||||||
for (const key in c.streams) {
|
for (const key in c.streams) {
|
||||||
const s = c.streams[key as StreamType];
|
const s = c.streams[key as StreamType]!;
|
||||||
s.camera = c;
|
s.camera = c;
|
||||||
s.streamType = key as StreamType;
|
s.streamType = key as StreamType;
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,7 @@ export interface Camera {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
shortName: string;
|
shortName: string;
|
||||||
description: string;
|
description: string;
|
||||||
streams: Record<StreamType, Stream>;
|
streams: Partial<Record<StreamType, Stream>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Stream {
|
export interface Stream {
|
||||||
|
|
Loading…
Reference in New Issue