Drag to reorder/resize cameras in camera groups (#11279)

* draggable/resizable cameras in camera groups on desktop/tablets

* fix edit button location on tablets

* assume 1rem is 16px
This commit is contained in:
Josh Hawkins 2024-05-07 09:28:10 -05:00 committed by GitHub
parent 08e5c791c8
commit ff2948a76b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 714 additions and 59 deletions

71
web/package-lock.json generated
View File

@ -48,6 +48,7 @@
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-device-detect": "^2.2.3", "react-device-detect": "^2.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-grid-layout": "^1.4.4",
"react-hook-form": "^7.51.3", "react-hook-form": "^7.51.3",
"react-icons": "^5.1.0", "react-icons": "^5.1.0",
"react-konva": "^18.2.10", "react-konva": "^18.2.10",
@ -76,6 +77,7 @@
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"@types/react": "^18.2.79", "@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25", "@types/react-dom": "^18.2.25",
"@types/react-grid-layout": "^1.3.5",
"@types/react-icons": "^3.0.0", "@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10", "@types/react-transition-group": "^4.4.10",
"@types/strftime": "^0.9.8", "@types/strftime": "^0.9.8",
@ -2572,6 +2574,15 @@
"@types/react": "*" "@types/react": "*"
} }
}, },
"node_modules/@types/react-grid-layout": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz",
"integrity": "sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-icons": { "node_modules/@types/react-icons": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/react-icons/-/react-icons-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/react-icons/-/react-icons-3.0.0.tgz",
@ -4392,6 +4403,11 @@
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"dev": true "dev": true
}, },
"node_modules/fast-equals": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg=="
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.2", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
@ -6329,6 +6345,44 @@
"react": "^18.2.0" "react": "^18.2.0"
} }
}, },
"node_modules/react-draggable": {
"version": "4.4.6",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz",
"integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==",
"dependencies": {
"clsx": "^1.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-draggable/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"engines": {
"node": ">=6"
}
},
"node_modules/react-grid-layout": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.4.4.tgz",
"integrity": "sha512-7+Lg8E8O8HfOH5FrY80GCIR1SHTn2QnAYKh27/5spoz+OHhMmEhU/14gIkRzJOtympDPaXcVRX/nT1FjmeOUmQ==",
"dependencies": {
"clsx": "^2.0.0",
"fast-equals": "^4.0.3",
"prop-types": "^15.8.1",
"react-draggable": "^4.4.5",
"react-resizable": "^3.0.5",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-hook-form": { "node_modules/react-hook-form": {
"version": "7.51.3", "version": "7.51.3",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.3.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.3.tgz",
@ -6448,6 +6502,18 @@
} }
} }
}, },
"node_modules/react-resizable": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz",
"integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==",
"dependencies": {
"prop-types": "15.x",
"react-draggable": "^4.0.3"
},
"peerDependencies": {
"react": ">= 16.3"
}
},
"node_modules/react-router": { "node_modules/react-router": {
"version": "6.22.3", "version": "6.22.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.3.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.3.tgz",
@ -6639,6 +6705,11 @@
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true "dev": true
}, },
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.8", "version": "1.22.8",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",

View File

@ -53,6 +53,7 @@
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-device-detect": "^2.2.3", "react-device-detect": "^2.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-grid-layout": "^1.4.4",
"react-hook-form": "^7.51.3", "react-hook-form": "^7.51.3",
"react-icons": "^5.1.0", "react-icons": "^5.1.0",
"react-konva": "^18.2.10", "react-konva": "^18.2.10",
@ -81,6 +82,7 @@
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"@types/react": "^18.2.79", "@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25", "@types/react-dom": "^18.2.25",
"@types/react-grid-layout": "^1.3.5",
"@types/react-icons": "^3.0.0", "@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10", "@types/react-transition-group": "^4.4.10",
"@types/strftime": "^0.9.8", "@types/strftime": "^0.9.8",

View File

