diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index d1eb4b49b..e024b3c80 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -1,10 +1,14 @@ -import { CameraGroupConfig, FrigateConfig } from "@/types/frigateConfig"; +import { + CameraGroupConfig, + FrigateConfig, + GroupStreamingSettingsType, +} from "@/types/frigateConfig"; import { isDesktop, isMobile } from "react-device-detect"; 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, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { LuPencil, LuPlus } from "react-icons/lu"; import { @@ -43,7 +47,6 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; import axios from "axios"; -import FilterSwitch from "./FilterSwitch"; import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi"; import IconWrapper from "../ui/icon-wrapper"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -66,6 +69,9 @@ import { MobilePageHeader, MobilePageTitle, } from "../mobile/MobilePage"; +import { Label } from "../ui/label"; +import { Switch } from "../ui/switch"; +import { CameraStreamingDialog } from "../settings/CameraStreamingDialog"; type CameraGroupSelectorProps = { className?: string; @@ -607,6 +613,14 @@ export function CameraGroupEdit({ const { data: config, mutate: updateConfig } = useSWR("config"); + const [groupStreamingSettings, setGroupStreamingSettings] = + useState({}); + + const [persistedGroupStreamingSettings, setPersistedGroupStreamingSettings] = + usePersistence<{ [groupName: string]: GroupStreamingSettingsType }>( + "streaming-settings", + ); + const birdseyeConfig = useMemo(() => config?.birdseye, [config]); const formSchema = z.object({ @@ -656,6 +670,18 @@ export function CameraGroupEdit({ setIsLoading(true); + // update streaming settings + const updatedSettings: { + [groupName: string]: GroupStreamingSettingsType; + } = { + ...Object.fromEntries( + Object.entries(persistedGroupStreamingSettings || {}).filter( + ([key]) => key !== editingGroup?.[0], + ), + ), + [values.name]: groupStreamingSettings, + }; + let renamingQuery = ""; if (editingGroup && editingGroup[0] !== values.name) { renamingQuery = `camera_groups.${editingGroup[0]}&`; @@ -679,7 +705,7 @@ export function CameraGroupEdit({ requires_restart: 0, }, ) - .then((res) => { + .then(async (res) => { if (res.status === 200) { toast.success(`Camera group (${values.name}) has been saved.`, { position: "top-center", @@ -688,6 +714,7 @@ export function CameraGroupEdit({ if (onSave) { onSave(); } + await setPersistedGroupStreamingSettings(updatedSettings); } else { toast.error(`Failed to save config changes: ${res.statusText}`, { position: "top-center", @@ -704,7 +731,16 @@ export function CameraGroupEdit({ setIsLoading(false); }); }, - [currentGroups, setIsLoading, onSave, updateConfig, editingGroup], + [ + currentGroups, + setIsLoading, + onSave, + updateConfig, + editingGroup, + groupStreamingSettings, + setPersistedGroupStreamingSettings, + persistedGroupStreamingSettings, + ], ); const form = useForm>({ @@ -717,6 +753,20 @@ export function CameraGroupEdit({ }, }); + // streaming settings + + useEffect(() => { + if (editingGroup && editingGroup[0] && persistedGroupStreamingSettings) { + setGroupStreamingSettings( + persistedGroupStreamingSettings[editingGroup[0]] || {}, + ); + } + }, [ + editingGroup, + persistedGroupStreamingSettings, + setGroupStreamingSettings, + ]); + return (
( - { - const updatedCameras = checked - ? [...(field.value || []), camera] - : (field.value || []).filter((c) => c !== camera); - form.setValue("cameras", updatedCameras); - }} - /> +
+ + +
+ {camera !== "birdseye" && ( + + )} + { + const updatedCameras = checked + ? [...(field.value || []), camera] + : (field.value || []).filter((c) => c !== camera); + form.setValue("cameras", updatedCameras); + }} + /> +
+
))} diff --git a/web/src/components/settings/CameraStreamingDialog.tsx b/web/src/components/settings/CameraStreamingDialog.tsx new file mode 100644 index 000000000..03ddf5cb6 --- /dev/null +++ b/web/src/components/settings/CameraStreamingDialog.tsx @@ -0,0 +1,253 @@ +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { + FrigateConfig, + GroupStreamingSettingsType, +} from "@/types/frigateConfig"; +import ActivityIndicator from "../indicators/activity-indicator"; +import { LuSettings } from "react-icons/lu"; + +type CameraStreamingDialogProps = { + camera: string; + selectedCameras: string[]; + config?: FrigateConfig; + groupStreamingSettings: GroupStreamingSettingsType; + setGroupStreamingSettings: React.Dispatch< + React.SetStateAction + >; +}; + +export function CameraStreamingDialog({ + camera, + selectedCameras, + config, + groupStreamingSettings, + setGroupStreamingSettings, +}: CameraStreamingDialogProps) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const [streamName, setStreamName] = useState(""); + const [streamType, setStreamType] = useState("smart"); + const [compatibilityMode, setCompatibilityMode] = useState(false); + + useEffect(() => { + if (groupStreamingSettings && groupStreamingSettings[camera]) { + const cameraSettings = groupStreamingSettings[camera]; + setStreamName(cameraSettings.streamName || ""); + setStreamType(cameraSettings.streamType || "smart"); + setCompatibilityMode(cameraSettings.compatibilityMode || false); + } else { + setStreamName(""); + setStreamType("smart"); + setCompatibilityMode(false); + } + }, [groupStreamingSettings, camera]); + + const handleSave = useCallback(() => { + setIsLoading(true); + setGroupStreamingSettings((prevSettings) => ({ + ...prevSettings, + [camera]: { streamName, streamType, compatibilityMode }, + })); + setIsDialogOpen(false); + setIsLoading(false); + }, [ + setGroupStreamingSettings, + camera, + streamName, + streamType, + compatibilityMode, + ]); + + const handleCancel = useCallback(() => { + if (groupStreamingSettings && groupStreamingSettings[camera]) { + const cameraSettings = groupStreamingSettings[camera]; + setStreamName(cameraSettings.streamName || ""); + setStreamType(cameraSettings.streamType || "smart"); + setCompatibilityMode(cameraSettings.compatibilityMode || false); + } else { + setStreamName(""); + setStreamType("smart"); + setCompatibilityMode(false); + } + setIsDialogOpen(false); + }, [groupStreamingSettings, camera]); + + if (!config) { + return null; + } + + return ( + + + + + + + + {camera.replaceAll("_", " ")} Streaming Settings + + + Change the live streaming options for this camera group's dashboard.{" "} + These settings are device/browser-specific. + + +
+ {Object.entries(config?.cameras[camera].live.streams).length > 1 && ( +
+ + +
+ )} +
+ + + {streamType === "no-streaming" && ( +

+ Camera images will only update once per minute and no live + streaming will occur. +

+ )} + {streamType === "smart" && ( +

+ 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. +

+ )} + {streamType === "continuous" && ( + <> +

+ Camera image will always be a live stream when visible on the + dashboard, even if no activity is being detected. +

+
+ +
+ Continuous streaming may cause high bandwidth usage and + performance issues. Use with caution. +
+
+ + )} +
+
+
+ setCompatibilityMode(!compatibilityMode)} + /> + +
+
+

