Implement camera groups in config and live view

This commit is contained in:
Nicolas Mowen 2024-03-03 19:10:08 -07:00
parent 0d6ebcdba8
commit 6188a622d3
7 changed files with 85 additions and 49 deletions

View File

@ -1010,6 +1010,7 @@ class CameraGroupConfig(FrigateBaseModel):
default_factory=list, title="List of cameras in this group." default_factory=list, title="List of cameras in this group."
) )
icon: str = Field(default="generic", title="Icon that represents camera group.") icon: str = Field(default="generic", title="Icon that represents camera group.")
order: int = Field(default=0, title="Sort order for group.")
def verify_config_roles(camera_config: CameraConfig) -> None: def verify_config_roles(camera_config: CameraConfig) -> None:

View File

@ -1,42 +1,54 @@
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { isMobile } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import useSWR from "swr"; import useSWR from "swr";
import { MdHome } from "react-icons/md"; import { MdHome } from "react-icons/md";
import { FaCar, FaCircle, FaLeaf } from "react-icons/fa"; import { FaCar, FaCat, FaCircle, FaDog, FaLeaf } from "react-icons/fa";
import useOverlayState from "@/hooks/use-overlay-state"; import useOverlayState from "@/hooks/use-overlay-state";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useMemo } from "react";
export function CameraGroupSelector() { export function CameraGroupSelector() {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const navigate = useNavigate(); const navigate = useNavigate();
const [group, setGroup] = useOverlayState("cameraGroup"); const [group, setGroup] = useOverlayState("cameraGroup");
if (isMobile) { const groups = useMemo(() => {
if (!config) {
return [];
}
return Object.entries(config.camera_groups).sort(
(a, b) => a[1].order - b[1].order,
);
}, [config]);
return ( return (
<div className="flex items-center justify-start gap-2"> <div
className={`flex items-center justify-start gap-2 ${isDesktop ? "flex-col mb-4" : ""}`}
>
<Button <Button
className={ className={
group == undefined group == undefined
? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60" ? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
: "text-muted-foreground bg-muted" : "text-muted-foreground bg-secondary"
} }
size="xs" size="xs"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >
<MdHome className="size-4" /> <MdHome className="size-4" />
</Button> </Button>
{Object.entries(config?.camera_groups ?? {}).map(([name, config]) => { {groups.map(([name, config]) => {
return ( return (
<Button <Button
key={name} key={name}
className={ className={
group == name group == name
? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60" ? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
: "text-muted-foreground bg-muted" : "text-muted-foreground bg-secondary"
} }
size="xs" size="xs"
onClick={() => setGroup(name)} onClick={() => setGroup(name, group != undefined)}
> >
{getGroupIcon(config.icon)} {getGroupIcon(config.icon)}
</Button> </Button>
@ -44,15 +56,16 @@ export function CameraGroupSelector() {
})} })}
</div> </div>
); );
}
return <div></div>;
} }
function getGroupIcon(icon: string) { function getGroupIcon(icon: string) {
switch (icon) { switch (icon) {
case "car": case "car":
return <FaCar className="size-4" />; return <FaCar className="size-4" />;
case "cat":
return <FaCat className="size-4" />;
case "dog":
return <FaDog className="size-4" />;
case "leaf": case "leaf":
return <FaLeaf className="size-4" />; return <FaLeaf className="size-4" />;
default: default:

View File

@ -2,6 +2,7 @@ import Logo from "../Logo";
import { navbarLinks } from "@/pages/site-navigation"; import { navbarLinks } from "@/pages/site-navigation";
import SettingsNavItems from "../settings/SettingsNavItems"; import SettingsNavItems from "../settings/SettingsNavItems";
import NavItem from "./NavItem"; import NavItem from "./NavItem";
import { CameraGroupSelector } from "../filter/CameraGroupSelector";
function Sidebar() { function Sidebar() {
return ( return (
@ -10,14 +11,17 @@ function Sidebar() {
<div className="w-full flex flex-col gap-0 items-center"> <div className="w-full flex flex-col gap-0 items-center">
<Logo className="w-8 h-8 mb-6" /> <Logo className="w-8 h-8 mb-6" />
{navbarLinks.map((item) => ( {navbarLinks.map((item) => (
<>
<NavItem <NavItem
className="mx-[10px] mb-6" className={`mx-[10px] ${item.id == 1 ? "mb-2" : "mb-6"}`}
key={item.id} key={item.id}
Icon={item.icon} Icon={item.icon}
title={item.title} title={item.title}
url={item.url} url={item.url}
dev={item.dev} dev={item.dev}
/> />
{item.id == 1 && <CameraGroupSelector />}
</>
))} ))}
</div> </div>
<SettingsNavItems className="hidden md:flex flex-col items-center mb-8" /> <SettingsNavItems className="hidden md:flex flex-col items-center mb-8" />

View File

@ -3,16 +3,16 @@ import { useLocation, useNavigate } from "react-router-dom";
export default function useOverlayState( export default function useOverlayState(
key: string, key: string,
): [string | undefined, (value: string) => void] { ): [string | undefined, (value: string, replace?: boolean) => void] {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const currentLocationState = location.state; const currentLocationState = location.state;
const setOverlayStateValue = useCallback( const setOverlayStateValue = useCallback(
(value: string) => { (value: string, replace: boolean = false) => {
const newLocationState = { ...currentLocationState }; const newLocationState = { ...currentLocationState };
newLocationState[key] = value; newLocationState[key] = value;
navigate(location.pathname, { state: newLocationState }); navigate(location.pathname, { state: newLocationState, replace });
}, },
// we know that these deps are correct // we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps

View File

@ -7,17 +7,26 @@ import useSWR from "swr";
function Live() { function Live() {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [selectedCameraName, setSelectedCameraName] = useOverlayState("camera"); const [selectedCameraName, setSelectedCameraName] = useOverlayState("camera");
const [cameraGroup] = useOverlayState("cameraGroup");
const cameras = useMemo(() => { const cameras = useMemo(() => {
if (!config) { if (!config) {
return []; return [];
} }
if (cameraGroup) {
const group = config.camera_groups[cameraGroup];
return Object.values(config.cameras)
.filter((conf) => conf.enabled && group.cameras.includes(conf.name))
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}
return Object.values(config.cameras) return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard && conf.enabled) .filter((conf) => conf.ui.dashboard && conf.enabled)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]); }, [config, cameraGroup]);
const selectedCamera = useMemo( const selectedCamera = useMemo(
() => cameras.find((cam) => cam.name == selectedCameraName), () => cameras.find((cam) => cam.name == selectedCameraName),

View File

@ -204,6 +204,12 @@ export interface CameraConfig {
}; };
} }
export type CameraGroupConfig = {
cameras: string[];
icon: string;
order: number;
};
export interface FrigateConfig { export interface FrigateConfig {
audio: { audio: {
enabled: boolean; enabled: boolean;
@ -276,6 +282,8 @@ export interface FrigateConfig {
go2rtc: Record<string, unknown>; go2rtc: Record<string, unknown>;
camera_groups: { [groupName: string]: CameraGroupConfig };
live: { live: {
height: number; height: number;
quality: number; quality: number;

View File

@ -1,5 +1,6 @@
import { useFrigateReviews } from "@/api/ws"; import { useFrigateReviews } from "@/api/ws";
import Logo from "@/components/Logo"; import Logo from "@/components/Logo";
import { CameraGroupSelector } from "@/components/filter/CameraGroupSelector";
import { AnimatedEventThumbnail } from "@/components/image/AnimatedEventThumbnail"; import { AnimatedEventThumbnail } from "@/components/image/AnimatedEventThumbnail";
import LivePlayer from "@/components/player/LivePlayer"; import LivePlayer from "@/components/player/LivePlayer";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -78,7 +79,7 @@ export default function LiveDashboardView({
{isMobile && ( {isMobile && (
<div className="relative h-9 flex items-center justify-between"> <div className="relative h-9 flex items-center justify-between">
<Logo className="absolute inset-y-0 inset-x-1/2 -translate-x-1/2 h-8" /> <Logo className="absolute inset-y-0 inset-x-1/2 -translate-x-1/2 h-8" />
<div /> <CameraGroupSelector />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
className={ className={