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:
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user