2021-03-26 13:43:04 -07:00
|
|
|
// 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
|
|
|
|
|
2022-10-01 08:39:27 -07:00
|
|
|
import Box from "@mui/material/Box";
|
2021-09-24 10:57:29 -07:00
|
|
|
import Select, { SelectChangeEvent } from "@mui/material/Select";
|
|
|
|
import MenuItem from "@mui/material/MenuItem";
|
2024-04-05 14:41:19 +07:00
|
|
|
import React, { useCallback, useEffect, useReducer } from "react";
|
2021-03-26 13:43:04 -07:00
|
|
|
import { Camera } from "../types";
|
2022-02-04 08:10:46 +01:00
|
|
|
import { useSearchParams } from "react-router-dom";
|
2024-04-05 14:41:19 +07:00
|
|
|
import { IconButton, Tooltip } from "@mui/material";
|
|
|
|
import { Fullscreen } from "@mui/icons-material";
|
2021-03-26 13:43:04 -07:00
|
|
|
|
|
|
|
export interface Layout {
|
|
|
|
className: string;
|
|
|
|
cameras: number;
|
2024-04-05 14:41:19 +07:00
|
|
|
name: string
|
2021-03-26 13:43:04 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
// These class names must match useStyles rules (below).
|
|
|
|
const LAYOUTS: Layout[] = [
|
2024-04-05 14:41:19 +07:00
|
|
|
{ className: "solo", cameras: 1, name: "1" },
|
|
|
|
{ className: "dual", cameras: 2, name: "2" },
|
|
|
|
{ className: "main-plus-five", cameras: 6, name: "Main + 5" },
|
|
|
|
{ className: "two-by-two", cameras: 4, name: "2x2" },
|
|
|
|
{ className: "three-by-three", cameras: 9, name: "3x3" }
|
2021-03-26 13:43:04 -07:00
|
|
|
];
|
|
|
|
const MAX_CAMERAS = 9;
|
|
|
|
|
|
|
|
export interface MultiviewProps {
|
|
|
|
cameras: Camera[];
|
|
|
|
layoutIndex: number;
|
2021-03-29 11:49:33 -07:00
|
|
|
renderCamera: (camera: Camera | null, chooser: JSX.Element) => JSX.Element;
|
2021-03-26 13:43:04 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
export interface MultiviewChooserProps {
|
|
|
|
/// An index into <tt>LAYOUTS</tt>.
|
|
|
|
layoutIndex: number;
|
|
|
|
onChoice: (selectedIndex: number) => void;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Chooses the layout for a Multiview.
|
|
|
|
* Styled for placement in the app menu bar.
|
|
|
|
*/
|
|
|
|
export const MultiviewChooser = (props: MultiviewChooserProps) => {
|
|
|
|
return (
|
|
|
|
<Select
|
|
|
|
id="layout"
|
|
|
|
value={props.layoutIndex}
|
2022-01-31 22:55:14 +01:00
|
|
|
onChange={(e) => {
|
2021-08-10 11:57:16 -07:00
|
|
|
props.onChoice(
|
|
|
|
typeof e.target.value === "string"
|
|
|
|
? parseInt(e.target.value)
|
|
|
|
: e.target.value
|
2022-02-04 08:10:46 +01:00
|
|
|
);
|
2022-01-31 22:55:14 +01:00
|
|
|
}}
|
2021-03-26 13:43:04 -07:00
|
|
|
size="small"
|
|
|
|
sx={{
|
|
|
|
// Hacky attempt to style for the app menu.
|
|
|
|
color: "inherit",
|
|
|
|
"& svg": {
|
|
|
|
color: "inherit",
|
|
|
|
},
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
{LAYOUTS.map((e, i) => (
|
|
|
|
<MenuItem key={e.className} value={i}>
|
2024-04-05 14:41:19 +07:00
|
|
|
{e.name}
|
2021-03-26 13:43:04 -07:00
|
|
|
</MenuItem>
|
|
|
|
))}
|
|
|
|
</Select>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The cameras selected for the multiview.
|
|
|
|
* This is always an array of length <tt>MAX_CAMERAS</tt>; only the first
|
|
|
|
* LAYOUTS[layoutIndex].cameras are currently visible. There are no duplicates;
|
|
|
|
* setting one element to a given camera unsets any others pointing to the same
|
|
|
|
* camera.
|
|
|
|
*/
|
|
|
|
type SelectedCameras = Array<number | null>;
|
|
|
|
|
|
|
|
interface SelectOp {
|
|
|
|
selectedIndex: number;
|
|
|
|
cameraIndex: number | null;
|
|
|
|
}
|
|
|
|
|
|
|
|
function selectedReducer(old: SelectedCameras, op: SelectOp): SelectedCameras {
|
|
|
|
let selected = [...old]; // shallow clone.
|
|
|
|
if (op.cameraIndex !== null) {
|
|
|
|
// de-dupe.
|
|
|
|
for (let i = 0; i < selected.length; i++) {
|
|
|
|
if (selected[i] === op.cameraIndex) {
|
|
|
|
selected[i] = null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
selected[op.selectedIndex] = op.cameraIndex ?? null;
|
|
|
|
return selected;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Presents one or more camera views in one of several layouts.
|
|
|
|
*
|
|
|
|
* The parent should arrange for the multiview's outer div to be as large
|
2021-08-12 13:32:01 -07:00
|
|
|
* as possible.
|
2021-03-26 13:43:04 -07:00
|
|
|
*/
|
|
|
|
const Multiview = (props: MultiviewProps) => {
|
2022-01-31 22:55:14 +01:00
|
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
2022-01-29 19:03:58 +01:00
|
|
|
|
2021-03-26 13:43:04 -07:00
|
|
|
const [selected, updateSelected] = useReducer(
|
|
|
|
selectedReducer,
|
2022-02-04 08:10:46 +01:00
|
|
|
searchParams.has("cams")
|
2024-04-05 14:41:19 +07:00
|
|
|
? JSON.parse(searchParams.get("cams") || "") :
|
|
|
|
localStorage.getItem("camsSelected") !== null ?
|
|
|
|
JSON.parse(localStorage.getItem("camsSelected") || "")
|
2022-02-04 08:10:46 +01:00
|
|
|
: Array(MAX_CAMERAS).fill(null)
|
2021-03-26 13:43:04 -07:00
|
|
|
);
|
2021-08-12 13:32:01 -07:00
|
|
|
|
2024-04-05 14:41:19 +07:00
|
|
|
/**
|
|
|
|
* Save previously selected cameras to local storage.
|
|
|
|
*/
|
|
|
|
useEffect(() => {
|
|
|
|
if (searchParams.has("cams")) localStorage.setItem("camsSelected", (searchParams.get("cams") || ""));
|
|
|
|
}, [searchParams]);
|
|
|
|
|
2022-01-31 22:55:14 +01:00
|
|
|
const outerRef = React.useRef<HTMLDivElement>(null);
|
2021-03-26 13:43:04 -07:00
|
|
|
const layout = LAYOUTS[props.layoutIndex];
|
2022-01-31 22:55:14 +01:00
|
|
|
|
2024-04-05 14:41:19 +07:00
|
|
|
/**
|
|
|
|
* Toggle full screen.
|
|
|
|
*/
|
|
|
|
const handleFullScreen = useCallback(() => {
|
|
|
|
if (outerRef.current) {
|
|
|
|
const elem = outerRef.current;
|
2024-04-13 22:45:57 +07:00
|
|
|
if (document.fullscreenElement) {
|
2024-04-05 14:41:19 +07:00
|
|
|
if (document.exitFullscreen) {
|
|
|
|
document.exitFullscreen();
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if (elem.requestFullscreen) {
|
|
|
|
elem.requestFullscreen();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}, [outerRef]);
|
|
|
|
|
|
|
|
|
2021-03-26 13:43:04 -07:00
|
|
|
const monoviews = selected.slice(0, layout.cameras).map((e, i) => {
|
|
|
|
// When a camera is selected, use the camera's index as the key.
|
|
|
|
// This allows swapping cameras' positions without tearing down their
|
|
|
|
// WebSocket connections and buffers.
|
|
|
|
//
|
|
|
|
// When no camera is selected, use the index within selected. (Actually,
|
2021-08-31 16:20:24 -07:00
|
|
|
// -1 minus the index, to disambiguate between the two cases.)
|
|
|
|
const key = e ?? -1 - i;
|
2022-01-31 22:55:14 +01:00
|
|
|
|
2021-03-26 13:43:04 -07:00
|
|
|
return (
|
|
|
|
<Monoview
|
|
|
|
key={key}
|
|
|
|
cameras={props.cameras}
|
|
|
|
cameraIndex={e}
|
|
|
|
renderCamera={props.renderCamera}
|
2022-01-31 22:55:14 +01:00
|
|
|
onSelect={(cameraIndex) => {
|
2022-02-04 08:10:46 +01:00
|
|
|
updateSelected({ selectedIndex: i, cameraIndex });
|
|
|
|
searchParams.set(
|
|
|
|
"cams",
|
|
|
|
JSON.stringify(
|
|
|
|
selectedReducer(selected, { selectedIndex: i, cameraIndex })
|
|
|
|
)
|
|
|
|
);
|
|
|
|
setSearchParams(searchParams);
|
2022-01-31 22:55:14 +01:00
|
|
|
}}
|
2021-03-26 13:43:04 -07:00
|
|
|
/>
|
|
|
|
);
|
|
|
|
});
|
2022-01-31 22:55:14 +01:00
|
|
|
|
2021-03-26 13:43:04 -07:00
|
|
|
return (
|
2022-10-01 08:39:27 -07:00
|
|
|
<Box
|
|
|
|
ref={outerRef}
|
|
|
|
sx={{
|
|
|
|
flex: "1 0 0",
|
|
|
|
color: "white",
|
|
|
|
overflow: "hidden",
|
|
|
|
|
|
|
|
// TODO: this mid-level div can probably be removed.
|
|
|
|
"& > .mid": {
|
|
|
|
width: "100%",
|
|
|
|
height: "100%",
|
|
|
|
position: "relative",
|
|
|
|
display: "inline-block",
|
|
|
|
},
|
|
|
|
}}
|
|
|
|
>
|
2024-04-05 14:41:19 +07:00
|
|
|
<Tooltip title="Toggle full screen">
|
|
|
|
<IconButton size="small" sx={{
|
2024-04-13 22:45:57 +07:00
|
|
|
position: 'fixed', background: 'rgba(255,255,255,0.4) !important', transition: '0.2s', opacity: '0.4', bottom: 10, right: 10, zIndex: 9, color: "#fff", ":hover": {
|
2024-04-05 14:41:19 +07:00
|
|
|
opacity: '1'
|
|
|
|
}
|
|
|
|
}} onClick={handleFullScreen}>
|
|
|
|
<Fullscreen />
|
|
|
|
</IconButton>
|
|
|
|
</Tooltip>
|
2021-08-12 13:32:01 -07:00
|
|
|
<div className="mid">
|
2022-10-01 08:39:27 -07:00
|
|
|
<Box
|
|
|
|
className={layout.className}
|
|
|
|
sx={{
|
|
|
|
// match parent's size without influencing it.
|
|
|
|
position: "absolute",
|
|
|
|
width: "100%",
|
|
|
|
height: "100%",
|
|
|
|
|
|
|
|
backgroundColor: "#000",
|
|
|
|
overflow: "hidden",
|
|
|
|
display: "grid",
|
|
|
|
gridGap: "0px",
|
|
|
|
|
|
|
|
// These class names must match LAYOUTS (above).
|
|
|
|
"&.solo": {
|
|
|
|
gridTemplateColumns: "100%",
|
|
|
|
gridTemplateRows: "100%",
|
|
|
|
},
|
2024-04-05 14:41:19 +07:00
|
|
|
"&.dual": {
|
|
|
|
gridTemplateColumns: {
|
|
|
|
xs: "100%",
|
|
|
|
sm: "100%",
|
|
|
|
md: "repeat(2, calc(100% / 2))"
|
|
|
|
},
|
|
|
|
gridTemplateRows: {
|
|
|
|
xs: "50%",
|
|
|
|
sm: "50%",
|
|
|
|
md: "repeat(1, calc(100% / 1))"
|
|
|
|
},
|
|
|
|
},
|
2022-10-01 08:39:27 -07:00
|
|
|
"&.two-by-two": {
|
|
|
|
gridTemplateColumns: "repeat(2, calc(100% / 2))",
|
|
|
|
gridTemplateRows: "repeat(2, calc(100% / 2))",
|
|
|
|
},
|
|
|
|
"&.main-plus-five, &.three-by-three": {
|
|
|
|
gridTemplateColumns: "repeat(3, calc(100% / 3))",
|
|
|
|
gridTemplateRows: "repeat(3, calc(100% / 3))",
|
|
|
|
},
|
2023-01-11 22:25:56 -08:00
|
|
|
"&.main-plus-five > div:nth-of-type(1)": {
|
2022-10-01 08:39:27 -07:00
|
|
|
gridColumn: "span 2",
|
|
|
|
gridRow: "span 2",
|
|
|
|
},
|
|
|
|
}}
|
|
|
|
>
|
2021-03-26 13:43:04 -07:00
|
|
|
{monoviews}
|
2022-10-01 08:39:27 -07:00
|
|
|
</Box>
|
2021-03-26 13:43:04 -07:00
|
|
|
</div>
|
2022-10-01 08:39:27 -07:00
|
|
|
</Box>
|
2021-03-26 13:43:04 -07:00
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
interface MonoviewProps {
|
|
|
|
cameras: Camera[];
|
|
|
|
cameraIndex: number | null;
|
|
|
|
onSelect: (cameraIndex: number | null) => void;
|
2021-03-29 11:49:33 -07:00
|
|
|
renderCamera: (camera: Camera | null, chooser: JSX.Element) => JSX.Element;
|
2021-03-26 13:43:04 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
/** A single pane of a Multiview, including its camera chooser. */
|
|
|
|
const Monoview = (props: MonoviewProps) => {
|
2023-02-13 11:05:27 -08:00
|
|
|
const handleChange = (event: SelectChangeEvent<string>) => {
|
2021-08-10 11:57:16 -07:00
|
|
|
const {
|
|
|
|
target: { value },
|
|
|
|
} = event;
|
2022-01-29 22:36:41 +01:00
|
|
|
|
2023-02-13 11:05:27 -08:00
|
|
|
props.onSelect(value === "null" ? null : parseInt(value));
|
2021-08-10 11:57:16 -07:00
|
|
|
};
|
2022-01-29 19:03:58 +01:00
|
|
|
|
2021-03-29 11:49:33 -07:00
|
|
|
const chooser = (
|
|
|
|
<Select
|
2023-02-13 11:05:27 -08:00
|
|
|
value={props.cameraIndex === null ? "null" : props.cameraIndex.toString()}
|
2021-08-10 11:57:16 -07:00
|
|
|
onChange={handleChange}
|
2021-03-29 11:49:33 -07:00
|
|
|
displayEmpty
|
|
|
|
size="small"
|
|
|
|
sx={{
|
2024-04-05 14:41:19 +07:00
|
|
|
transform: "scale(0.8)",
|
2021-03-29 11:49:33 -07:00
|
|
|
// Restyle to fit over the video (or black).
|
2024-04-05 14:41:19 +07:00
|
|
|
backgroundColor: "rgba(255, 255, 255, 0.3)",
|
2024-04-05 15:03:52 +07:00
|
|
|
color: "#fff",
|
2021-03-29 11:49:33 -07:00
|
|
|
"& svg": {
|
|
|
|
color: "inherit",
|
|
|
|
},
|
|
|
|
}}
|
|
|
|
>
|
2023-02-13 11:05:27 -08:00
|
|
|
<MenuItem value="null">(none)</MenuItem>
|
2021-03-29 11:49:33 -07:00
|
|
|
{props.cameras.map((e, i) => (
|
|
|
|
<MenuItem key={i} value={i}>
|
|
|
|
{e.shortName}
|
|
|
|
</MenuItem>
|
|
|
|
))}
|
|
|
|
</Select>
|
|
|
|
);
|
|
|
|
return props.renderCamera(
|
2022-01-31 22:55:14 +01:00
|
|
|
props.cameraIndex === null ? null : props.cameras[props.cameraIndex],
|
2021-03-29 11:49:33 -07:00
|
|
|
chooser
|
2021-03-26 13:43:04 -07:00
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
export default Multiview;
|