mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-07 02:18:07 +01:00
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:
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 isn’t 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>
|
||||
</>
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface CameraConfig {
|
||||
width: number;
|
||||
};
|
||||
enabled: boolean;
|
||||
enabled_in_config: boolean;
|
||||
ffmpeg: {
|
||||
global_args: string[];
|
||||
hwaccel_args: string;
|
||||
|
||||
@@ -52,6 +52,7 @@ export type ObjectType = {
|
||||
};
|
||||
|
||||
export interface FrigateCameraState {
|
||||
enabled: boolean;
|
||||
motion: boolean;
|
||||
objects: ObjectType[];
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user