@ -59,6 +59,7 @@ import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner"; import { toast } from "sonner";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { ScrollArea, ScrollBar } from "../ui/scroll-area"; import { ScrollArea, ScrollBar } from "../ui/scroll-area";
import { usePersistence } from "@/hooks/use-persistence";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -89,7 +90,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
// groups // groups
const [group, setGroup] = usePersistedOverlayState( const [group, setGroup, deleteGroup] = usePersistedOverlayState(
"cameraGroup", "cameraGroup",
"default" as string, "default" as string,
); );
@ -118,6 +119,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
currentGroups={groups} currentGroups={groups}
activeGroup={group} activeGroup={group}
setGroup={setGroup} setGroup={setGroup}
deleteGroup={deleteGroup}
/> />
<Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}> <Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}>
<div <div
@ -198,6 +200,7 @@ type NewGroupDialogProps = {
currentGroups: [string, CameraGroupConfig][]; currentGroups: [string, CameraGroupConfig][];
activeGroup?: string; activeGroup?: string;
setGroup: (value: string | undefined, replace?: boolean | undefined) => void; setGroup: (value: string | undefined, replace?: boolean | undefined) => void;
deleteGroup: () => void;
}; };
function NewGroupDialog({ function NewGroupDialog({
open, open,
@ -205,6 +208,7 @@ function NewGroupDialog({
currentGroups, currentGroups,
activeGroup, activeGroup,
setGroup, setGroup,
deleteGroup,
}: NewGroupDialogProps) { }: NewGroupDialogProps) {
const { mutate: updateConfig } = useSWR<FrigateConfig>("config"); const { mutate: updateConfig } = useSWR<FrigateConfig>("config");
@ -225,11 +229,16 @@ function NewGroupDialog({
const [editState, setEditState] = useState<"none" | "add" | "edit">("none"); const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [, , , deleteGridLayout] = usePersistence(
`${activeGroup}-draggable-layout`,
);
// callbacks // callbacks
const onDeleteGroup = useCallback( const onDeleteGroup = useCallback(
async (name: string) => { async (name: string) => {
// TODO: reset order on groups when deleting deleteGridLayout();
deleteGroup();
await axios await axios
.put(`config/set?camera_groups.${name}`, { requires_restart: 0 }) .put(`config/set?camera_groups.${name}`, { requires_restart: 0 })
@ -260,7 +269,14 @@ function NewGroupDialog({
setIsLoading(false); setIsLoading(false);
}); });
}, },
[updateConfig, activeGroup, setGroup, setOpen], [
updateConfig,
activeGroup,
setGroup,
setOpen,
deleteGroup,
deleteGridLayout,
],
); );
const onSave = () => { const onSave = () => {
@ -479,7 +495,11 @@ export function CameraGroupEdit({
{ {
message: "Camera group name already exists.", message: "Camera group name already exists.",
}, },
), )
.refine((value: string) => value.toLowerCase() !== "default", {
message: "Invalid camera group name.",
}),
cameras: z.array(z.string()).min(2, { cameras: z.array(z.string()).min(2, {
message: "You must select at least two cameras.", message: "You must select at least two cameras.",
}), }),

View File

@ -1,19 +1,91 @@
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { useEffect } from "react"; import { useCallback, useEffect } from "react";
import { Toaster } from "sonner";
import { toast } from "sonner";
import { Separator } from "../ui/separator";
import { Button } from "../ui/button";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { del as delData } from "idb-keyval";
export default function General() { export default function General() {
const { data: config } = useSWR<FrigateConfig>("config");
const clearStoredLayouts = useCallback(() => {
if (!config) {
return [];
}
Object.entries(config.camera_groups).forEach(async (value) => {
await delData(`${value[0]}-draggable-layout`)
.then(() => {
toast.success(`Cleared stored layout for ${value[0]}`, {
position: "top-center",
});
})
.catch((error) => {
toast.error(
`Failed to clear stored layout: ${error.response.data.message}`,
{ position: "top-center" },
);
});
});
}, [config]);
useEffect(() => { useEffect(() => {
document.title = "General Settings - Frigate"; document.title = "General Settings - Frigate";
}, []); }, []);
return ( return (
<> <>
<Heading as="h2">Settings</Heading> <div className="flex flex-col md:flex-row size-full">
<div className="flex items-center space-x-2 mt-5"> <Toaster position="top-center" closeButton={true} />
<Switch id="lowdata" checked={false} onCheckedChange={() => {}} /> <div className="flex flex-col h-full w-full overflow-y-auto mt-2 md:mt-0 mb-10 md:mb-0 order-last md:order-none md:mr-2 rounded-lg border-secondary-foreground border-[1px] p-2 bg-background_alt">
<Label htmlFor="lowdata">Low Data Mode (this device only)</Label> <Heading as="h3" className="my-2">
General Settings
</Heading>
<div className="flex flex-col w-full space-y-6">
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<div className="text-md">Stored Layouts</div>
<div className="text-sm text-muted-foreground my-2">
<p>
The layout of cameras in a camera group can be
dragged/resized. The positions are stored in your browser's
local storage.
</p>
</div>
</div>
<div className="flex flex-row justify-start items-center gap-2">
<Button onClick={clearStoredLayouts}>Clear All Layouts</Button>
</div>
</div>
<Separator className="flex my-2 bg-secondary" />
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<div className="text-md">Low Data Mode</div>
<div className="text-sm text-muted-foreground my-2">
<p>
Not yet implemented. <em>Default: disabled</em>
</p>
</div>
</div>
<div className="flex flex-row justify-start items-center gap-2">
<Switch
id="lowdata"
checked={false}
onCheckedChange={() => {}}
/>
<Label htmlFor="lowdata">
Low Data Mode (this device only)
</Label>
</div>
</div>
</div>
</div>
</div> </div>
</> </>
); );

View File

@ -33,14 +33,15 @@ export function useOverlayState<S>(
export function usePersistedOverlayState<S extends string>( export function usePersistedOverlayState<S extends string>(
key: string, key: string,
defaultValue: S | undefined = undefined, defaultValue: S | undefined = undefined,
): [S | undefined, (value: S | undefined, replace?: boolean) => void] { ): [
const [persistedValue, setPersistedValue] = usePersistence<S>( S | undefined,
key, (value: S | undefined, replace?: boolean) => void,
defaultValue, () => void,
); ] {
const [persistedValue, setPersistedValue, , deletePersistedValue] =
usePersistence<S>(key, defaultValue);
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const currentLocationState = useMemo(() => location.state, [location]); const currentLocationState = useMemo(() => location.state, [location]);
const setOverlayStateValue = useCallback( const setOverlayStateValue = useCallback(
@ -63,6 +64,7 @@ export function usePersistedOverlayState<S extends string>(
return [ return [
overlayStateValue ?? persistedValue ?? defaultValue, overlayStateValue ?? persistedValue ?? defaultValue,
setOverlayStateValue, setOverlayStateValue,
deletePersistedValue,
]; ];
} }

View File

@ -1,10 +1,11 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { get as getData, set as setData } from "idb-keyval"; import { get as getData, set as setData, del as delData } from "idb-keyval";
type usePersistenceReturn<S> = [ type usePersistenceReturn<S> = [
value: S | undefined, value: S | undefined,
setValue: (value: S | undefined) => void, setValue: (value: S | undefined) => void,
loaded: boolean, loaded: boolean,
deleteValue: () => void,
]; ];
export function usePersistence<S>( export function usePersistence<S>(
@ -26,6 +27,11 @@ export function usePersistence<S>(
[key], [key],
); );
const deleteValue = useCallback(async () => {
await delData(key);
setInternalValue(defaultValue);
}, [key, defaultValue]);
useEffect(() => { useEffect(() => {
setLoaded(false); setLoaded(false);
setInternalValue(defaultValue); setInternalValue(defaultValue);
@ -41,5 +47,5 @@ export function usePersistence<S>(
load(); load();
}, [key, defaultValue, setValue]); }, [key, defaultValue, setValue]);
return [value, setValue, loaded]; return [value, setValue, loaded, deleteValue];
} }

View File

@ -89,6 +89,7 @@ function Live() {
return ( return (
<LiveDashboardView <LiveDashboardView
cameras={cameras} cameras={cameras}
cameraGroup={cameraGroup}
includeBirdseye={includesBirdseye} includeBirdseye={includesBirdseye}
onSelectCamera={setSelectedCameraName} onSelectCamera={setSelectedCameraName}
/> />

View File

@ -0,0 +1,461 @@
import { usePersistence } from "@/hooks/use-persistence";
import {
BirdseyeConfig,
CameraConfig,
FrigateConfig,
} from "@/types/frigateConfig";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Layout, Responsive, WidthProvider } from "react-grid-layout";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
import { LivePlayerMode } from "@/types/live";
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
import { Skeleton } from "@/components/ui/skeleton";
import { useResizeObserver } from "@/hooks/resize-observer";
import { isEqual } from "lodash";
import useSWR from "swr";
import { isSafari } from "react-device-detect";
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
import LivePlayer from "@/components/player/LivePlayer";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { IoClose } from "react-icons/io5";
import { LuMoveDiagonal2 } from "react-icons/lu";
type DraggableGridLayoutProps = {
cameras: CameraConfig[];
cameraGroup: string;
cameraRef: (node: HTMLElement | null) => void;
includeBirdseye: boolean;
onSelectCamera: (camera: string) => void;
windowVisible: boolean;
visibleCameras: string[];
};
export default function DraggableGridLayout({
cameras,
cameraGroup,
cameraRef,
includeBirdseye,
onSelectCamera,
windowVisible,
visibleCameras,
}: DraggableGridLayoutProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
const [gridLayout, setGridLayout, isGridLayoutLoaded] = usePersistence<
Layout[]
>(`${cameraGroup}-draggable-layout`);
const [currentCameras, setCurrentCameras] = useState<CameraConfig[]>();
const [currentIncludeBirdseye, setCurrentIncludeBirdseye] =
useState<boolean>();
const [currentGridLayout, setCurrentGridLayout] = useState<
Layout[] | undefined
>();
const [isEditMode, setIsEditMode] = useState<boolean>(false);
const handleLayoutChange = useCallback(
(currentLayout: Layout[]) => {
if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) {
return;
}
// save layout to idb
setGridLayout(currentLayout);
},
[setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout],
);
const generateLayout = useCallback(() => {
if (!isGridLayoutLoaded) {
return;
}
const cameraNames =
includeBirdseye && birdseyeConfig?.enabled
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
: cameras.map((camera) => camera?.name || "");
const optionsMap: Layout[] = currentGridLayout
? currentGridLayout.filter((layout) => cameraNames?.includes(layout.i))
: [];
cameraNames.forEach((cameraName, index) => {
const existingLayout = optionsMap.find(
(layout) => layout.i === cameraName,
);
// Skip if the camera already exists in the layout
if (existingLayout) {
return;
}
let aspectRatio;
let col;
// Handle "birdseye" camera as a special case
if (cameraName === "birdseye") {
aspectRatio =
(birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1);
col = 0; // Set birdseye camera in the first column
} else {
const camera = cameras.find((cam) => cam.name === cameraName);
aspectRatio =
(camera && camera?.detect.width / camera?.detect.height) || 16 / 9;
col = index % 3; // Regular cameras distributed across columns
}
// Calculate layout options based on aspect ratio
const columnsPerPlayer = 4;
let height;
let width;
if (aspectRatio < 1) {
// Portrait
height = 2 * columnsPerPlayer;
width = columnsPerPlayer;
} else if (aspectRatio > 2) {
// Wide
height = 1 * columnsPerPlayer;
width = 2 * columnsPerPlayer;
} else {
// Landscape
height = 1 * columnsPerPlayer;
width = columnsPerPlayer;
}
const options = {
i: cameraName,
x: col * width,
y: 0, // don't set y, grid does automatically
w: width,
h: height,
isDraggable: isEditMode,
isResizable: isEditMode,
};
optionsMap.push(options);
});
return optionsMap;
}, [
cameras,
isEditMode,
isGridLayoutLoaded,
currentGridLayout,
includeBirdseye,
birdseyeConfig,
]);
const toggleEditMode = useCallback(() => {
if (currentGridLayout) {
const updatedGridLayout = currentGridLayout.map((layout) => ({
...layout,
isDraggable: !isEditMode,
isResizable: !isEditMode,
}));
if (isEditMode) {
setGridLayout(updatedGridLayout);
setCurrentGridLayout(updatedGridLayout);
} else {
setGridLayout(updatedGridLayout);
}
setIsEditMode((prevIsEditMode) => !prevIsEditMode);
}
}, [currentGridLayout, isEditMode, setGridLayout]);
useEffect(() => {
if (isGridLayoutLoaded) {
if (gridLayout) {
// set current grid layout from loaded
setCurrentGridLayout(gridLayout);
} else {
// idb is empty, set it with an initial layout
setGridLayout(generateLayout());
}
}
}, [
isEditMode,
gridLayout,
currentGridLayout,
setGridLayout,
isGridLayoutLoaded,
generateLayout,
]);
useEffect(() => {
if (
!isEqual(cameras, currentCameras) ||
includeBirdseye !== currentIncludeBirdseye
) {
setCurrentCameras(cameras);
setCurrentIncludeBirdseye(includeBirdseye);
// set new grid layout in idb
setGridLayout(generateLayout());
}
}, [
cameras,
includeBirdseye,
currentCameras,
currentIncludeBirdseye,
setCurrentGridLayout,
generateLayout,
setGridLayout,
isGridLayoutLoaded,
]);
const gridContainerRef = useRef<HTMLDivElement>(null);
const [{ width: containerWidth }] = useResizeObserver(gridContainerRef);
const cellHeight = useMemo(() => {
const aspectRatio = 16 / 9;
const totalMarginWidth = 11 * 13; // 11 margins with 13px each
const rowHeight =
((containerWidth ?? window.innerWidth) - totalMarginWidth) /
(13 * aspectRatio);
return rowHeight;
}, [containerWidth]);
return (
<>
{!isGridLayoutLoaded || !currentGridLayout ? (
<div className="mt-2 px-2 grid grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4 gap-2 md:gap-4">
{includeBirdseye && birdseyeConfig?.enabled && (
<Skeleton className="size-full rounded-2xl" />
)}
{cameras.map((camera) => {
return (
<Skeleton
key={camera.name}
className="aspect-video size-full rounded-2xl"
/>
);
})}
</div>
) : (
<div
className="my-2 px-2 pb-8 no-scrollbar overflow-x-hidden"
ref={gridContainerRef}
>
<ResponsiveGridLayout
className="grid-layout"
layouts={{
lg: currentGridLayout,
md: currentGridLayout,
sm: currentGridLayout,
xs: currentGridLayout,
xxs: currentGridLayout,
}}
rowHeight={cellHeight}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 12, sm: 12, xs: 12, xxs: 12 }}
margin={[16, 16]}
containerPadding={[8, 8]}
resizeHandles={["sw", "nw", "se", "ne"]}
onDragStop={handleLayoutChange}
onResizeStop={handleLayoutChange}
>
{includeBirdseye && birdseyeConfig?.enabled && (
<BirdseyeLivePlayerGridItem
key="birdseye"
className={`${isEditMode ? "outline outline-2 hover:outline-4 outline-muted-foreground hover:cursor-grab active:cursor-grabbing" : ""}`}
birdseyeConfig={birdseyeConfig}
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
onClick={() => onSelectCamera("birdseye")}
>
{isEditMode && (
<>
<div className="absolute top-[-6px] left-[-6px] z-50 size-3 p-2 rounded-full bg-primary-variant outline-2 outline-muted text-background pointer-events-none" />
<div className="absolute top-[-6px] right-[-6px] z-50 size-3 p-2 rounded-full bg-primary-variant outline-2 outline-muted text-background pointer-events-none" />
<div className="absolute bottom-[-6px] right-[-6px] z-50 size-3 p-2 rounded-full bg-primary-variant outline-2 outline-muted text-background pointer-events-none" />
<div className="absolute bottom-[-6px] left-[-6px] z-50 size-3 p-2 rounded-full bg-primary-variant outline-2 outline-muted text-background pointer-events-none" />
</>
)}
</BirdseyeLivePlayerGridItem>
)}
{cameras.map((camera) => {
let grow;
const aspectRatio = camera.detect.width / camera.detect.height;
if (aspectRatio > ASPECT_WIDE_LAYOUT) {
grow = `aspect-wide`;
} else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) {
grow = `aspect-tall`;
} else {
grow = "aspect-video";
}
return (
<LivePlayerGridItem
key={camera.name}
cameraRef={cameraRef}
className={`${grow} size-full rounded-lg md:rounded-2xl bg-black ${isEditMode ? "outline-2 hover:outline-4 outline-muted-foreground hover:cursor-grab active:cursor-grabbing" : ""}`}
windowVisible={
windowVisible && visibleCameras.includes(camera.name)
}
cameraConfig={camera}
preferredLiveMode={isSafari ? "webrtc" : "mse"}
onClick={() => {
!isEditMode && onSelectCamera(camera.name);
}}
>
{isEditMode && (
<>
<div className="absolute top-[-6px] left-[-6px] z-50 size-3 p-2 rounded-full bg-primary-variant outline-2 outline-muted text-background pointer-events-none" />
<div className="absolute top-[-6px] right-[-6px] z-50 size-3 p-2 rounded-full bg-primary-variant outline-2 outline-muted text-background pointer-events-none" />
<div className="absolute bottom-[-6px] right-[-6px] z-50 size-3 p-2 rounded-full bg-primary-variant outline-2 outline-muted text-background pointer-events-none" />
<div className="absolute bottom-[-6px] left-[-6px] z-50 size-3 p-2 rounded-full bg-primary-variant outline-2 outline-muted text-background pointer-events-none" />
</>
)}
</LivePlayerGridItem>
);
})}
</ResponsiveGridLayout>
<div className="flex flex-row gap-2 items-center text-primary">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
className="fixed bottom-12 lg:bottom-9 right-5 z-50 h-12 w-12 p-0 rounded-full opacity-30 hover:opacity-100 transition-all duration-300"
onClick={toggleEditMode}
>
{isEditMode ? (
<IoClose className="size-5" />
) : (
<LuMoveDiagonal2 className="size-5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left">
{isEditMode ? "Exit Editing" : "Edit Layout"}
</TooltipContent>
</Tooltip>
</div>
</div>
)}
</>
);
}
type BirdseyeLivePlayerGridItemProps = {
style?: React.CSSProperties;
className?: string;
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
children?: React.ReactNode;
birdseyeConfig: BirdseyeConfig;
liveMode: LivePlayerMode;
onClick: () => void;
};
const BirdseyeLivePlayerGridItem = React.forwardRef<
HTMLDivElement,
BirdseyeLivePlayerGridItemProps
>(
(
{
style,
className,
onMouseDown,
onMouseUp,
onTouchEnd,
children,
birdseyeConfig,
liveMode,
onClick,
...props
},
ref,
) => {
return (
<div
style={{ ...style }}
ref={ref}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onTouchEnd={onTouchEnd}
{...props}
>
<BirdseyeLivePlayer
className={className}
birdseyeConfig={birdseyeConfig}
liveMode={liveMode}
onClick={onClick}
/>
{children}
</div>
);
},
);
type LivePlayerGridItemProps = {
style?: React.CSSProperties;
className: string;
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
children?: React.ReactNode;
cameraRef: (node: HTMLElement | null) => void;
windowVisible: boolean;
cameraConfig: CameraConfig;
preferredLiveMode: LivePlayerMode;
onClick: () => void;
};
const LivePlayerGridItem = React.forwardRef<
HTMLDivElement,
LivePlayerGridItemProps
>(
(
{
style,
className,
onMouseDown,
onMouseUp,
onTouchEnd,
children,
cameraRef,
windowVisible,
cameraConfig,
preferredLiveMode,
onClick,
...props
},
ref,
) => {
return (
<div
style={{ ...style }}
ref={ref}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onTouchEnd={onTouchEnd}
{...props}
>
<LivePlayer
cameraRef={cameraRef}
className={className}
windowVisible={windowVisible}
cameraConfig={cameraConfig}
preferredLiveMode={preferredLiveMode}
onClick={onClick}
/>
{children}
</div>
);
},
);

View File

@ -12,16 +12,24 @@ import { usePersistence } from "@/hooks/use-persistence";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { ReviewSegment } from "@/types/review"; import { ReviewSegment } from "@/types/review";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isDesktop, isMobile, isSafari } from "react-device-detect"; import {
isDesktop,
isMobile,
isMobileOnly,
isSafari,
} from "react-device-detect";
import useSWR from "swr"; import useSWR from "swr";
import DraggableGridLayout from "./DraggableGridLayout";
type LiveDashboardViewProps = { type LiveDashboardViewProps = {
cameras: CameraConfig[]; cameras: CameraConfig[];
cameraGroup?: string;
includeBirdseye: boolean; includeBirdseye: boolean;
onSelectCamera: (camera: string) => void; onSelectCamera: (camera: string) => void;
}; };
export default function LiveDashboardView({ export default function LiveDashboardView({
cameras, cameras,
cameraGroup,
includeBirdseye, includeBirdseye,
onSelectCamera, onSelectCamera,
}: LiveDashboardViewProps) { }: LiveDashboardViewProps) {
@ -29,7 +37,7 @@ export default function LiveDashboardView({
// layout // layout
const [layout, setLayout] = usePersistence<"grid" | "list">( const [mobileLayout, setMobileLayout] = usePersistence<"grid" | "list">(
"live-layout", "live-layout",
isDesktop ? "grid" : "list", isDesktop ? "grid" : "list",
); );
@ -150,25 +158,25 @@ export default function LiveDashboardView({
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
className={`p-1 ${ className={`p-1 ${
layout == "grid" mobileLayout == "grid"
? "bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60" ? "bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
: "bg-secondary" : "bg-secondary"
}`} }`}
size="xs" size="xs"
onClick={() => setLayout("grid")} onClick={() => setMobileLayout("grid")}
> >
<LiveGridIcon layout={layout} /> <LiveGridIcon layout={mobileLayout} />
</Button> </Button>
<Button <Button
className={`p-1 ${ className={`p-1 ${
layout == "list" mobileLayout == "list"
? "bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60" ? "bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
: "bg-secondary" : "bg-secondary"
}`} }`}
size="xs" size="xs"
onClick={() => setLayout("list")} onClick={() => setMobileLayout("list")}
> >
<LiveListIcon layout={layout} /> <LiveListIcon layout={mobileLayout} />
</Button> </Button>
</div> </div>
</div> </div>
@ -187,8 +195,9 @@ export default function LiveDashboardView({
</ScrollArea> </ScrollArea>
)} )}
{!cameraGroup || cameraGroup == "default" || isMobileOnly ? (
<div <div
className={`mt-2 px-2 grid ${layout == "grid" ? "grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" : ""} gap-2 md:gap-4`} className={`mt-2 px-2 grid ${mobileLayout == "grid" ? "grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" : ""} gap-2 md:gap-4`}
> >
{includeBirdseye && birdseyeConfig?.enabled && ( {includeBirdseye && birdseyeConfig?.enabled && (
<BirdseyeLivePlayer <BirdseyeLivePlayer
@ -201,9 +210,9 @@ export default function LiveDashboardView({
let grow; let grow;
const aspectRatio = camera.detect.width / camera.detect.height; const aspectRatio = camera.detect.width / camera.detect.height;
if (aspectRatio > 2) { if (aspectRatio > 2) {
grow = `${layout == "grid" ? "col-span-2" : ""} aspect-wide`; grow = `${mobileLayout == "grid" ? "col-span-2" : ""} aspect-wide`;
} else if (aspectRatio < 1) { } else if (aspectRatio < 1) {
grow = `${layout == "grid" ? "row-span-2 aspect-tall md:h-full" : ""} aspect-tall`; grow = `${mobileLayout == "grid" ? "row-span-2 aspect-tall md:h-full" : ""} aspect-tall`;
} else { } else {
grow = "aspect-video"; grow = "aspect-video";
} }
@ -222,6 +231,17 @@ export default function LiveDashboardView({
); );
})} })}
</div> </div>
) : (
<DraggableGridLayout
cameras={cameras}
cameraGroup={cameraGroup}
cameraRef={cameraRef}
includeBirdseye={includeBirdseye}
onSelectCamera={onSelectCamera}
windowVisible={windowVisible}
visibleCameras={visibleCameras}
/>
)}
</div> </div>
); );
} }