mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-18 17:14:26 +03:00
streaming dialog from context menu
This commit is contained in:
parent
826dbc4da3
commit
95047308ba
@ -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" && (
|
||||
<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}
|
||||
selectedCameras={field.value}
|
||||
config={config}
|
||||
groupStreamingSettings={groupStreamingSettings}
|
||||
setGroupStreamingSettings={
|
||||
setGroupStreamingSettings
|
||||
}
|
||||
setIsDialogOpen={(isOpen) =>
|
||||
setOpenCamera(isOpen ? camera : null)
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
<Switch
|
||||
id={camera.replaceAll("_", " ")}
|
||||
|
||||
@ -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,8 +151,27 @@ 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}>
|
||||
<>
|
||||
<ContextMenu key={camera} onOpenChange={handleOpenChange}>
|
||||
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<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">
|
||||
<VolumeIcon
|
||||
className="size-5"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toggleAudio();
|
||||
}}
|
||||
onClick={handleVolumeIconClick}
|
||||
/>
|
||||
<VolumeSlider
|
||||
disabled={!audioState}
|
||||
@ -84,9 +195,7 @@ export default function LiveContextMenu({
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.02}
|
||||
onValueChange={(value) => {
|
||||
setVolumeState(value[0]);
|
||||
}}
|
||||
onValueChange={handleVolumeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -110,13 +219,13 @@ export default function LiveContextMenu({
|
||||
<div className="text-primary">Unmute All Cameras</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
{isRestreamed && (
|
||||
{isRestreamed && cameraGroup && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem>
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center justify-start gap-2"
|
||||
onClick={() => {}}
|
||||
onClick={() => setShowSettings(true)}
|
||||
>
|
||||
<div className="text-primary">Streaming Settings</div>
|
||||
</div>
|
||||
@ -138,5 +247,16 @@ export default function LiveContextMenu({
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<Dialog open={showSettings} onOpenChange={setShowSettings}>
|
||||
<CameraStreamingDialog
|
||||
camera={camera}
|
||||
groupStreamingSettings={groupStreamingSettings}
|
||||
setGroupStreamingSettings={setGroupStreamingSettings}
|
||||
setIsDialogOpen={setShowSettings}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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,32 +129,13 @@ 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">
|
||||
@ -162,6 +166,43 @@ export function CameraStreamingDialog({
|
||||
),
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
@ -192,9 +233,9 @@ export function CameraStreamingDialog({
|
||||
)}
|
||||
{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
|
||||
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>
|
||||
)}
|
||||
@ -231,9 +272,9 @@ export function CameraStreamingDialog({
|
||||
</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.
|
||||
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>
|
||||
@ -266,6 +307,5 @@ export function CameraStreamingDialog({
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -235,6 +235,8 @@ export type CameraStreamingSettings = {
|
||||
streamName: string;
|
||||
streamType: StreamType;
|
||||
compatibilityMode: boolean;
|
||||
playAudio: boolean;
|
||||
volume: number;
|
||||
};
|
||||
|
||||
export type GroupStreamingSettings = {
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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;
|
||||
});
|
||||
setAudioStates(updatedStates);
|
||||
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,
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const unmuteAll = (): void => {
|
||||
const updatedStates: Record<string, boolean> = {};
|
||||
visibleCameras.forEach((cameraName) => {
|
||||
updatedStates[cameraName] = true;
|
||||
setAllGroupsStreamingSettings?.(updatedSettings);
|
||||
},
|
||||
[cameraGroup, allGroupsStreamingSettings, setAllGroupsStreamingSettings],
|
||||
);
|
||||
|
||||
const muteAll = () => {
|
||||
const updatedStates: AudioState = {};
|
||||
cameras.forEach((camera) => {
|
||||
updatedStates[camera.name] = false;
|
||||
});
|
||||
setAudioStates(updatedStates);
|
||||
onSaveMuting(false);
|
||||
};
|
||||
|
||||
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}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user