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

View File

@ -1,4 +1,11 @@
import { ReactNode, useMemo } from "react";
import {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
ContextMenu,
ContextMenuContent,
@ -12,10 +19,19 @@ import {
MdVolumeOff,
MdVolumeUp,
} from "react-icons/md";
import { Dialog } from "@/components/ui/dialog";
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 = {
camera: string;
streamName: string;
cameraGroup?: string;
preferredLiveMode: string;
isRestreamed: boolean;
supportsAudio: boolean;
@ -30,6 +46,8 @@ type LiveContextMenuProps = {
};
export default function LiveContextMenu({
camera,
streamName,
cameraGroup,
preferredLiveMode,
isRestreamed,
supportsAudio,
@ -42,6 +60,83 @@ export default function LiveContextMenu({
resetPreferredLiveMode,
children,
}: 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(() => {
if (!volumeState || volumeState == 0.0 || !audioState) {
return MdVolumeOff;
@ -56,87 +151,112 @@ export default function LiveContextMenu({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 (
<ContextMenu key={camera}>
<ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent>
<div className="text-md py-1 pl-2 capitalize text-primary-variant">
{camera.replaceAll("_", " ")}
</div>
{preferredLiveMode != "jsmpeg" && isRestreamed && supportsAudio && (
<>
<ContextMenuSeparator className="mb-1" />
<div className="p-2 text-sm">
<div className="flex w-full flex-col gap-1">
<p>Audio</p>
<div className="flex flex-row items-center gap-1">
<VolumeIcon
className="size-5"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
toggleAudio();
}}
/>
<VolumeSlider
disabled={!audioState}
className="my-3 ml-0.5 rounded-lg bg-background/60"
value={[volumeState ?? 0]}
min={0}
max={1}
step={0.02}
onValueChange={(value) => {
setVolumeState(value[0]);
}}
/>
<>
<ContextMenu key={camera} onOpenChange={handleOpenChange}>
<ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent>
<div className="text-md py-1 pl-2 capitalize text-primary-variant">
{camera.replaceAll("_", " ")}
</div>
{preferredLiveMode != "jsmpeg" && isRestreamed && supportsAudio && (
<>
<ContextMenuSeparator className="mb-1" />
<div className="p-2 text-sm">
<div className="flex w-full flex-col gap-1">
<p>Audio</p>
<div className="flex flex-row items-center gap-1">
<VolumeIcon
className="size-5"
onClick={handleVolumeIconClick}
/>
<VolumeSlider
disabled={!audioState}
className="my-3 ml-0.5 rounded-lg bg-background/60"
value={[volumeState ?? 0]}
min={0}
max={1}
step={0.02}
onValueChange={handleVolumeChange}
/>
</div>
</div>
</div>
</>
)}
<ContextMenuSeparator />
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={muteAll}
>
<div className="text-primary">Mute All Cameras</div>
</div>
</>
)}
<ContextMenuSeparator />
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={muteAll}
>
<div className="text-primary">Mute All Cameras</div>
</div>
</ContextMenuItem>
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={unmuteAll}
>
<div className="text-primary">Unmute All Cameras</div>
</div>
</ContextMenuItem>
{isRestreamed && (
<>
<ContextMenuSeparator />
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={() => {}}
>
<div className="text-primary">Streaming Settings</div>
</div>
</ContextMenuItem>
</>
)}
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<>
<ContextMenuSeparator />
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={resetPreferredLiveMode}
>
<div className="text-primary">Reset</div>
</div>
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
</ContextMenuItem>
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={unmuteAll}
>
<div className="text-primary">Unmute All Cameras</div>
</div>
</ContextMenuItem>
{isRestreamed && cameraGroup && (
<>
<ContextMenuSeparator />
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={() => setShowSettings(true)}
>
<div className="text-primary">Streaming Settings</div>
</div>
</ContextMenuItem>
</>
)}
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<>
<ContextMenuSeparator />
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={resetPreferredLiveMode}
>
<div className="text-primary">Reset</div>
</div>
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</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 useNavigation from "@/hooks/use-navigation";
import { baseUrl } from "@/api/baseUrl";
import { useEffect, useMemo, useState } from "react";
import { usePersistence } from "@/hooks/use-persistence";
import { AllGroupsStreamingSettings } from "@/types/frigateConfig";
import { useMemo } from "react";
function Sidebar() {
const basePath = useMemo(() => new URL(baseUrl).pathname, []);
@ -18,18 +16,6 @@ function Sidebar() {
const navbarLinks = useNavigation();
const [, setAllGroupsStreamingSettings] =
useState<AllGroupsStreamingSettings>({});
const [persistedStreamingSettings, _, isStreamingSettingsLoaded] =
usePersistence<AllGroupsStreamingSettings>("streaming-settings");
useEffect(() => {
if (isStreamingSettingsLoaded) {
setAllGroupsStreamingSettings(persistedStreamingSettings ?? {});
}
}, [isStreamingSettingsLoaded, persistedStreamingSettings]);
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">
<span tabIndex={0} className="sr-only" />
@ -48,12 +34,7 @@ function Sidebar() {
item={item}
Icon={item.icon}
/>
{showCameraGroups && (
<CameraGroupSelector
setAllGroupsStreamingSettings={setAllGroupsStreamingSettings}
className="mb-4"
/>
)}
{showCameraGroups && <CameraGroupSelector className="mb-4" />}
</div>
);
})}

View File

@ -2,11 +2,9 @@ import { useState, useCallback, useEffect } from "react";
import { IoIosWarning } from "react-icons/io";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogDescription,
} from "@/components/ui/dialog";
import {
@ -16,35 +14,48 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import {
FrigateConfig,
GroupStreamingSettings,
StreamType,
} from "@/types/frigateConfig";
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 = {
camera: string;
selectedCameras: string[];
config?: FrigateConfig;
groupStreamingSettings: GroupStreamingSettings;
setGroupStreamingSettings: React.Dispatch<
React.SetStateAction<GroupStreamingSettings>
>;
setIsDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
onSave?: (settings: GroupStreamingSettings) => void;
};
export function CameraStreamingDialog({
camera,
selectedCameras,
config,
groupStreamingSettings,
setGroupStreamingSettings,
setIsDialogOpen,
onSave,
}: 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 [streamName, setStreamName] = useState(
@ -74,18 +85,30 @@ export function CameraStreamingDialog({
const handleSave = useCallback(() => {
setIsLoading(true);
setGroupStreamingSettings((prevSettings) => ({
...prevSettings,
[camera]: { streamName, streamType, compatibilityMode },
}));
const updatedSettings = {
...groupStreamingSettings,
[camera]: {
streamName,
streamType,
compatibilityMode,
playAudio: groupStreamingSettings[camera]?.playAudio ?? false,
volume: groupStreamingSettings[camera]?.volume ?? 1,
},
};
setGroupStreamingSettings(updatedSettings);
setIsDialogOpen(false);
setIsLoading(false);
onSave?.(updatedSettings);
}, [
groupStreamingSettings,
setGroupStreamingSettings,
camera,
streamName,
streamType,
compatibilityMode,
setIsDialogOpen,
onSave,
]);
const handleCancel = useCallback(() => {
@ -106,166 +129,183 @@ export function CameraStreamingDialog({
setCompatibilityMode(false);
}
setIsDialogOpen(false);
}, [groupStreamingSettings, camera, config]);
}, [groupStreamingSettings, camera, config, setIsDialogOpen]);
if (!config) {
return null;
}
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]">
<DialogHeader className="mb-4">
<DialogTitle className="capitalize">
{camera.replaceAll("_", " ")} Streaming Settings
</DialogTitle>
<DialogDescription>
Change the live streaming options for this camera group's dashboard.{" "}
<em>These settings are device/browser-specific.</em>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col space-y-8">
{Object.entries(config?.cameras[camera].live.streams).length > 1 && (
<div className="flex flex-col items-start gap-2">
<Label htmlFor="stream" className="text-right">
Stream
</Label>
<Select value={streamName} onValueChange={setStreamName}>
<SelectTrigger className="">
<SelectValue placeholder="Choose a stream" />
</SelectTrigger>
<SelectContent>
{camera !== "birdseye" &&
Object.entries(config?.cameras[camera].live.streams).map(
([name, stream]) => (
<SelectItem key={stream} value={stream}>
{name}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
)}
<DialogContent className="sm:max-w-[425px]">
<DialogHeader className="mb-4">
<DialogTitle className="capitalize">
{camera.replaceAll("_", " ")} Streaming Settings
</DialogTitle>
<DialogDescription>
Change the live streaming options for this camera group's dashboard.{" "}
<em>These settings are device/browser-specific.</em>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col space-y-8">
{Object.entries(config?.cameras[camera].live.streams).length > 1 && (
<div className="flex flex-col items-start gap-2">
<Label htmlFor="streaming-method" className="text-right">
Streaming Method
<Label htmlFor="stream" className="text-right">
Stream
</Label>
<Select
value={streamType}
onValueChange={(value) => setStreamType(value as StreamType)}
>
<Select value={streamName} onValueChange={setStreamName}>
<SelectTrigger className="">
<SelectValue placeholder="Choose a streaming option" />
<SelectValue placeholder="Choose a stream" />
</SelectTrigger>
<SelectContent>
<SelectItem value="no-streaming">No Streaming</SelectItem>
<SelectItem value="smart">
Smart Streaming (recommended)
</SelectItem>
<SelectItem value="continuous">Continuous Streaming</SelectItem>
{camera !== "birdseye" &&
Object.entries(config?.cameras[camera].live.streams).map(
([name, stream]) => (
<SelectItem key={stream} value={stream}>
{name}
</SelectItem>
),
)}
</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>
{streamType === "no-streaming" && (
<p className="text-sm text-muted-foreground">
Camera images will only update once per minute and no live
streaming will occur.
</p>
)}
{streamType === "smart" && (
<p className="text-sm text-muted-foreground">
Smart streaming will update your camera image once per minute
when no detectable activity is occurring to conserve bandwidth
and resources. When activity is detected, the image seamlessly
switches to a live stream.
</p>
)}
{streamType === "continuous" && (
<>
<p className="text-sm text-muted-foreground">
Camera image will always be a live stream when visible on the
dashboard, even if no activity is being detected.
</p>
<div className="flex items-center gap-2">
<IoIosWarning className="mr-2 size-5 text-danger" />
<div className="max-w-[85%] text-sm">
Continuous streaming may cause high bandwidth usage and
performance issues. Use with caution.
</div>
</div>
</>
)}
</div>
<div className="flex flex-col items-start gap-2">
<div className="flex items-center gap-2">
<Checkbox
id="compatibility"
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={compatibilityMode}
onCheckedChange={() => setCompatibilityMode(!compatibilityMode)}
/>
<Label
htmlFor="compatibility"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Compatibility mode
</Label>
</div>
<div className="flex flex-col gap-2 leading-none">
)}
<div className="flex flex-col items-start gap-2">
<Label htmlFor="streaming-method" className="text-right">
Streaming Method
</Label>
<Select
value={streamType}
onValueChange={(value) => setStreamType(value as StreamType)}
>
<SelectTrigger className="">
<SelectValue placeholder="Choose a streaming option" />
</SelectTrigger>
<SelectContent>
<SelectItem value="no-streaming">No Streaming</SelectItem>
<SelectItem value="smart">
Smart Streaming (recommended)
</SelectItem>
<SelectItem value="continuous">Continuous Streaming</SelectItem>
</SelectContent>
</Select>
{streamType === "no-streaming" && (
<p className="text-sm text-muted-foreground">
Camera images will only update once per minute and no live
streaming will occur.
</p>
)}
{streamType === "smart" && (
<p className="text-sm text-muted-foreground">
Smart streaming will update your camera image once per minute when
no detectable activity is occurring to conserve bandwidth and
resources. When activity is detected, the image seamlessly
switches to a live stream.
</p>
)}
{streamType === "continuous" && (
<>
<p className="text-sm text-muted-foreground">
Enable this option only if your camera's live stream is
displaying color artifacts and has a diagonal line on the right
side of the image.
Camera image will always be a live stream when visible on the
dashboard, even if no activity is being detected.
</p>
</div>
<div className="flex items-center gap-2">
<IoIosWarning className="mr-2 size-5 text-danger" />
<div className="max-w-[85%] text-sm">
Continuous streaming may cause high bandwidth usage and
performance issues. Use with caution.
</div>
</div>
</>
)}
</div>
<div className="flex flex-col items-start gap-2">
<div className="flex items-center gap-2">
<Checkbox
id="compatibility"
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={compatibilityMode}
onCheckedChange={() => setCompatibilityMode(!compatibilityMode)}
/>
<Label
htmlFor="compatibility"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Compatibility mode
</Label>
</div>
<div className="flex flex-col gap-2 leading-none">
<p className="text-sm text-muted-foreground">
Enable this option only if your camera's live stream is displaying
color artifacts and has a diagonal line on the right side of the
image.
</p>
</div>
</div>
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label="Cancel"
onClick={handleCancel}
>
Cancel
</Button>
<Button
variant="select"
aria-label="Save"
disabled={isLoading}
className="flex flex-1"
onClick={handleSave}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
</div>
) : (
"Save"
)}
</Button>
</div>
</div>
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label="Cancel"
onClick={handleCancel}
>
Cancel
</Button>
<Button
variant="select"
aria-label="Save"
disabled={isLoading}
className="flex flex-1"
onClick={handleSave}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
</div>
) : (
"Save"
)}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</DialogContent>
);
}

View File

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

View File

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

View File

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

View File

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