mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
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:
parent
08e5c791c8
commit
ff2948a76b
71
web/package-lock.json
generated
71
web/package-lock.json
generated
@ -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",
|
||||||
|
@ -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",
|
||||||
|
@ -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.",
|
||||||
}),
|
}),
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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];
|
||||||
}
|
}
|
||||||
|
@ -89,6 +89,7 @@ function Live() {
|
|||||||
return (
|
return (
|
||||||
<LiveDashboardView
|
<LiveDashboardView
|
||||||
cameras={cameras}
|
cameras={cameras}
|
||||||
|
cameraGroup={cameraGroup}
|
||||||
includeBirdseye={includesBirdseye}
|
includeBirdseye={includesBirdseye}
|
||||||
onSelectCamera={setSelectedCameraName}
|
onSelectCamera={setSelectedCameraName}
|
||||||
/>
|
/>
|
||||||
|
461
web/src/views/live/DraggableGridLayout.tsx
Normal file
461
web/src/views/live/DraggableGridLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
@ -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,41 +195,53 @@ export default function LiveDashboardView({
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
{!cameraGroup || cameraGroup == "default" || isMobileOnly ? (
|
||||||
className={`mt-2 px-2 grid ${layout == "grid" ? "grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" : ""} gap-2 md:gap-4`}
|
<div
|
||||||
>
|
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 && (
|
>
|
||||||
<BirdseyeLivePlayer
|
{includeBirdseye && birdseyeConfig?.enabled && (
|
||||||
birdseyeConfig={birdseyeConfig}
|
<BirdseyeLivePlayer
|
||||||
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
|
birdseyeConfig={birdseyeConfig}
|
||||||
onClick={() => onSelectCamera("birdseye")}
|
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
|
||||||
/>
|
onClick={() => onSelectCamera("birdseye")}
|
||||||
)}
|
|
||||||
{cameras.map((camera) => {
|
|
||||||
let grow;
|
|
||||||
const aspectRatio = camera.detect.width / camera.detect.height;
|
|
||||||
if (aspectRatio > 2) {
|
|
||||||
grow = `${layout == "grid" ? "col-span-2" : ""} aspect-wide`;
|
|
||||||
} else if (aspectRatio < 1) {
|
|
||||||
grow = `${layout == "grid" ? "row-span-2 aspect-tall md:h-full" : ""} aspect-tall`;
|
|
||||||
} else {
|
|
||||||
grow = "aspect-video";
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<LivePlayer
|
|
||||||
cameraRef={cameraRef}
|
|
||||||
key={camera.name}
|
|
||||||
className={`${grow} rounded-lg md:rounded-2xl bg-black`}
|
|
||||||
windowVisible={
|
|
||||||
windowVisible && visibleCameras.includes(camera.name)
|
|
||||||
}
|
|
||||||
cameraConfig={camera}
|
|
||||||
preferredLiveMode={isSafari ? "webrtc" : "mse"}
|
|
||||||
onClick={() => onSelectCamera(camera.name)}
|
|
||||||
/>
|
/>
|
||||||
);
|
)}
|
||||||
})}
|
{cameras.map((camera) => {
|
||||||
</div>
|
let grow;
|
||||||
|
const aspectRatio = camera.detect.width / camera.detect.height;
|
||||||
|
if (aspectRatio > 2) {
|
||||||
|
grow = `${mobileLayout == "grid" ? "col-span-2" : ""} aspect-wide`;
|
||||||
|
} else if (aspectRatio < 1) {
|
||||||
|
grow = `${mobileLayout == "grid" ? "row-span-2 aspect-tall md:h-full" : ""} aspect-tall`;
|
||||||
|
} else {
|
||||||
|
grow = "aspect-video";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<LivePlayer
|
||||||
|
cameraRef={cameraRef}
|
||||||
|
key={camera.name}
|
||||||
|
className={`${grow} rounded-lg md:rounded-2xl bg-black`}
|
||||||
|
windowVisible={
|
||||||
|
windowVisible && visibleCameras.includes(camera.name)
|
||||||
|
}
|
||||||
|
cameraConfig={camera}
|
||||||
|
preferredLiveMode={isSafari ? "webrtc" : "mse"}
|
||||||
|
onClick={() => onSelectCamera(camera.name)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DraggableGridLayout
|
||||||
|
cameras={cameras}
|
||||||
|
cameraGroup={cameraGroup}
|
||||||
|
cameraRef={cameraRef}
|
||||||
|
includeBirdseye={includeBirdseye}
|
||||||
|
onSelectCamera={onSelectCamera}
|
||||||
|
windowVisible={windowVisible}
|
||||||
|
visibleCameras={visibleCameras}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user