streaming dialog from context menu

This commit is contained in:
Josh Hawkins 2024-12-26 07:51:16 -06:00
parent 826dbc4da3
commit 95047308ba
9 changed files with 543 additions and 350 deletions

View File

@ -9,7 +9,7 @@ import useSWR from "swr";
import { MdHome } from "react-icons/md"; import { MdHome } from "react-icons/md";
import { usePersistedOverlayState } from "@/hooks/use-overlay-state"; import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
import { Button, buttonVariants } from "../ui/button"; import { Button, buttonVariants } from "../ui/button";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { LuPencil, LuPlus } from "react-icons/lu"; import { LuPencil, LuPlus } from "react-icons/lu";
import { import {
@ -73,17 +73,13 @@ import {
import { Label } from "../ui/label"; import { Label } from "../ui/label";
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import { CameraStreamingDialog } from "../settings/CameraStreamingDialog"; import { CameraStreamingDialog } from "../settings/CameraStreamingDialog";
import { DialogTrigger } from "@radix-ui/react-dialog";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
type CameraGroupSelectorProps = { type CameraGroupSelectorProps = {
className?: string; className?: string;
setAllGroupsStreamingSettings: React.Dispatch<
React.SetStateAction<AllGroupsStreamingSettings>
>;
}; };
export function CameraGroupSelector({ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
className,
setAllGroupsStreamingSettings,
}: CameraGroupSelectorProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
// tooltip // tooltip
@ -137,7 +133,6 @@ export function CameraGroupSelector({
activeGroup={group} activeGroup={group}
setGroup={setGroup} setGroup={setGroup}
deleteGroup={deleteGroup} deleteGroup={deleteGroup}
setAllGroupsStreamingSettings={setAllGroupsStreamingSettings}
/> />
<Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}> <Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}>
<div <div
@ -227,9 +222,6 @@ type NewGroupDialogProps = {
activeGroup?: string; activeGroup?: string;
setGroup: (value: string | undefined, replace?: boolean | undefined) => void; setGroup: (value: string | undefined, replace?: boolean | undefined) => void;
deleteGroup: () => void; deleteGroup: () => void;
setAllGroupsStreamingSettings: React.Dispatch<
React.SetStateAction<AllGroupsStreamingSettings>
>;
}; };
function NewGroupDialog({ function NewGroupDialog({
open, open,
@ -238,7 +230,6 @@ function NewGroupDialog({
activeGroup, activeGroup,
setGroup, setGroup,
deleteGroup, deleteGroup,
setAllGroupsStreamingSettings,
}: NewGroupDialogProps) { }: NewGroupDialogProps) {
const { mutate: updateConfig } = useSWR<FrigateConfig>("config"); const { mutate: updateConfig } = useSWR<FrigateConfig>("config");
@ -421,7 +412,6 @@ function NewGroupDialog({
setIsLoading={setIsLoading} setIsLoading={setIsLoading}
onSave={onSave} onSave={onSave}
onCancel={onCancel} onCancel={onCancel}
setAllGroupsStreamingSettings={setAllGroupsStreamingSettings}
/> />
</> </>
)} )}
@ -436,16 +426,12 @@ type EditGroupDialogProps = {
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
currentGroups: [string, CameraGroupConfig][]; currentGroups: [string, CameraGroupConfig][];
activeGroup?: string; activeGroup?: string;
setAllGroupsStreamingSettings: React.Dispatch<
React.SetStateAction<AllGroupsStreamingSettings>
>;
}; };
export function EditGroupDialog({ export function EditGroupDialog({
open, open,
setOpen, setOpen,
currentGroups, currentGroups,
activeGroup, activeGroup,
setAllGroupsStreamingSettings,
}: EditGroupDialogProps) { }: EditGroupDialogProps) {
const Overlay = isDesktop ? Dialog : MobilePage; const Overlay = isDesktop ? Dialog : MobilePage;
const Content = isDesktop ? DialogContent : MobilePageContent; const Content = isDesktop ? DialogContent : MobilePageContent;
@ -497,7 +483,6 @@ export function EditGroupDialog({
setIsLoading={setIsLoading} setIsLoading={setIsLoading}
onSave={() => setOpen(false)} onSave={() => setOpen(false)}
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
setAllGroupsStreamingSettings={setAllGroupsStreamingSettings}
/> />
</div> </div>
</Content> </Content>
@ -618,9 +603,6 @@ type CameraGroupEditProps = {
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>; setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
onSave?: () => void; onSave?: () => void;
onCancel?: () => void; onCancel?: () => void;
setAllGroupsStreamingSettings: React.Dispatch<
React.SetStateAction<AllGroupsStreamingSettings>
>;
}; };
export function CameraGroupEdit({ export function CameraGroupEdit({
@ -630,16 +612,19 @@ export function CameraGroupEdit({
setIsLoading, setIsLoading,
onSave, onSave,
onCancel, onCancel,
setAllGroupsStreamingSettings,
}: CameraGroupEditProps) { }: CameraGroupEditProps) {
const { data: config, mutate: updateConfig } = const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config"); useSWR<FrigateConfig>("config");
const [groupStreamingSettings, setGroupStreamingSettings] = const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
useState<GroupStreamingSettings>({}); useStreamingSettings();
const [persistedGroupStreamingSettings, setPersistedGroupStreamingSettings] = const [groupStreamingSettings, setGroupStreamingSettings] =
usePersistence<AllGroupsStreamingSettings>("streaming-settings"); useState<GroupStreamingSettings>(
allGroupsStreamingSettings[editingGroup?.[0] ?? ""],
);
const [openCamera, setOpenCamera] = useState<string | null>();
const birdseyeConfig = useMemo(() => config?.birdseye, [config]); const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
@ -693,7 +678,7 @@ export function CameraGroupEdit({
// update streaming settings // update streaming settings
const updatedSettings: AllGroupsStreamingSettings = { const updatedSettings: AllGroupsStreamingSettings = {
...Object.fromEntries( ...Object.fromEntries(
Object.entries(persistedGroupStreamingSettings || {}).filter( Object.entries(allGroupsStreamingSettings || {}).filter(
([key]) => key !== editingGroup?.[0], ([key]) => key !== editingGroup?.[0],
), ),
), ),
@ -732,7 +717,6 @@ export function CameraGroupEdit({
if (onSave) { if (onSave) {
onSave(); onSave();
} }
await setPersistedGroupStreamingSettings(updatedSettings);
setAllGroupsStreamingSettings(updatedSettings); setAllGroupsStreamingSettings(updatedSettings);
} else { } else {
toast.error(`Failed to save config changes: ${res.statusText}`, { toast.error(`Failed to save config changes: ${res.statusText}`, {
@ -757,8 +741,7 @@ export function CameraGroupEdit({
updateConfig, updateConfig,
editingGroup, editingGroup,
groupStreamingSettings, groupStreamingSettings,
setPersistedGroupStreamingSettings, allGroupsStreamingSettings,
persistedGroupStreamingSettings,
setAllGroupsStreamingSettings, setAllGroupsStreamingSettings,
], ],
); );
@ -773,20 +756,6 @@ export function CameraGroupEdit({
}, },
}); });
// streaming settings
useEffect(() => {
if (editingGroup && editingGroup[0] && persistedGroupStreamingSettings) {
setGroupStreamingSettings(
persistedGroupStreamingSettings[editingGroup[0]] || {},
);
}
}, [
editingGroup,
persistedGroupStreamingSettings,
setGroupStreamingSettings,
]);
return ( return (
<Form {...form}> <Form {...form}>
<form <form
@ -838,15 +807,43 @@ export function CameraGroupEdit({
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
{camera !== "birdseye" && ( {camera !== "birdseye" && (
<Dialog
open={openCamera === camera}
onOpenChange={(isOpen) =>
setOpenCamera(isOpen ? camera : null)
}
>
<DialogTrigger asChild>
<Button
className="flex h-auto items-center gap-1"
aria-label="Camera streaming settings"
size="icon"
variant="ghost"
disabled={
!(field.value && field.value.includes(camera))
}
>
<LuIcons.LuSettings
className={cn(
field.value && field.value.includes(camera)
? "text-primary"
: "text-muted-foreground",
"size-5",
)}
/>
</Button>
</DialogTrigger>
<CameraStreamingDialog <CameraStreamingDialog
camera={camera} camera={camera}
selectedCameras={field.value}
config={config}
groupStreamingSettings={groupStreamingSettings} groupStreamingSettings={groupStreamingSettings}
setGroupStreamingSettings={ setGroupStreamingSettings={
setGroupStreamingSettings setGroupStreamingSettings
} }
setIsDialogOpen={(isOpen) =>
setOpenCamera(isOpen ? camera : null)
}
/> />
</Dialog>
)} )}
<Switch <Switch
id={camera.replaceAll("_", " ")} id={camera.replaceAll("_", " ")}

View File

@ -1,4 +1,11 @@
import { ReactNode, useMemo } from "react"; import {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@ -12,10 +19,19 @@ import {
MdVolumeOff, MdVolumeOff,
MdVolumeUp, MdVolumeUp,
} from "react-icons/md"; } from "react-icons/md";
import { Dialog } from "@/components/ui/dialog";
import { VolumeSlider } from "@/components/ui/slider"; import { VolumeSlider } from "@/components/ui/slider";
import { CameraStreamingDialog } from "../settings/CameraStreamingDialog";
import {
AllGroupsStreamingSettings,
GroupStreamingSettings,
} from "@/types/frigateConfig";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
type LiveContextMenuProps = { type LiveContextMenuProps = {
camera: string; camera: string;
streamName: string;
cameraGroup?: string;
preferredLiveMode: string; preferredLiveMode: string;
isRestreamed: boolean; isRestreamed: boolean;
supportsAudio: boolean; supportsAudio: boolean;
@ -30,6 +46,8 @@ type LiveContextMenuProps = {
}; };
export default function LiveContextMenu({ export default function LiveContextMenu({
camera, camera,
streamName,
cameraGroup,
preferredLiveMode, preferredLiveMode,
isRestreamed, isRestreamed,
supportsAudio, supportsAudio,
@ -42,6 +60,83 @@ export default function LiveContextMenu({
resetPreferredLiveMode, resetPreferredLiveMode,
children, children,
}: LiveContextMenuProps) { }: LiveContextMenuProps) {
const [showSettings, setShowSettings] = useState(false);
// streaming settings
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
useStreamingSettings();
const [groupStreamingSettings, setGroupStreamingSettings] =
useState<GroupStreamingSettings>(
allGroupsStreamingSettings[cameraGroup ?? ""],
);
useEffect(() => {
if (cameraGroup) {
setGroupStreamingSettings(allGroupsStreamingSettings[cameraGroup]);
}
// set individual group when all groups changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allGroupsStreamingSettings]);
const onSave = useCallback(
(settings: GroupStreamingSettings) => {
if (!cameraGroup || !allGroupsStreamingSettings) {
return;
}
const updatedSettings: AllGroupsStreamingSettings = {
...Object.fromEntries(
Object.entries(allGroupsStreamingSettings || {}).filter(
([key]) => key !== cameraGroup,
),
),
[cameraGroup]: {
...Object.fromEntries(
Object.entries(settings).map(([cameraName, cameraSettings]) => [
cameraName,
cameraName === camera
? {
...cameraSettings,
playAudio: audioState ?? cameraSettings.playAudio ?? false,
volume: volumeState ?? cameraSettings.volume ?? 1,
}
: cameraSettings,
]),
),
// Add the current camera if it doesn't exist
...(!settings[camera]
? {
[camera]: {
streamName: streamName,
streamType: "smart",
compatibilityMode: false,
playAudio: audioState,
volume: volumeState ?? 1,
},
}
: {}),
},
};
setAllGroupsStreamingSettings?.(updatedSettings);
},
[
camera,
streamName,
cameraGroup,
allGroupsStreamingSettings,
setAllGroupsStreamingSettings,
audioState,
volumeState,
],
);
// ui
const audioControlsUsed = useRef(false);
const VolumeIcon = useMemo(() => { const VolumeIcon = useMemo(() => {
if (!volumeState || volumeState == 0.0 || !audioState) { if (!volumeState || volumeState == 0.0 || !audioState) {
return MdVolumeOff; return MdVolumeOff;
@ -56,8 +151,27 @@ export default function LiveContextMenu({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [volumeState, audioState]); }, [volumeState, audioState]);
const handleVolumeIconClick = (e: React.MouseEvent) => {
e.stopPropagation();
audioControlsUsed.current = true;
toggleAudio();
};
const handleVolumeChange = (value: number[]) => {
audioControlsUsed.current = true;
setVolumeState(value[0]);
};
const handleOpenChange = (open: boolean) => {
if (!open && audioControlsUsed.current) {
onSave(groupStreamingSettings);
audioControlsUsed.current = false;
}
};
return ( return (
<ContextMenu key={camera}> <>
<ContextMenu key={camera} onOpenChange={handleOpenChange}>
<ContextMenuTrigger>{children}</ContextMenuTrigger> <ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
<div className="text-md py-1 pl-2 capitalize text-primary-variant"> <div className="text-md py-1 pl-2 capitalize text-primary-variant">
@ -72,10 +186,7 @@ export default function LiveContextMenu({
<div className="flex flex-row items-center gap-1"> <div className="flex flex-row items-center gap-1">
<VolumeIcon <VolumeIcon
className="size-5" className="size-5"
onClick={(e: React.MouseEvent) => { onClick={handleVolumeIconClick}
e.stopPropagation();
toggleAudio();
}}
/> />
<VolumeSlider <VolumeSlider
disabled={!audioState} disabled={!audioState}
@ -84,9 +195,7 @@ export default function LiveContextMenu({
min={0} min={0}
max={1} max={1}
step={0.02} step={0.02}
onValueChange={(value) => { onValueChange={handleVolumeChange}
setVolumeState(value[0]);
}}
/> />
</div> </div>
</div> </div>
@ -110,13 +219,13 @@ export default function LiveContextMenu({
<div className="text-primary">Unmute All Cameras</div> <div className="text-primary">Unmute All Cameras</div>
</div> </div>
</ContextMenuItem> </ContextMenuItem>
{isRestreamed && ( {isRestreamed && cameraGroup && (
<> <>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem> <ContextMenuItem>
<div <div
className="flex w-full cursor-pointer items-center justify-start gap-2" className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={() => {}} onClick={() => setShowSettings(true)}
> >
<div className="text-primary">Streaming Settings</div> <div className="text-primary">Streaming Settings</div>
</div> </div>
@ -138,5 +247,16 @@ export default function LiveContextMenu({
)} )}
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
<Dialog open={showSettings} onOpenChange={setShowSettings}>
<CameraStreamingDialog
camera={camera}
groupStreamingSettings={groupStreamingSettings}
setGroupStreamingSettings={setGroupStreamingSettings}
setIsDialogOpen={setShowSettings}
onSave={onSave}
/>
</Dialog>
</>
); );
} }

View File

@ -6,9 +6,7 @@ import GeneralSettings from "../menu/GeneralSettings";
import AccountSettings from "../menu/AccountSettings"; import AccountSettings from "../menu/AccountSettings";
import useNavigation from "@/hooks/use-navigation"; import useNavigation from "@/hooks/use-navigation";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { useEffect, useMemo, useState } from "react"; import { useMemo } from "react";
import { usePersistence } from "@/hooks/use-persistence";
import { AllGroupsStreamingSettings } from "@/types/frigateConfig";
function Sidebar() { function Sidebar() {
const basePath = useMemo(() => new URL(baseUrl).pathname, []); const basePath = useMemo(() => new URL(baseUrl).pathname, []);
@ -18,18 +16,6 @@ function Sidebar() {
const navbarLinks = useNavigation(); const navbarLinks = useNavigation();
const [, setAllGroupsStreamingSettings] =
useState<AllGroupsStreamingSettings>({});
const [persistedStreamingSettings, _, isStreamingSettingsLoaded] =
usePersistence<AllGroupsStreamingSettings>("streaming-settings");
useEffect(() => {
if (isStreamingSettingsLoaded) {
setAllGroupsStreamingSettings(persistedStreamingSettings ?? {});
}
}, [isStreamingSettingsLoaded, persistedStreamingSettings]);
return ( return (
<aside className="scrollbar-container scrollbar-hidden absolute inset-y-0 left-0 z-10 flex w-[52px] flex-col justify-between overflow-y-auto border-r border-secondary-highlight bg-background_alt py-4"> <aside className="scrollbar-container scrollbar-hidden absolute inset-y-0 left-0 z-10 flex w-[52px] flex-col justify-between overflow-y-auto border-r border-secondary-highlight bg-background_alt py-4">
<span tabIndex={0} className="sr-only" /> <span tabIndex={0} className="sr-only" />
@ -48,12 +34,7 @@ function Sidebar() {
item={item} item={item}
Icon={item.icon} Icon={item.icon}
/> />
{showCameraGroups && ( {showCameraGroups && <CameraGroupSelector className="mb-4" />}
<CameraGroupSelector
setAllGroupsStreamingSettings={setAllGroupsStreamingSettings}
className="mb-4"
/>
)}
</div> </div>
); );
})} })}

View File

@ -2,11 +2,9 @@ import { useState, useCallback, useEffect } from "react";
import { IoIosWarning } from "react-icons/io"; import { IoIosWarning } from "react-icons/io";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger,
DialogDescription, DialogDescription,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { import {
@ -16,35 +14,48 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { import {
FrigateConfig, FrigateConfig,
GroupStreamingSettings, GroupStreamingSettings,
StreamType, StreamType,
} from "@/types/frigateConfig"; } from "@/types/frigateConfig";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { LuSettings } from "react-icons/lu"; import useSWR from "swr";
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import { LuCheck, LuExternalLink, LuInfo, LuX } from "react-icons/lu";
import { Link } from "react-router-dom";
type CameraStreamingDialogProps = { type CameraStreamingDialogProps = {
camera: string; camera: string;
selectedCameras: string[];
config?: FrigateConfig;
groupStreamingSettings: GroupStreamingSettings; groupStreamingSettings: GroupStreamingSettings;
setGroupStreamingSettings: React.Dispatch< setGroupStreamingSettings: React.Dispatch<
React.SetStateAction<GroupStreamingSettings> React.SetStateAction<GroupStreamingSettings>
>; >;
setIsDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
onSave?: (settings: GroupStreamingSettings) => void;
}; };
export function CameraStreamingDialog({ export function CameraStreamingDialog({
camera, camera,
selectedCameras,
config,
groupStreamingSettings, groupStreamingSettings,
setGroupStreamingSettings, setGroupStreamingSettings,
setIsDialogOpen,
onSave,
}: CameraStreamingDialogProps) { }: CameraStreamingDialogProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false); const { data: config } = useSWR<FrigateConfig>("config");
const { supportsAudioOutputStates } = useCameraLiveMode(
config?.cameras[camera] ? [config.cameras[camera]] : [],
false,
);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [streamName, setStreamName] = useState( const [streamName, setStreamName] = useState(
@ -74,18 +85,30 @@ export function CameraStreamingDialog({
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
setIsLoading(true); setIsLoading(true);
setGroupStreamingSettings((prevSettings) => ({ const updatedSettings = {
...prevSettings, ...groupStreamingSettings,
[camera]: { streamName, streamType, compatibilityMode }, [camera]: {
})); streamName,
streamType,
compatibilityMode,
playAudio: groupStreamingSettings[camera]?.playAudio ?? false,
volume: groupStreamingSettings[camera]?.volume ?? 1,
},
};
setGroupStreamingSettings(updatedSettings);
setIsDialogOpen(false); setIsDialogOpen(false);
setIsLoading(false); setIsLoading(false);
onSave?.(updatedSettings);
}, [ }, [
groupStreamingSettings,
setGroupStreamingSettings, setGroupStreamingSettings,
camera, camera,
streamName, streamName,
streamType, streamType,
compatibilityMode, compatibilityMode,
setIsDialogOpen,
onSave,
]); ]);
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
@ -106,32 +129,13 @@ export function CameraStreamingDialog({
setCompatibilityMode(false); setCompatibilityMode(false);
} }
setIsDialogOpen(false); setIsDialogOpen(false);
}, [groupStreamingSettings, camera, config]); }, [groupStreamingSettings, camera, config, setIsDialogOpen]);
if (!config) { if (!config) {
return null; return null;
} }
return ( return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button
className="flex h-auto items-center gap-1"
aria-label="Camera streaming settings"
size="icon"
variant="ghost"
disabled={!(selectedCameras && selectedCameras.includes(camera))}
>
<LuSettings
className={cn(
selectedCameras && selectedCameras.includes(camera)
? "text-primary"
: "text-muted-foreground",
"size-5",
)}
/>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]"> <DialogContent className="sm:max-w-[425px]">
<DialogHeader className="mb-4"> <DialogHeader className="mb-4">
<DialogTitle className="capitalize"> <DialogTitle className="capitalize">
@ -162,6 +166,43 @@ export function CameraStreamingDialog({
), ),
)} )}
</SelectContent> </SelectContent>
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutputStates[streamName] &&
supportsAudioOutputStates[streamName].supportsAudio ? (
<>
<LuCheck className="size-4 text-success" />
<div>Audio is available for this stream</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>Audio is unavailable for this stream</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80">
Audio must be output from your camera and configured in
go2rtc for this stream.
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
</Select> </Select>
</div> </div>
)} )}
@ -192,9 +233,9 @@ export function CameraStreamingDialog({
)} )}
{streamType === "smart" && ( {streamType === "smart" && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Smart streaming will update your camera image once per minute Smart streaming will update your camera image once per minute when
when no detectable activity is occurring to conserve bandwidth no detectable activity is occurring to conserve bandwidth and
and resources. When activity is detected, the image seamlessly resources. When activity is detected, the image seamlessly
switches to a live stream. switches to a live stream.
</p> </p>
)} )}
@ -231,9 +272,9 @@ export function CameraStreamingDialog({
</div> </div>
<div className="flex flex-col gap-2 leading-none"> <div className="flex flex-col gap-2 leading-none">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Enable this option only if your camera's live stream is Enable this option only if your camera's live stream is displaying
displaying color artifacts and has a diagonal line on the right color artifacts and has a diagonal line on the right side of the
side of the image. image.
</p> </p>
</div> </div>
</div> </div>
@ -266,6 +307,5 @@ export function CameraStreamingDialog({
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog>
); );
} }

View File

@ -5,6 +5,7 @@ import { ApiProvider } from "@/api";
import { IconContext } from "react-icons"; import { IconContext } from "react-icons";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { StatusBarMessagesProvider } from "@/context/statusbar-provider"; import { StatusBarMessagesProvider } from "@/context/statusbar-provider";
import { StreamingSettingsProvider } from "./streaming-settings-provider";
type TProvidersProps = { type TProvidersProps = {
children: ReactNode; children: ReactNode;
@ -17,7 +18,11 @@ function providers({ children }: TProvidersProps) {
<ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme"> <ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme">
<TooltipProvider> <TooltipProvider>
<IconContext.Provider value={{ size: "20" }}> <IconContext.Provider value={{ size: "20" }}>
<StatusBarMessagesProvider>{children}</StatusBarMessagesProvider> <StatusBarMessagesProvider>
<StreamingSettingsProvider>
{children}
</StreamingSettingsProvider>
</StatusBarMessagesProvider>
</IconContext.Provider> </IconContext.Provider>
</TooltipProvider> </TooltipProvider>
</ThemeProvider> </ThemeProvider>

View File

@ -235,6 +235,8 @@ export type CameraStreamingSettings = {
streamName: string; streamName: string;
streamType: StreamType; streamType: StreamType;
compatibilityMode: boolean; compatibilityMode: boolean;
playAudio: boolean;
volume: number;
}; };
export type GroupStreamingSettings = { export type GroupStreamingSettings = {

View File

@ -32,3 +32,6 @@ export type LiveStreamMetadata = {
}; };
export type LivePlayerError = "stalled" | "startup" | "mse-decode"; export type LivePlayerError = "stalled" | "startup" | "mse-decode";
export type AudioState = Record<string, boolean>;
export type VolumeState = Record<string, number>;

View File

@ -21,7 +21,7 @@ import {
} from "react-grid-layout"; } from "react-grid-layout";
import "react-grid-layout/css/styles.css"; import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css"; import "react-resizable/css/styles.css";
import { LivePlayerMode } from "@/types/live"; import { AudioState, LivePlayerMode, VolumeState } from "@/types/live";
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
@ -44,6 +44,7 @@ import {
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import useCameraLiveMode from "@/hooks/use-camera-live-mode"; import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import LiveContextMenu from "@/components/menu/LiveContextMenu"; import LiveContextMenu from "@/components/menu/LiveContextMenu";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
type DraggableGridLayoutProps = { type DraggableGridLayoutProps = {
cameras: CameraConfig[]; cameras: CameraConfig[];
@ -58,10 +59,6 @@ type DraggableGridLayoutProps = {
setIsEditMode: React.Dispatch<React.SetStateAction<boolean>>; setIsEditMode: React.Dispatch<React.SetStateAction<boolean>>;
fullscreen: boolean; fullscreen: boolean;
toggleFullscreen: () => void; toggleFullscreen: () => void;
allGroupsStreamingSettings: AllGroupsStreamingSettings;
setAllGroupsStreamingSettings: React.Dispatch<
React.SetStateAction<AllGroupsStreamingSettings>
>;
}; };
export default function DraggableGridLayout({ export default function DraggableGridLayout({
cameras, cameras,
@ -76,8 +73,6 @@ export default function DraggableGridLayout({
setIsEditMode, setIsEditMode,
fullscreen, fullscreen,
toggleFullscreen, toggleFullscreen,
allGroupsStreamingSettings,
setAllGroupsStreamingSettings,
}: DraggableGridLayoutProps) { }: DraggableGridLayoutProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const birdseyeConfig = useMemo(() => config?.birdseye, [config]); const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
@ -94,6 +89,9 @@ export default function DraggableGridLayout({
const [globalAutoLive] = usePersistence("autoLiveView", true); const [globalAutoLive] = usePersistence("autoLiveView", true);
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
useStreamingSettings();
const currentGroupStreamingSettings = useMemo(() => { const currentGroupStreamingSettings = useMemo(() => {
if (cameraGroup && cameraGroup != "default" && allGroupsStreamingSettings) { if (cameraGroup && cameraGroup != "default" && allGroupsStreamingSettings) {
return allGroupsStreamingSettings[cameraGroup]; return allGroupsStreamingSettings[cameraGroup];
@ -367,30 +365,87 @@ export default function DraggableGridLayout({
// audio states // audio states
const [audioStates, setAudioStates] = useState<Record<string, boolean>>({}); const [audioStates, setAudioStates] = useState<AudioState>({});
const [volumeStates, setVolumeStates] = useState<Record<string, number>>({}); const [volumeStates, setVolumeStates] = useState<VolumeState>({});
const toggleAudio = (cameraName: string): void => { useEffect(() => {
if (!allGroupsStreamingSettings) {
return;
}
const initialAudioStates: AudioState = {};
const initialVolumeStates: VolumeState = {};
Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
});
});
setAudioStates(initialAudioStates);
setVolumeStates(initialVolumeStates);
}, [allGroupsStreamingSettings]);
const toggleAudio = (cameraName: string) => {
setAudioStates((prev) => ({ setAudioStates((prev) => ({
...prev, ...prev,
[cameraName]: !prev[cameraName], [cameraName]: !prev[cameraName],
})); }));
}; };
const muteAll = (): void => { const onSaveMuting = useCallback(
const updatedStates: Record<string, boolean> = {}; (playAudio: boolean) => {
visibleCameras.forEach((cameraName) => { if (!cameraGroup || !allGroupsStreamingSettings) {
updatedStates[cameraName] = false; return;
}); }
setAudioStates(updatedStates);
const existingGroupSettings =
allGroupsStreamingSettings[cameraGroup] || {};
const updatedSettings: AllGroupsStreamingSettings = {
...Object.fromEntries(
Object.entries(allGroupsStreamingSettings || {}).filter(
([key]) => key !== cameraGroup,
),
),
[cameraGroup]: {
...existingGroupSettings,
...Object.fromEntries(
Object.entries(existingGroupSettings).map(
([cameraName, settings]) => [
cameraName,
{
...settings,
playAudio: playAudio,
},
],
),
),
},
}; };
const unmuteAll = (): void => { setAllGroupsStreamingSettings?.(updatedSettings);
const updatedStates: Record<string, boolean> = {}; },
visibleCameras.forEach((cameraName) => { [cameraGroup, allGroupsStreamingSettings, setAllGroupsStreamingSettings],
updatedStates[cameraName] = true; );
const muteAll = () => {
const updatedStates: AudioState = {};
cameras.forEach((camera) => {
updatedStates[camera.name] = false;
}); });
setAudioStates(updatedStates); setAudioStates(updatedStates);
onSaveMuting(false);
};
const unmuteAll = () => {
const updatedStates: AudioState = {};
cameras.forEach((camera) => {
updatedStates[camera.name] = true;
});
setAudioStates(updatedStates);
onSaveMuting(true);
}; };
return ( return (
@ -423,7 +478,6 @@ export default function DraggableGridLayout({
setOpen={setEditGroup} setOpen={setEditGroup}
currentGroups={groups} currentGroups={groups}
activeGroup={group} activeGroup={group}
setAllGroupsStreamingSettings={setAllGroupsStreamingSettings}
/> />
<ResponsiveGridLayout <ResponsiveGridLayout
className="grid-layout" className="grid-layout"
@ -488,6 +542,8 @@ export default function DraggableGridLayout({
<GridLiveContextMenu <GridLiveContextMenu
key={camera.name} key={camera.name}
camera={camera.name} camera={camera.name}
streamName={streamName}
cameraGroup={cameraGroup}
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"} preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
isRestreamed={isRestreamedStates[camera.name]} isRestreamed={isRestreamedStates[camera.name]}
supportsAudio={ supportsAudio={
@ -542,6 +598,8 @@ export default function DraggableGridLayout({
}); });
}} }}
onResetLiveMode={() => resetPreferredLiveMode(camera.name)} onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
playAudio={audioStates[camera.name]}
volume={volumeStates[camera.name]}
/> />
{isEditMode && showCircles && <CornerCircles />} {isEditMode && showCircles && <CornerCircles />}
</GridLiveContextMenu> </GridLiveContextMenu>
@ -694,6 +752,8 @@ type GridLiveContextMenuProps = {
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>; onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
children?: React.ReactNode; children?: React.ReactNode;
camera: string; camera: string;
streamName: string;
cameraGroup: string;
preferredLiveMode: string; preferredLiveMode: string;
isRestreamed: boolean; isRestreamed: boolean;
supportsAudio: boolean; supportsAudio: boolean;
@ -718,6 +778,8 @@ const GridLiveContextMenu = React.forwardRef<
onTouchEnd, onTouchEnd,
children, children,
camera, camera,
streamName,
cameraGroup,
preferredLiveMode, preferredLiveMode,
isRestreamed, isRestreamed,
supportsAudio, supportsAudio,
@ -743,6 +805,8 @@ const GridLiveContextMenu = React.forwardRef<
> >
<LiveContextMenu <LiveContextMenu
camera={camera} camera={camera}
streamName={streamName}
cameraGroup={cameraGroup}
preferredLiveMode={preferredLiveMode} preferredLiveMode={preferredLiveMode}
isRestreamed={isRestreamed} isRestreamed={isRestreamed}
supportsAudio={supportsAudio} supportsAudio={supportsAudio}

View File

@ -14,11 +14,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { usePersistence } from "@/hooks/use-persistence"; import { usePersistence } from "@/hooks/use-persistence";
import { import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
AllGroupsStreamingSettings,
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 { import {
@ -197,18 +193,6 @@ export default function LiveDashboardView({
supportsAudioOutputStates, supportsAudioOutputStates,
} = useCameraLiveMode(cameras, windowVisible); } = useCameraLiveMode(cameras, windowVisible);
const [allGroupsStreamingSettings, setAllGroupsStreamingSettings] =
useState<AllGroupsStreamingSettings>({});
const [persistedStreamingSettings, _, isStreamingSettingsLoaded] =
usePersistence<AllGroupsStreamingSettings>("streaming-settings");
useEffect(() => {
if (isStreamingSettingsLoaded) {
setAllGroupsStreamingSettings(persistedStreamingSettings ?? {});
}
}, [isStreamingSettingsLoaded, persistedStreamingSettings]);
const cameraRef = useCallback( const cameraRef = useCallback(
(node: HTMLElement | null) => { (node: HTMLElement | null) => {
if (!visibleCameraObserver.current) { if (!visibleCameraObserver.current) {
@ -280,9 +264,7 @@ export default function LiveDashboardView({
<div className="relative flex h-11 items-center justify-between"> <div className="relative flex h-11 items-center justify-between">
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" /> <Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
<div className="max-w-[45%]"> <div className="max-w-[45%]">
<CameraGroupSelector <CameraGroupSelector />
setAllGroupsStreamingSettings={setAllGroupsStreamingSettings}
/>
</div> </div>
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && ( {(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -401,6 +383,7 @@ export default function LiveDashboardView({
<LiveContextMenu <LiveContextMenu
key={camera.name} key={camera.name}
camera={camera.name} camera={camera.name}
streamName={Object.values(camera.live.streams)?.[0]}
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"} preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
isRestreamed={isRestreamedStates[camera.name]} isRestreamed={isRestreamedStates[camera.name]}
supportsAudio={ supportsAudio={
@ -489,8 +472,6 @@ export default function LiveDashboardView({
setIsEditMode={setIsEditMode} setIsEditMode={setIsEditMode}
fullscreen={fullscreen} fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen} toggleFullscreen={toggleFullscreen}
allGroupsStreamingSettings={allGroupsStreamingSettings}
setAllGroupsStreamingSettings={setAllGroupsStreamingSettings}
/> />
)} )}
</div> </div>