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" }} >