mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-03-31 01:33:42 -04:00
This structure (described in https://github.com/scottlamb/moonfire-nvr/issues/202#issue-1161741347) has less activity-specific logic in App.tsx itself and avoids duplicate route handling. This fixes the `No routes matched location "/"` mentioned in #202.
354 lines
10 KiB
TypeScript
354 lines
10 KiB
TypeScript
// 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 Box from "@mui/material/Box";
|
|
import Modal from "@mui/material/Modal";
|
|
import Paper from "@mui/material/Paper";
|
|
import { Theme } from "@mui/material/styles";
|
|
import { makeStyles } from "@mui/styles";
|
|
import Table from "@mui/material/Table";
|
|
import TableContainer from "@mui/material/TableContainer";
|
|
import utcToZonedTime from "date-fns-tz/utcToZonedTime";
|
|
import format from "date-fns/format";
|
|
import React, { useMemo, useReducer, useState } from "react";
|
|
import * as api from "../api";
|
|
import { Stream } from "../types";
|
|
import DisplaySelector, { DEFAULT_DURATION } from "./DisplaySelector";
|
|
import StreamMultiSelector from "./StreamMultiSelector";
|
|
import TimerangeSelector from "./TimerangeSelector";
|
|
import VideoList from "./VideoList";
|
|
import { useLayoutEffect } from "react";
|
|
import { fillAspect } from "../aspect";
|
|
import useResizeObserver from "@react-hook/resize-observer";
|
|
import { useSearchParams } from "react-router-dom";
|
|
import { FrameProps } from "../App";
|
|
import IconButton from "@mui/material/IconButton";
|
|
import FilterList from "@mui/icons-material/FilterList";
|
|
|
|
const useStyles = makeStyles((theme: Theme) => ({
|
|
root: {
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
margin: theme.spacing(2),
|
|
},
|
|
selectors: {
|
|
width: "max-content",
|
|
"& .MuiCard-root": {
|
|
marginRight: theme.spacing(2),
|
|
marginBottom: theme.spacing(2),
|
|
},
|
|
},
|
|
videoTable: {
|
|
flexGrow: 1,
|
|
width: "max-content",
|
|
height: "max-content",
|
|
"& .streamHeader": {
|
|
background: theme.palette.primary.light,
|
|
color: theme.palette.primary.contrastText,
|
|
},
|
|
"& .MuiTableBody-root:not(:last-child):after": {
|
|
content: "''",
|
|
display: "block",
|
|
height: theme.spacing(2),
|
|
},
|
|
"& tbody .recording": {
|
|
cursor: "pointer",
|
|
},
|
|
"& .opt": {
|
|
[theme.breakpoints.down("lg")]: {
|
|
display: "none",
|
|
},
|
|
},
|
|
},
|
|
|
|
// When there's a video modal up, make the content as large as possible
|
|
// without distorting it. Center it in the screen and ensure that the video
|
|
// element only takes up the space actually used by the content, so that
|
|
// clicking outside it will dismiss the modal.
|
|
videoModal: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
"& video": {
|
|
objectFit: "fill",
|
|
},
|
|
},
|
|
}));
|
|
|
|
interface FullScreenVideoProps {
|
|
src: string;
|
|
aspect: [number, number];
|
|
}
|
|
|
|
/**
|
|
* A video sized for the entire document window constrained to aspect ratio.
|
|
* This is particularly helpful for Firefox (89), which doesn't honor the
|
|
* pixel aspect ratio specified in .mp4 files. Thus we need to specify it
|
|
* out-of-band.
|
|
*/
|
|
const FullScreenVideo = ({ src, aspect }: FullScreenVideoProps) => {
|
|
const ref = React.useRef<HTMLVideoElement>(null);
|
|
useLayoutEffect(() => {
|
|
fillAspect(document.body.getBoundingClientRect(), ref, aspect);
|
|
});
|
|
useResizeObserver(document.body, (entry: ResizeObserverEntry) => {
|
|
fillAspect(entry.contentRect, ref, aspect);
|
|
});
|
|
return <video ref={ref} controls preload="auto" autoPlay src={src} />;
|
|
};
|
|
|
|
interface Props {
|
|
timeZoneName: string;
|
|
toplevel: api.ToplevelResponse;
|
|
Frame: (props: FrameProps) => JSX.Element;
|
|
}
|
|
|
|
/// Parsed URL search parameters.
|
|
interface ParsedSearchParams {
|
|
selectedStreamIds: Set<number>;
|
|
split90k: number | undefined;
|
|
trimStartAndEnd: boolean;
|
|
timestampTrack: boolean;
|
|
}
|
|
|
|
/// <tt>ParsedSearchParams</tt> plus <tt>useState</tt>-like setters.
|
|
interface ParsedSearchParamsAndSetters extends ParsedSearchParams {
|
|
setSelectedStreamIds: (selectedStreamIds: Set<number>) => void;
|
|
setSplit90k: (split90k: number | undefined) => void;
|
|
setTrimStartAndEnd: (trimStartAndEnd: boolean) => void;
|
|
setTimestampTrack: (timestampTrack: boolean) => void;
|
|
}
|
|
|
|
const parseSearchParams = (raw: URLSearchParams): ParsedSearchParams => {
|
|
let selectedStreamIds = new Set<number>();
|
|
let split90k = DEFAULT_DURATION;
|
|
let trimStartAndEnd = true;
|
|
let timestampTrack = false;
|
|
for (const [key, value] of raw) {
|
|
switch (key) {
|
|
case "s":
|
|
selectedStreamIds.add(Number.parseInt(value, 10));
|
|
break;
|
|
case "split":
|
|
split90k = value === "inf" ? undefined : Number.parseInt(value, 10);
|
|
break;
|
|
case "trim":
|
|
trimStartAndEnd = value === "true";
|
|
break;
|
|
case "ts":
|
|
timestampTrack = value === "true";
|
|
break;
|
|
}
|
|
}
|
|
return {
|
|
selectedStreamIds,
|
|
split90k,
|
|
trimStartAndEnd,
|
|
timestampTrack,
|
|
};
|
|
};
|
|
|
|
const useParsedSearchParams = (): ParsedSearchParamsAndSetters => {
|
|
const [search, setSearch] = useSearchParams();
|
|
|
|
// This useMemo is necessary to avoid a re-rendering loop caused by each
|
|
// call's selectedStreamIds set having different identity.
|
|
const { selectedStreamIds, split90k, trimStartAndEnd, timestampTrack } =
|
|
useMemo(() => parseSearchParams(search), [search]);
|
|
|
|
const setSelectedStreamIds = (newSelectedStreamIds: Set<number>) => {
|
|
// TODO: check if it's worth suppressing no-ops here.
|
|
search.delete("s");
|
|
for (const id of newSelectedStreamIds) {
|
|
search.append("s", id.toString());
|
|
}
|
|
setSearch(search);
|
|
};
|
|
const setSplit90k = (newSplit90k: number | undefined) => {
|
|
if (newSplit90k === split90k) {
|
|
return;
|
|
} else if (newSplit90k === DEFAULT_DURATION) {
|
|
search.delete("split");
|
|
} else if (newSplit90k === undefined) {
|
|
search.set("split", "inf");
|
|
} else {
|
|
search.set("split", newSplit90k.toString());
|
|
}
|
|
setSearch(search);
|
|
};
|
|
const setTrimStartAndEnd = (newTrimStartAndEnd: boolean) => {
|
|
if (newTrimStartAndEnd === trimStartAndEnd) {
|
|
return;
|
|
} else if (newTrimStartAndEnd === true) {
|
|
search.delete("trim"); // default
|
|
} else {
|
|
search.set("trim", "false");
|
|
}
|
|
setSearch(search);
|
|
};
|
|
const setTimestampTrack = (newTimestampTrack: boolean) => {
|
|
if (newTimestampTrack === timestampTrack) {
|
|
return;
|
|
} else if (newTimestampTrack === false) {
|
|
search.delete("ts"); // default
|
|
} else {
|
|
search.set("ts", "true");
|
|
}
|
|
setSearch(search);
|
|
};
|
|
return {
|
|
selectedStreamIds,
|
|
setSelectedStreamIds,
|
|
split90k,
|
|
setSplit90k,
|
|
trimStartAndEnd,
|
|
setTrimStartAndEnd,
|
|
timestampTrack,
|
|
setTimestampTrack,
|
|
};
|
|
};
|
|
|
|
const calcSelectedStreams = (
|
|
toplevel: api.ToplevelResponse,
|
|
ids: Set<number>
|
|
): Set<Stream> => {
|
|
let streams = new Set<Stream>();
|
|
for (const id of ids) {
|
|
const s = toplevel.streams.get(id);
|
|
if (s === undefined) {
|
|
continue;
|
|
}
|
|
streams.add(s);
|
|
}
|
|
return streams;
|
|
};
|
|
|
|
const Main = ({ toplevel, timeZoneName, Frame }: Props) => {
|
|
const classes = useStyles();
|
|
|
|
const {
|
|
selectedStreamIds,
|
|
setSelectedStreamIds,
|
|
split90k,
|
|
setSplit90k,
|
|
trimStartAndEnd,
|
|
setTrimStartAndEnd,
|
|
timestampTrack,
|
|
setTimestampTrack,
|
|
} = useParsedSearchParams();
|
|
|
|
const [showSelectors, toggleShowSelectors] = useReducer(
|
|
(m: boolean) => !m,
|
|
true
|
|
);
|
|
|
|
// The time range to examine, or null if one hasn't yet been selected. Note
|
|
// this is derived from state held within TimerangeSelector.
|
|
const [range90k, setRange90k] = useState<[number, number] | null>(null);
|
|
|
|
// TimerangeSelector currently expects a Set<Stream>. Memoize one; otherwise
|
|
// we'd get an infinite rerendering loop because the Set identity changes
|
|
// each time.
|
|
const selectedStreams = useMemo(
|
|
() => calcSelectedStreams(toplevel, selectedStreamIds),
|
|
[toplevel, selectedStreamIds]
|
|
);
|
|
|
|
const [activeRecording, setActiveRecording] = useState<
|
|
[Stream, api.Recording, api.VideoSampleEntry] | 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(
|
|
<VideoList
|
|
key={`${s.camera.uuid}-${s.streamType}`}
|
|
stream={s}
|
|
range90k={range90k}
|
|
split90k={split90k}
|
|
trimStartAndEnd={trimStartAndEnd}
|
|
setActiveRecording={setActiveRecording}
|
|
formatTime={formatTime}
|
|
/>
|
|
);
|
|
}
|
|
const closeModal = (event: {}, reason: string) => {
|
|
setActiveRecording(null);
|
|
};
|
|
const recordingsTable = (
|
|
<TableContainer component={Paper} className={classes.videoTable}>
|
|
<Table size="small">{videoLists}</Table>
|
|
</TableContainer>
|
|
);
|
|
return (
|
|
<Frame
|
|
activityMenuPart={
|
|
<IconButton
|
|
aria-label="selectors"
|
|
onClick={toggleShowSelectors}
|
|
color="inherit"
|
|
size="small"
|
|
>
|
|
<FilterList />
|
|
</IconButton>
|
|
}
|
|
>
|
|
<div className={classes.root}>
|
|
<Box
|
|
className={classes.selectors}
|
|
sx={{ display: showSelectors ? "block" : "none" }}
|
|
>
|
|
<StreamMultiSelector
|
|
toplevel={toplevel}
|
|
selected={selectedStreamIds}
|
|
setSelected={setSelectedStreamIds}
|
|
/>
|
|
<TimerangeSelector
|
|
selectedStreams={selectedStreams}
|
|
range90k={range90k}
|
|
setRange90k={setRange90k}
|
|
timeZoneName={timeZoneName}
|
|
/>
|
|
<DisplaySelector
|
|
split90k={split90k}
|
|
setSplit90k={setSplit90k}
|
|
trimStartAndEnd={trimStartAndEnd}
|
|
setTrimStartAndEnd={setTrimStartAndEnd}
|
|
timestampTrack={timestampTrack}
|
|
setTimestampTrack={setTimestampTrack}
|
|
/>
|
|
</Box>
|
|
{videoLists.length > 0 && recordingsTable}
|
|
{activeRecording != null && (
|
|
<Modal open onClose={closeModal} className={classes.videoModal}>
|
|
<FullScreenVideo
|
|
src={api.recordingUrl(
|
|
activeRecording[0].camera.uuid,
|
|
activeRecording[0].streamType,
|
|
activeRecording[1],
|
|
timestampTrack,
|
|
trimStartAndEnd ? range90k! : undefined
|
|
)}
|
|
aspect={[
|
|
activeRecording[2].aspectWidth,
|
|
activeRecording[2].aspectHeight,
|
|
]}
|
|
/>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
</Frame>
|
|
);
|
|
};
|
|
|
|
export default Main;
|