Dynamically enable/disable cameras (#16894)

* config options

* metrics

* stop and restart ffmpeg processes

* dispatcher

* frontend websocket

* buttons for testing

* don't recreate log pipe

* add/remove cam from birdseye when enabling/disabling

* end all objects and send empty camera activity

* enable/disable switch in ui

* disable buttons when camera is disabled

* use enabled_in_config for some frontend checks

* tweaks

* handle settings pane with disabled cameras

* frontend tweaks

* change to debug log

* mqtt docs

* tweak

* ensure all ffmpeg processes are initially started

* clean up

* use zmq

* remove camera metrics

* remove camera metrics

* tweaks

* frontend tweaks
This commit is contained in:
Josh Hawkins
2025-03-03 09:30:52 -06:00
committed by GitHub
parent 71e6e04d77
commit 531042467a
24 changed files with 713 additions and 202 deletions

View File

@@ -56,6 +56,7 @@ function useValue(): useValueReturn {
const {
record,
detect,
enabled,
snapshots,
audio,
notifications,
@@ -67,6 +68,7 @@ function useValue(): useValueReturn {
// @ts-expect-error we know this is correct
state["config"];
cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF";
cameraStates[`${name}/enabled/state`] = enabled ? "ON" : "OFF";
cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF";
cameraStates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF";
cameraStates[`${name}/audio/state`] = audio ? "ON" : "OFF";
@@ -164,6 +166,17 @@ export function useWs(watchTopic: string, publishTopic: string) {
return { value, send };
}
export function useEnabledState(camera: string): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(`${camera}/enabled/state`, `${camera}/enabled/set`);
return { payload: payload as ToggleableSetting, send };
}
export function useDetectState(camera: string): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;

View File

@@ -5,6 +5,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
import { useResizeObserver } from "@/hooks/resize-observer";
import { isDesktop } from "react-device-detect";
import { cn } from "@/lib/utils";
import { useEnabledState } from "@/api/ws";
type CameraImageProps = {
className?: string;
@@ -26,7 +27,8 @@ export default function CameraImage({
const imgRef = useRef<HTMLImageElement | null>(null);
const { name } = config ? config.cameras[camera] : "";
const enabled = config ? config.cameras[camera].enabled : "True";
const { payload: enabledState } = useEnabledState(camera);
const enabled = enabledState === "ON" || enabledState === undefined;
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef);
@@ -96,9 +98,7 @@ export default function CameraImage({
loading="lazy"
/>
) : (
<div className="pt-6 text-center">
Camera is disabled in config, no stream or snapshot available!
</div>
<div className="size-full rounded-lg border-2 border-muted bg-background_alt text-center md:rounded-2xl" />
)}
{!imageLoaded && enabled ? (
<div className="absolute bottom-0 left-0 right-0 top-0 flex items-center justify-center">

View File

@@ -108,9 +108,7 @@ export default function CameraImage({
width={scaledWidth}
/>
) : (
<div className="pt-6 text-center">
Camera is disabled in config, no stream or snapshot available!
</div>
<div className="pt-6 text-center">Camera is disabled.</div>
)}
{!hasLoaded && enabled ? (
<div

View File

@@ -11,11 +11,15 @@ const variants = {
primary: {
active: "font-bold text-white bg-selected rounded-lg",
inactive: "text-secondary-foreground bg-secondary rounded-lg",
disabled:
"text-secondary-foreground bg-secondary rounded-lg cursor-not-allowed opacity-50",
},
overlay: {
active: "font-bold text-white bg-selected rounded-full",
inactive:
"text-primary rounded-full bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500",
disabled:
"bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500 rounded-full cursor-not-allowed opacity-50",
},
};
@@ -26,6 +30,7 @@ type CameraFeatureToggleProps = {
Icon: IconType;
title: string;
onClick?: () => void;
disabled?: boolean; // New prop for disabling
};
export default function CameraFeatureToggle({
@@ -35,18 +40,28 @@ export default function CameraFeatureToggle({
Icon,
title,
onClick,
disabled = false, // Default to false
}: CameraFeatureToggleProps) {
const content = (
<div
onClick={onClick}
onClick={disabled ? undefined : onClick}
className={cn(
"flex flex-col items-center justify-center",
variants[variant][isActive ? "active" : "inactive"],
disabled
? variants[variant].disabled
: variants[variant][isActive ? "active" : "inactive"],
className,
)}
>
<Icon
className={`size-5 md:m-[6px] ${isActive ? "text-white" : "text-secondary-foreground"}`}
className={cn(
"size-5 md:m-[6px]",
disabled
? "text-gray-400"
: isActive
? "text-white"
: "text-secondary-foreground",
)}
/>
</div>
);
@@ -54,7 +69,7 @@ export default function CameraFeatureToggle({
if (isDesktop) {
return (
<Tooltip>
<TooltipTrigger>{content}</TooltipTrigger>
<TooltipTrigger disabled={disabled}>{content}</TooltipTrigger>
<TooltipContent side="bottom">
<p>{title}</p>
</TooltipContent>

View File

@@ -39,7 +39,11 @@ import {
import { cn } from "@/lib/utils";
import { useNavigate } from "react-router-dom";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { useNotifications, useNotificationSuspend } from "@/api/ws";
import {
useEnabledState,
useNotifications,
useNotificationSuspend,
} from "@/api/ws";
type LiveContextMenuProps = {
className?: string;
@@ -83,6 +87,11 @@ export default function LiveContextMenu({
}: LiveContextMenuProps) {
const [showSettings, setShowSettings] = useState(false);
// camera enabled
const { payload: enabledState, send: sendEnabled } = useEnabledState(camera);
const isEnabled = enabledState === "ON";
// streaming settings
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
@@ -263,7 +272,7 @@ export default function LiveContextMenu({
onClick={handleVolumeIconClick}
/>
<VolumeSlider
disabled={!audioState}
disabled={!audioState || !isEnabled}
className="my-3 ml-0.5 rounded-lg bg-background/60"
value={[volumeState ?? 0]}
min={0}
@@ -280,34 +289,49 @@ export default function LiveContextMenu({
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={muteAll}
onClick={() => sendEnabled(isEnabled ? "OFF" : "ON")}
>
<div className="text-primary">
{isEnabled ? "Disable" : "Enable"} Camera
</div>
</div>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={isEnabled ? muteAll : undefined}
>
<div className="text-primary">Mute All Cameras</div>
</div>
</ContextMenuItem>
<ContextMenuItem>
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={unmuteAll}
onClick={isEnabled ? unmuteAll : undefined}
>
<div className="text-primary">Unmute All Cameras</div>
</div>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem>
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={toggleStats}
onClick={isEnabled ? toggleStats : undefined}
>
<div className="text-primary">
{statsState ? "Hide" : "Show"} Stream Stats
</div>
</div>
</ContextMenuItem>
<ContextMenuItem>
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={() => navigate(`/settings?page=debug&camera=${camera}`)}
onClick={
isEnabled
? () => navigate(`/settings?page=debug&camera=${camera}`)
: undefined
}
>
<div className="text-primary">Debug View</div>
</div>
@@ -315,10 +339,10 @@ export default function LiveContextMenu({
{cameraGroup && cameraGroup !== "default" && (
<>
<ContextMenuSeparator />
<ContextMenuItem>
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={() => setShowSettings(true)}
onClick={isEnabled ? () => setShowSettings(true) : undefined}
>
<div className="text-primary">Streaming Settings</div>
</div>
@@ -328,10 +352,10 @@ export default function LiveContextMenu({
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<>
<ContextMenuSeparator />
<ContextMenuItem>
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={resetPreferredLiveMode}
onClick={isEnabled ? resetPreferredLiveMode : undefined}
>
<div className="text-primary">Reset</div>
</div>
@@ -342,7 +366,7 @@ export default function LiveContextMenu({
<>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>
<ContextMenuSubTrigger disabled={!isEnabled}>
<div className="flex items-center gap-2">
<span>Notifications</span>
</div>
@@ -382,10 +406,15 @@ export default function LiveContextMenu({
<>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
sendNotification("ON");
sendNotificationSuspend(0);
}}
disabled={!isEnabled}
onClick={
isEnabled
? () => {
sendNotification("ON");
sendNotificationSuspend(0);
}
: undefined
}
>
<div className="flex w-full flex-col gap-2">
{notificationState === "ON" ? (
@@ -405,36 +434,71 @@ export default function LiveContextMenu({
Suspend for:
</p>
<div className="space-y-1">
<ContextMenuItem onClick={() => handleSuspend("5")}>
<ContextMenuItem
disabled={!isEnabled}
onClick={
isEnabled ? () => handleSuspend("5") : undefined
}
>
5 minutes
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleSuspend("10")}
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("10")
: undefined
}
>
10 minutes
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleSuspend("30")}
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("30")
: undefined
}
>
30 minutes
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleSuspend("60")}
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("60")
: undefined
}
>
1 hour
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleSuspend("840")}
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("840")
: undefined
}
>
12 hours
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleSuspend("1440")}
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("1440")
: undefined
}
>
24 hours
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleSuspend("off")}
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("off")
: undefined
}
>
Until restart
</ContextMenuItem>

View File

@@ -22,6 +22,7 @@ import { TbExclamationCircle } from "react-icons/tb";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { baseUrl } from "@/api/baseUrl";
import { PlayerStats } from "./PlayerStats";
import { LuVideoOff } from "react-icons/lu";
type LivePlayerProps = {
cameraRef?: (ref: HTMLDivElement | null) => void;
@@ -86,8 +87,13 @@ export default function LivePlayer({
// camera activity
const { activeMotion, activeTracking, objects, offline } =
useCameraActivity(cameraConfig);
const {
enabled: cameraEnabled,
activeMotion,
activeTracking,
objects,
offline,
} = useCameraActivity(cameraConfig);
const cameraActive = useMemo(
() =>
@@ -191,12 +197,37 @@ export default function LivePlayer({
setLiveReady(true);
}, []);
// enabled states
const [isReEnabling, setIsReEnabling] = useState(false);
const prevCameraEnabledRef = useRef(cameraEnabled);
useEffect(() => {
if (!prevCameraEnabledRef.current && cameraEnabled) {
// Camera enabled
setLiveReady(false);
setIsReEnabling(true);
setKey((prevKey) => prevKey + 1);
} else if (prevCameraEnabledRef.current && !cameraEnabled) {
// Camera disabled
setLiveReady(false);
setKey((prevKey) => prevKey + 1);
}
prevCameraEnabledRef.current = cameraEnabled;
}, [cameraEnabled]);
useEffect(() => {
if (liveReady && isReEnabling) {
setIsReEnabling(false);
}
}, [liveReady, isReEnabling]);
if (!cameraConfig) {
return <ActivityIndicator />;
}
let player;
if (!autoLive || !streamName) {
if (!autoLive || !streamName || !cameraEnabled) {
player = null;
} else if (preferredLiveMode == "webrtc") {
player = (
@@ -267,6 +298,22 @@ export default function LivePlayer({
player = <ActivityIndicator />;
}
// if (cameraConfig.name == "lpr")
// console.log(
// cameraConfig.name,
// "enabled",
// cameraEnabled,
// "prev enabled",
// prevCameraEnabledRef.current,
// "offline",
// offline,
// "show still",
// showStillWithoutActivity,
// "live ready",
// liveReady,
// player,
// );
return (
<div
ref={cameraRef ?? internalContainerRef}
@@ -287,16 +334,18 @@ export default function LivePlayer({
}
}}
>
{((showStillWithoutActivity && !liveReady) || liveReady) && (
<>
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl"></div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl"></div>
</>
)}
{cameraEnabled &&
((showStillWithoutActivity && !liveReady) || liveReady) && (
<>
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl"></div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl"></div>
</>
)}
{player}
{!offline && !showStillWithoutActivity && !liveReady && (
<ActivityIndicator />
)}
{cameraEnabled &&
!offline &&
(!showStillWithoutActivity || isReEnabling) &&
!liveReady && <ActivityIndicator />}
{((showStillWithoutActivity && !liveReady) || liveReady) &&
objects.length > 0 && (
@@ -344,7 +393,9 @@ export default function LivePlayer({
<div
className={cn(
"absolute inset-0 w-full",
showStillWithoutActivity && !liveReady ? "visible" : "invisible",
showStillWithoutActivity && !liveReady && !isReEnabling
? "visible"
: "invisible",
)}
>
<AutoUpdatingCameraImage
@@ -371,6 +422,17 @@ export default function LivePlayer({
</div>
)}
{!cameraEnabled && (
<div className="relative flex h-full w-full items-center justify-center">
<div className="flex h-32 flex-col items-center justify-center rounded-lg p-4 md:h-48 md:w-48">
<LuVideoOff className="mb-2 size-8 md:size-10" />
<p className="max-w-32 text-center text-sm md:max-w-40 md:text-base">
Camera is disabled
</p>
</div>
</div>
)}
<div className="absolute right-2 top-2">
{autoLive &&
!offline &&
@@ -378,7 +440,7 @@ export default function LivePlayer({
((showStillWithoutActivity && !liveReady) || liveReady) && (
<MdCircle className="mr-2 size-2 animate-pulse text-danger shadow-danger drop-shadow-md" />
)}
{offline && showStillWithoutActivity && (
{((offline && showStillWithoutActivity) || !cameraEnabled) && (
<Chip
className={`z-0 flex items-start justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize`}
>

View File

@@ -68,7 +68,7 @@ export default function ZoneEditPane({
}
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard && conf.enabled)
.filter((conf) => conf.ui.dashboard && conf.enabled_in_config)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);

View File

@@ -1,4 +1,5 @@
import {
useEnabledState,
useFrigateEvents,
useInitialCameraState,
useMotionActivity,
@@ -15,6 +16,7 @@ import useSWR from "swr";
import { getAttributeLabels } from "@/utils/iconUtil";
type useCameraActivityReturn = {
enabled: boolean;
activeTracking: boolean;
activeMotion: boolean;
objects: ObjectType[];
@@ -56,6 +58,7 @@ export function useCameraActivity(
[objects],
);
const { payload: cameraEnabled } = useEnabledState(camera.name);
const { payload: detectingMotion } = useMotionActivity(camera.name);
const { payload: event } = useFrigateEvents();
const updatedEvent = useDeepMemo(event);
@@ -145,12 +148,17 @@ export function useCameraActivity(
return cameras[camera.name].camera_fps == 0 && stats["service"].uptime > 60;
}, [camera, stats]);
const isCameraEnabled = cameraEnabled === "ON";
return {
activeTracking: hasActiveObjects,
activeMotion: detectingMotion
? detectingMotion === "ON"
: updatedCameraState?.motion === true,
objects,
enabled: isCameraEnabled,
activeTracking: isCameraEnabled ? hasActiveObjects : false,
activeMotion: isCameraEnabled
? detectingMotion
? detectingMotion === "ON"
: updatedCameraState?.motion === true
: false,
objects: isCameraEnabled ? objects : [],
offline,
};
}

View File

@@ -101,12 +101,14 @@ function Live() {
) {
const group = config.camera_groups[cameraGroup];
return Object.values(config.cameras)
.filter((conf) => conf.enabled && group.cameras.includes(conf.name))
.filter(
(conf) => conf.enabled_in_config && group.cameras.includes(conf.name),
)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard && conf.enabled)
.filter((conf) => conf.ui.dashboard && conf.enabled_in_config)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config, cameraGroup]);

View File

@@ -39,6 +39,7 @@ import SearchSettingsView from "@/views/settings/SearchSettingsView";
import UiSettingsView from "@/views/settings/UiSettingsView";
import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useSearchParams } from "react-router-dom";
import { useInitialCameraState } from "@/api/ws";
const allSettingsViews = [
"UI settings",
@@ -71,12 +72,33 @@ export default function Settings() {
}
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard && conf.enabled)
.filter((conf) => conf.ui.dashboard && conf.enabled_in_config)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const [selectedCamera, setSelectedCamera] = useState<string>("");
const { payload: allCameraStates } = useInitialCameraState(
cameras.length > 0 ? cameras[0].name : "",
true,
);
const cameraEnabledStates = useMemo(() => {
const states: Record<string, boolean> = {};
if (allCameraStates) {
Object.entries(allCameraStates).forEach(([camName, state]) => {
states[camName] = state.config?.enabled ?? false;
});
}
// fallback to config if ws data isnt available yet
cameras.forEach((cam) => {
if (!(cam.name in states)) {
states[cam.name] = cam.enabled;
}
});
return states;
}, [allCameraStates, cameras]);
const [filterZoneMask, setFilterZoneMask] = useState<PolygonType[]>();
const handleDialog = useCallback(
@@ -91,10 +113,25 @@ export default function Settings() {
);
useEffect(() => {
if (cameras.length > 0 && selectedCamera === "") {
setSelectedCamera(cameras[0].name);
if (cameras.length > 0) {
if (!selectedCamera) {
// Set to first enabled camera initially if no selection
const firstEnabledCamera =
cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0];
setSelectedCamera(firstEnabledCamera.name);
} else if (
!cameraEnabledStates[selectedCamera] &&
page !== "camera settings"
) {
// Switch to first enabled camera if current one is disabled, unless on "camera settings" page
const firstEnabledCamera =
cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0];
if (firstEnabledCamera.name !== selectedCamera) {
setSelectedCamera(firstEnabledCamera.name);
}
}
}
}, [cameras, selectedCamera]);
}, [cameras, selectedCamera, cameraEnabledStates, page]);
useEffect(() => {
if (tabsRef.current) {
@@ -177,6 +214,8 @@ export default function Settings() {
allCameras={cameras}
selectedCamera={selectedCamera}
setSelectedCamera={setSelectedCamera}
cameraEnabledStates={cameraEnabledStates}
currentPage={page}
/>
</div>
)}
@@ -244,17 +283,21 @@ type CameraSelectButtonProps = {
allCameras: CameraConfig[];
selectedCamera: string;
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
cameraEnabledStates: Record<string, boolean>;
currentPage: SettingsType;
};
function CameraSelectButton({
allCameras,
selectedCamera,
setSelectedCamera,
cameraEnabledStates,
currentPage,
}: CameraSelectButtonProps) {
const [open, setOpen] = useState(false);
if (!allCameras.length) {
return;
return null;
}
const trigger = (
@@ -283,19 +326,24 @@ function CameraSelectButton({
)}
<div className="scrollbar-container mb-5 h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden p-4 md:mb-1">
<div className="flex flex-col gap-2.5">
{allCameras.map((item) => (
<FilterSwitch
key={item.name}
isChecked={item.name === selectedCamera}
label={item.name.replaceAll("_", " ")}
onCheckedChange={(isChecked) => {
if (isChecked) {
setSelectedCamera(item.name);
setOpen(false);
}
}}
/>
))}
{allCameras.map((item) => {
const isEnabled = cameraEnabledStates[item.name];
const isCameraSettingsPage = currentPage === "camera settings";
return (
<FilterSwitch
key={item.name}
isChecked={item.name === selectedCamera}
label={item.name.replaceAll("_", " ")}
onCheckedChange={(isChecked) => {
if (isChecked && (isEnabled || isCameraSettingsPage)) {
setSelectedCamera(item.name);
setOpen(false);
}
}}
disabled={!isEnabled && !isCameraSettingsPage}
/>
);
})}
</div>
</div>
</>

View File

@@ -57,6 +57,7 @@ export interface CameraConfig {
width: number;
};
enabled: boolean;
enabled_in_config: boolean;
ffmpeg: {
global_args: string[];
hwaccel_args: string;

View File

@@ -52,6 +52,7 @@ export type ObjectType = {
};
export interface FrigateCameraState {
enabled: boolean;
motion: boolean;
objects: ObjectType[];
}

View File

@@ -2,6 +2,7 @@ import {
useAudioState,
useAutotrackingState,
useDetectState,
useEnabledState,
usePtzCommand,
useRecordingsState,
useSnapshotsState,
@@ -82,6 +83,8 @@ import {
LuHistory,
LuInfo,
LuPictureInPicture,
LuPower,
LuPowerOff,
LuVideo,
LuVideoOff,
LuX,
@@ -185,6 +188,10 @@ export default function LiveCameraView({
);
}, [cameraMetadata]);
// camera enabled state
const { payload: enabledState } = useEnabledState(camera.name);
const cameraEnabled = enabledState === "ON";
// click overlay for ptzs
const [clickOverlay, setClickOverlay] = useState(false);
@@ -470,6 +477,7 @@ export default function LiveCameraView({
setPip(false);
}
}}
disabled={!cameraEnabled}
/>
)}
{supports2WayTalk && (
@@ -481,11 +489,11 @@ export default function LiveCameraView({
title={`${mic ? "Disable" : "Enable"} Two Way Talk`}
onClick={() => {
setMic(!mic);
// Turn on audio when enabling the mic if audio is currently off
if (!mic && !audio) {
setAudio(true);
}
}}
disabled={!cameraEnabled}
/>
)}
{supportsAudioOutput && preferredLiveMode != "jsmpeg" && (
@@ -496,6 +504,7 @@ export default function LiveCameraView({
isActive={audio ?? false}
title={`${audio ? "Disable" : "Enable"} Camera Audio`}
onClick={() => setAudio(!audio)}
disabled={!cameraEnabled}
/>
)}
<FrigateCameraFeatures
@@ -517,6 +526,7 @@ export default function LiveCameraView({
setLowBandwidth={setLowBandwidth}
supportsAudioOutput={supportsAudioOutput}
supports2WayTalk={supports2WayTalk}
cameraEnabled={cameraEnabled}
/>
</div>
</TooltipProvider>
@@ -913,6 +923,7 @@ type FrigateCameraFeaturesProps = {
setLowBandwidth: React.Dispatch<React.SetStateAction<boolean>>;
supportsAudioOutput: boolean;
supports2WayTalk: boolean;
cameraEnabled: boolean;
};
function FrigateCameraFeatures({
camera,
@@ -931,10 +942,14 @@ function FrigateCameraFeatures({
setLowBandwidth,
supportsAudioOutput,
supports2WayTalk,
cameraEnabled,
}: FrigateCameraFeaturesProps) {
const { payload: detectState, send: sendDetect } = useDetectState(
camera.name,
);
const { payload: enabledState, send: sendEnabled } = useEnabledState(
camera.name,
);
const { payload: recordState, send: sendRecord } = useRecordingsState(
camera.name,
);
@@ -1043,6 +1058,15 @@ function FrigateCameraFeatures({
if (isDesktop || isTablet) {
return (
<>
<CameraFeatureToggle
className="p-2 md:p-0"
variant={fullscreen ? "overlay" : "primary"}
Icon={enabledState == "ON" ? LuPower : LuPowerOff}
isActive={enabledState == "ON"}
title={`${enabledState == "ON" ? "Disable" : "Enable"} Camera`}
onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")}
disabled={false}
/>
<CameraFeatureToggle
className="p-2 md:p-0"
variant={fullscreen ? "overlay" : "primary"}
@@ -1050,6 +1074,7 @@ function FrigateCameraFeatures({
isActive={detectState == "ON"}
title={`${detectState == "ON" ? "Disable" : "Enable"} Detect`}
onClick={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
<CameraFeatureToggle
className="p-2 md:p-0"
@@ -1058,6 +1083,7 @@ function FrigateCameraFeatures({
isActive={recordState == "ON"}
title={`${recordState == "ON" ? "Disable" : "Enable"} Recording`}
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
<CameraFeatureToggle
className="p-2 md:p-0"
@@ -1066,6 +1092,7 @@ function FrigateCameraFeatures({
isActive={snapshotState == "ON"}
title={`${snapshotState == "ON" ? "Disable" : "Enable"} Snapshots`}
onClick={() => sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
{audioDetectEnabled && (
<CameraFeatureToggle
@@ -1075,6 +1102,7 @@ function FrigateCameraFeatures({
isActive={audioState == "ON"}
title={`${audioState == "ON" ? "Disable" : "Enable"} Audio Detect`}
onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
)}
{autotrackingEnabled && (
@@ -1087,6 +1115,7 @@ function FrigateCameraFeatures({
onClick={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
}
disabled={!cameraEnabled}
/>
)}
<CameraFeatureToggle
@@ -1099,6 +1128,7 @@ function FrigateCameraFeatures({
isActive={isRecording}
title={`${isRecording ? "Stop" : "Start"} on-demand recording`}
onClick={handleEventButtonClick}
disabled={!cameraEnabled}
/>
<DropdownMenu modal={false}>

View File

@@ -29,7 +29,7 @@ import { MdCircle } from "react-icons/md";
import { cn } from "@/lib/utils";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useAlertsState, useDetectionsState } from "@/api/ws";
import { useAlertsState, useDetectionsState, useEnabledState } from "@/api/ws";
type CameraSettingsViewProps = {
selectedCamera: string;
@@ -108,6 +108,8 @@ export default function CameraSettingsView({
const watchedAlertsZones = form.watch("alerts_zones");
const watchedDetectionsZones = form.watch("detections_zones");
const { payload: enabledState, send: sendEnabled } =
useEnabledState(selectedCamera);
const { payload: alertsState, send: sendAlerts } =
useAlertsState(selectedCamera);
const { payload: detectionsState, send: sendDetections } =
@@ -252,6 +254,31 @@ export default function CameraSettingsView({
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Streams
</Heading>
<div className="flex flex-row items-center">
<Switch
id="camera-enabled"
className="mr-3"
checked={enabledState === "ON"}
onCheckedChange={(isChecked) => {
sendEnabled(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="camera-enabled">Enable</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
Disabling a camera completely stops Frigate's processing of this
camera's streams. Detection, recording, and debugging will be
unavailable.
<br /> <em>Note: This does not disable go2rtc restreams.</em>
</div>
<Separator className="mb-2 mt-4 flex bg-secondary" />
<Heading as="h4" className="my-2">
Review
</Heading>

View File

@@ -80,7 +80,7 @@ export default function NotificationView({
return Object.values(config.cameras)
.filter(
(conf) =>
conf.enabled &&
conf.enabled_in_config &&
conf.notifications &&
conf.notifications.enabled_in_config,
)