diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 13bed3c..d0f485b 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -99,7 +99,7 @@ function App() {
path=""
element={
diff --git a/ui/src/List/StreamMultiSelector.tsx b/ui/src/List/StreamMultiSelector.tsx
index 27d95ef..6177c8d 100644
--- a/ui/src/List/StreamMultiSelector.tsx
+++ b/ui/src/List/StreamMultiSelector.tsx
@@ -11,8 +11,8 @@ import { ToplevelResponse } from "../api";
interface Props {
toplevel: ToplevelResponse;
- selected: Set;
- setSelected: (selected: Set) => void;
+ selected: Set;
+ setSelected: (selected: Set) => void;
}
const useStyles = makeStyles({
@@ -42,25 +42,29 @@ const StreamMultiSelector = ({ toplevel, selected, setSelected }: Props) => {
const setStream = (s: Stream, checked: boolean) => {
const updated = new Set(selected);
if (checked) {
- updated.add(s);
+ updated.add(s.id);
} else {
- updated.delete(s);
+ updated.delete(s.id);
}
setSelected(updated);
};
const toggleType = (st: StreamType) => {
let updated = new Set(selected);
let foundAny = false;
- for (const s of selected) {
+ for (const sid of selected) {
+ const s = toplevel.streams.get(sid);
+ if (s === undefined) {
+ continue;
+ }
if (s.streamType === st) {
- updated.delete(s);
+ updated.delete(s.id);
foundAny = true;
}
}
if (!foundAny) {
for (const c of toplevel.cameras) {
if (c.streams[st] !== undefined) {
- updated.add(c.streams[st as StreamType]!);
+ updated.add(c.streams[st as StreamType]!.id);
}
}
}
@@ -71,14 +75,14 @@ const StreamMultiSelector = ({ toplevel, selected, setSelected }: Props) => {
let foundAny = false;
for (const st in c.streams) {
const s = c.streams[st as StreamType]!;
- if (selected.has(s)) {
- updated.delete(s);
+ if (selected.has(s.id)) {
+ updated.delete(s.id);
foundAny = true;
}
}
if (!foundAny) {
for (const st in c.streams) {
- updated.add(c.streams[st as StreamType]!);
+ updated.add(c.streams[st as StreamType]!.id);
}
}
setSelected(updated);
@@ -96,7 +100,7 @@ const StreamMultiSelector = ({ toplevel, selected, setSelected }: Props) => {
setStream(s, event.target.checked)}
/>
diff --git a/ui/src/List/VideoList.test.tsx b/ui/src/List/VideoList.test.tsx
index ff5a390..90fa879 100644
--- a/ui/src/List/VideoList.test.tsx
+++ b/ui/src/List/VideoList.test.tsx
@@ -21,6 +21,7 @@ const TEST_CAMERA: Camera = {
const TEST_STREAM: Stream = {
camera: TEST_CAMERA,
+ id: 1,
streamType: "main",
retainBytes: 0,
minStartTime90k: 0,
diff --git a/ui/src/List/index.tsx b/ui/src/List/index.tsx
index d54163a..be35f08 100644
--- a/ui/src/List/index.tsx
+++ b/ui/src/List/index.tsx
@@ -21,6 +21,7 @@ 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";
const useStyles = makeStyles((theme: Theme) => ({
root: {
@@ -100,25 +101,151 @@ interface Props {
showSelectors: boolean;
}
+/// Parsed URL search parameters.
+interface ParsedSearchParams {
+ selectedStreamIds: Set;
+ split90k: number | undefined;
+ trimStartAndEnd: boolean;
+ timestampTrack: boolean;
+}
+
+/// ParsedSearchParams plus useState-like setters.
+interface ParsedSearchParamsAndSetters extends ParsedSearchParams {
+ setSelectedStreamIds: (selectedStreamIds: Set) => void;
+ setSplit90k: (split90k: number | undefined) => void;
+ setTrimStartAndEnd: (trimStartAndEnd: boolean) => void;
+ setTimestampTrack: (timestampTrack: boolean) => void;
+}
+
+const parseSearchParams = (raw: URLSearchParams): ParsedSearchParams => {
+ let selectedStreamIds = new Set();
+ 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) => {
+ // 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
+): Set => {
+ let streams = new Set();
+ for (const id of ids) {
+ const s = toplevel.streams.get(id);
+ if (s === undefined) {
+ continue;
+ }
+ streams.add(s);
+ }
+ return streams;
+};
+
const Main = ({ toplevel, timeZoneName, showSelectors }: Props) => {
const classes = useStyles();
- /**
- * Selected streams to display and use for selecting time ranges.
- * This currently uses the Stream object, which means it will
- * not be stable across top-level API fetches. Maybe an id would be better.
- */
- const [selectedStreams, setSelectedStreams] = useState>(
- new Set()
- );
+ const {
+ selectedStreamIds,
+ setSelectedStreamIds,
+ split90k,
+ setSplit90k,
+ trimStartAndEnd,
+ setTrimStartAndEnd,
+ timestampTrack,
+ setTimestampTrack,
+ } = useParsedSearchParams();
- /** Selected time range. */
+ // 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);
- const [split90k, setSplit90k] = useState(DEFAULT_DURATION);
-
- const [trimStartAndEnd, setTrimStartAndEnd] = useState(true);
- const [timestampTrack, setTimestampTrack] = useState(false);
+ // TimerangeSelector currently expects a Set. 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
@@ -161,9 +288,9 @@ const Main = ({ toplevel, timeZoneName, showSelectors }: Props) => {
sx={{ display: showSelectors ? "block" : "none" }}
>