+ 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. +

+
+
+
+
+
+ + +
+
+
+
+ ); +} diff --git a/web/src/hooks/use-camera-live-mode.ts b/web/src/hooks/use-camera-live-mode.ts index edf165951..eabc23aba 100644 --- a/web/src/hooks/use-camera-live-mode.ts +++ b/web/src/hooks/use-camera-live-mode.ts @@ -23,7 +23,7 @@ export default function useCameraLiveMode( const isRestreamed = config && Object.keys(config.go2rtc.streams || {}).includes( - camera.live.stream_name, + Object.values(camera.live.streams)[0], ); if (!mseSupported) { diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 8c3528f93..38d9baffd 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -229,6 +229,16 @@ export type CameraGroupConfig = { order: number; }; +export type CameraStreamingSettings = { + streamName: string; + streamType: string; + compatibilityMode: boolean; +}; + +export type GroupStreamingSettingsType = { + [cameraName: string]: CameraStreamingSettings; +}; + export interface FrigateConfig { audio: { enabled: boolean; diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx index fc2d9bb52..b559c5deb 100644 --- a/web/src/views/live/DraggableGridLayout.tsx +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -650,6 +650,7 @@ const LivePlayerGridItem = React.forwardRef< windowVisible={windowVisible} cameraConfig={cameraConfig} preferredLiveMode={preferredLiveMode} + streamName={Object.values(cameraConfig.live.streams)[0]} onClick={onClick} onError={onError} onResetLiveMode={onResetLiveMode} diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index 7642d5a0d..b9599d894 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -356,6 +356,7 @@ export default function LiveDashboardView({ cameraConfig={camera} preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"} autoLive={autoLiveView} + streamName={Object.values(camera.live.streams)[0]} onClick={() => onSelectCamera(camera.name)} onError={(e) => handleError(camera.name, e)} onResetLiveMode={() => resetPreferredLiveMode(camera.name)}