mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-03-04 00:17:22 +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:
parent
71e6e04d77
commit
531042467a
@ -222,6 +222,14 @@ Publishes the rms value for audio detected on this camera.
|
|||||||
|
|
||||||
**NOTE:** Requires audio detection to be enabled
|
**NOTE:** Requires audio detection to be enabled
|
||||||
|
|
||||||
|
### `frigate/<camera_name>/enabled/set`
|
||||||
|
|
||||||
|
Topic to turn Frigate's processing of a camera on and off. Expected values are `ON` and `OFF`.
|
||||||
|
|
||||||
|
### `frigate/<camera_name>/enabled/state`
|
||||||
|
|
||||||
|
Topic with current state of processing for a camera. Published values are `ON` and `OFF`.
|
||||||
|
|
||||||
### `frigate/<camera_name>/detect/set`
|
### `frigate/<camera_name>/detect/set`
|
||||||
|
|
||||||
Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`.
|
Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`.
|
||||||
|
@ -20,7 +20,7 @@ class CameraActivityManager:
|
|||||||
self.all_zone_labels: dict[str, set[str]] = {}
|
self.all_zone_labels: dict[str, set[str]] = {}
|
||||||
|
|
||||||
for camera_config in config.cameras.values():
|
for camera_config in config.cameras.values():
|
||||||
if not camera_config.enabled:
|
if not camera_config.enabled_in_config:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.last_camera_activity[camera_config.name] = {}
|
self.last_camera_activity[camera_config.name] = {}
|
||||||
|
@ -55,6 +55,7 @@ class Dispatcher:
|
|||||||
self._camera_settings_handlers: dict[str, Callable] = {
|
self._camera_settings_handlers: dict[str, Callable] = {
|
||||||
"audio": self._on_audio_command,
|
"audio": self._on_audio_command,
|
||||||
"detect": self._on_detect_command,
|
"detect": self._on_detect_command,
|
||||||
|
"enabled": self._on_enabled_command,
|
||||||
"improve_contrast": self._on_motion_improve_contrast_command,
|
"improve_contrast": self._on_motion_improve_contrast_command,
|
||||||
"ptz_autotracker": self._on_ptz_autotracker_command,
|
"ptz_autotracker": self._on_ptz_autotracker_command,
|
||||||
"motion": self._on_motion_command,
|
"motion": self._on_motion_command,
|
||||||
@ -167,6 +168,7 @@ class Dispatcher:
|
|||||||
for camera in camera_status.keys():
|
for camera in camera_status.keys():
|
||||||
camera_status[camera]["config"] = {
|
camera_status[camera]["config"] = {
|
||||||
"detect": self.config.cameras[camera].detect.enabled,
|
"detect": self.config.cameras[camera].detect.enabled,
|
||||||
|
"enabled": self.config.cameras[camera].enabled,
|
||||||
"snapshots": self.config.cameras[camera].snapshots.enabled,
|
"snapshots": self.config.cameras[camera].snapshots.enabled,
|
||||||
"record": self.config.cameras[camera].record.enabled,
|
"record": self.config.cameras[camera].record.enabled,
|
||||||
"audio": self.config.cameras[camera].audio.enabled,
|
"audio": self.config.cameras[camera].audio.enabled,
|
||||||
@ -278,6 +280,27 @@ class Dispatcher:
|
|||||||
self.config_updater.publish(f"config/detect/{camera_name}", detect_settings)
|
self.config_updater.publish(f"config/detect/{camera_name}", detect_settings)
|
||||||
self.publish(f"{camera_name}/detect/state", payload, retain=True)
|
self.publish(f"{camera_name}/detect/state", payload, retain=True)
|
||||||
|
|
||||||
|
def _on_enabled_command(self, camera_name: str, payload: str) -> None:
|
||||||
|
"""Callback for camera topic."""
|
||||||
|
camera_settings = self.config.cameras[camera_name]
|
||||||
|
|
||||||
|
if payload == "ON":
|
||||||
|
if not self.config.cameras[camera_name].enabled_in_config:
|
||||||
|
logger.error(
|
||||||
|
"Camera must be enabled in the config to be turned on via MQTT."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not camera_settings.enabled:
|
||||||
|
logger.info(f"Turning on camera {camera_name}")
|
||||||
|
camera_settings.enabled = True
|
||||||
|
elif payload == "OFF":
|
||||||
|
if camera_settings.enabled:
|
||||||
|
logger.info(f"Turning off camera {camera_name}")
|
||||||
|
camera_settings.enabled = False
|
||||||
|
|
||||||
|
self.config_updater.publish(f"config/enabled/{camera_name}", camera_settings)
|
||||||
|
self.publish(f"{camera_name}/enabled/state", payload, retain=True)
|
||||||
|
|
||||||
def _on_motion_command(self, camera_name: str, payload: str) -> None:
|
def _on_motion_command(self, camera_name: str, payload: str) -> None:
|
||||||
"""Callback for motion topic."""
|
"""Callback for motion topic."""
|
||||||
detect_settings = self.config.cameras[camera_name].detect
|
detect_settings = self.config.cameras[camera_name].detect
|
||||||
|
@ -102,6 +102,9 @@ class CameraConfig(FrigateBaseModel):
|
|||||||
zones: dict[str, ZoneConfig] = Field(
|
zones: dict[str, ZoneConfig] = Field(
|
||||||
default_factory=dict, title="Zone configuration."
|
default_factory=dict, title="Zone configuration."
|
||||||
)
|
)
|
||||||
|
enabled_in_config: Optional[bool] = Field(
|
||||||
|
default=None, title="Keep track of original state of camera."
|
||||||
|
)
|
||||||
|
|
||||||
_ffmpeg_cmds: list[dict[str, list[str]]] = PrivateAttr()
|
_ffmpeg_cmds: list[dict[str, list[str]]] = PrivateAttr()
|
||||||
|
|
||||||
|
@ -516,6 +516,7 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
camera_config.detect.stationary.interval = stationary_threshold
|
camera_config.detect.stationary.interval = stationary_threshold
|
||||||
|
|
||||||
# set config pre-value
|
# set config pre-value
|
||||||
|
camera_config.enabled_in_config = camera_config.enabled
|
||||||
camera_config.audio.enabled_in_config = camera_config.audio.enabled
|
camera_config.audio.enabled_in_config = camera_config.audio.enabled
|
||||||
camera_config.record.enabled_in_config = camera_config.record.enabled
|
camera_config.record.enabled_in_config = camera_config.record.enabled
|
||||||
camera_config.notifications.enabled_in_config = (
|
camera_config.notifications.enabled_in_config = (
|
||||||
|
@ -10,6 +10,7 @@ from typing import Callable, Optional
|
|||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from frigate.comms.config_updater import ConfigSubscriber
|
||||||
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
|
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
|
||||||
from frigate.comms.dispatcher import Dispatcher
|
from frigate.comms.dispatcher import Dispatcher
|
||||||
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
|
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
|
||||||
@ -61,6 +62,7 @@ class CameraState:
|
|||||||
self.previous_frame_id = None
|
self.previous_frame_id = None
|
||||||
self.callbacks = defaultdict(list)
|
self.callbacks = defaultdict(list)
|
||||||
self.ptz_autotracker_thread = ptz_autotracker_thread
|
self.ptz_autotracker_thread = ptz_autotracker_thread
|
||||||
|
self.prev_enabled = self.camera_config.enabled
|
||||||
|
|
||||||
def get_current_frame(self, draw_options={}):
|
def get_current_frame(self, draw_options={}):
|
||||||
with self.current_frame_lock:
|
with self.current_frame_lock:
|
||||||
@ -310,6 +312,7 @@ class CameraState:
|
|||||||
# TODO: can i switch to looking this up and only changing when an event ends?
|
# TODO: can i switch to looking this up and only changing when an event ends?
|
||||||
# maintain best objects
|
# maintain best objects
|
||||||
camera_activity: dict[str, list[any]] = {
|
camera_activity: dict[str, list[any]] = {
|
||||||
|
"enabled": True,
|
||||||
"motion": len(motion_boxes) > 0,
|
"motion": len(motion_boxes) > 0,
|
||||||
"objects": [],
|
"objects": [],
|
||||||
}
|
}
|
||||||
@ -437,6 +440,11 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
self.last_motion_detected: dict[str, float] = {}
|
self.last_motion_detected: dict[str, float] = {}
|
||||||
self.ptz_autotracker_thread = ptz_autotracker_thread
|
self.ptz_autotracker_thread = ptz_autotracker_thread
|
||||||
|
|
||||||
|
self.enabled_subscribers = {
|
||||||
|
camera: ConfigSubscriber(f"config/enabled/{camera}", True)
|
||||||
|
for camera in config.cameras.keys()
|
||||||
|
}
|
||||||
|
|
||||||
self.requestor = InterProcessRequestor()
|
self.requestor = InterProcessRequestor()
|
||||||
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video)
|
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video)
|
||||||
self.event_sender = EventUpdatePublisher()
|
self.event_sender = EventUpdatePublisher()
|
||||||
@ -679,8 +687,55 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
"""Returns the latest frame time for a given camera."""
|
"""Returns the latest frame time for a given camera."""
|
||||||
return self.camera_states[camera].current_frame_time
|
return self.camera_states[camera].current_frame_time
|
||||||
|
|
||||||
|
def force_end_all_events(self, camera: str, camera_state: CameraState):
|
||||||
|
"""Ends all active events on camera when disabling."""
|
||||||
|
last_frame_name = camera_state.previous_frame_id
|
||||||
|
for obj_id, obj in list(camera_state.tracked_objects.items()):
|
||||||
|
if "end_time" not in obj.obj_data:
|
||||||
|
logger.debug(f"Camera {camera} disabled, ending active event {obj_id}")
|
||||||
|
obj.obj_data["end_time"] = datetime.datetime.now().timestamp()
|
||||||
|
# end callbacks
|
||||||
|
for callback in camera_state.callbacks["end"]:
|
||||||
|
callback(camera, obj, last_frame_name)
|
||||||
|
|
||||||
|
# camera activity callbacks
|
||||||
|
for callback in camera_state.callbacks["camera_activity"]:
|
||||||
|
callback(
|
||||||
|
camera,
|
||||||
|
{"enabled": False, "motion": 0, "objects": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_enabled_state(self, camera: str) -> bool:
|
||||||
|
_, config_data = self.enabled_subscribers[camera].check_for_update()
|
||||||
|
if config_data:
|
||||||
|
enabled = config_data.enabled
|
||||||
|
if self.camera_states[camera].prev_enabled is None:
|
||||||
|
self.camera_states[camera].prev_enabled = enabled
|
||||||
|
return enabled
|
||||||
|
return (
|
||||||
|
self.camera_states[camera].prev_enabled
|
||||||
|
if self.camera_states[camera].prev_enabled is not None
|
||||||
|
else self.config.cameras[camera].enabled
|
||||||
|
)
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
|
for camera, config in self.config.cameras.items():
|
||||||
|
if not config.enabled_in_config:
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_enabled = self._get_enabled_state(camera)
|
||||||
|
camera_state = self.camera_states[camera]
|
||||||
|
|
||||||
|
if camera_state.prev_enabled and not current_enabled:
|
||||||
|
logger.debug(f"Not processing objects for disabled camera {camera}")
|
||||||
|
self.force_end_all_events(camera, camera_state)
|
||||||
|
|
||||||
|
camera_state.prev_enabled = current_enabled
|
||||||
|
|
||||||
|
if not current_enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
(
|
(
|
||||||
camera,
|
camera,
|
||||||
@ -693,6 +748,10 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if not self._get_enabled_state(camera):
|
||||||
|
logger.debug(f"Camera {camera} disabled, skipping update")
|
||||||
|
continue
|
||||||
|
|
||||||
camera_state = self.camera_states[camera]
|
camera_state = self.camera_states[camera]
|
||||||
|
|
||||||
camera_state.update(
|
camera_state.update(
|
||||||
@ -735,4 +794,7 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
self.detection_publisher.stop()
|
self.detection_publisher.stop()
|
||||||
self.event_sender.stop()
|
self.event_sender.stop()
|
||||||
self.event_end_subscriber.stop()
|
self.event_end_subscriber.stop()
|
||||||
|
for subscriber in self.enabled_subscribers.values():
|
||||||
|
subscriber.stop()
|
||||||
|
|
||||||
logger.info("Exiting object processor...")
|
logger.info("Exiting object processor...")
|
||||||
|
@ -10,6 +10,7 @@ import queue
|
|||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
import threading
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@ -280,6 +281,12 @@ class BirdsEyeFrameManager:
|
|||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.inactivity_threshold = config.birdseye.inactivity_threshold
|
self.inactivity_threshold = config.birdseye.inactivity_threshold
|
||||||
|
|
||||||
|
self.enabled_subscribers = {
|
||||||
|
cam: ConfigSubscriber(f"config/enabled/{cam}", True)
|
||||||
|
for cam in config.cameras.keys()
|
||||||
|
if config.cameras[cam].enabled_in_config
|
||||||
|
}
|
||||||
|
|
||||||
if config.birdseye.layout.max_cameras:
|
if config.birdseye.layout.max_cameras:
|
||||||
self.last_refresh_time = 0
|
self.last_refresh_time = 0
|
||||||
|
|
||||||
@ -380,8 +387,18 @@ class BirdsEyeFrameManager:
|
|||||||
if mode == BirdseyeModeEnum.objects and object_box_count > 0:
|
if mode == BirdseyeModeEnum.objects and object_box_count > 0:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def update_frame(self, frame: np.ndarray):
|
def _get_enabled_state(self, camera: str) -> bool:
|
||||||
"""Update to a new frame for birdseye."""
|
"""Fetch the latest enabled state for a camera from ZMQ."""
|
||||||
|
_, config_data = self.enabled_subscribers[camera].check_for_update()
|
||||||
|
if config_data:
|
||||||
|
return config_data.enabled
|
||||||
|
return self.config.cameras[camera].enabled
|
||||||
|
|
||||||
|
def update_frame(self, frame: Optional[np.ndarray] = None) -> bool:
|
||||||
|
"""
|
||||||
|
Update birdseye, optionally with a new frame.
|
||||||
|
When no frame is passed, check the layout and update for any disabled cameras.
|
||||||
|
"""
|
||||||
|
|
||||||
# determine how many cameras are tracking objects within the last inactivity_threshold seconds
|
# determine how many cameras are tracking objects within the last inactivity_threshold seconds
|
||||||
active_cameras: set[str] = set(
|
active_cameras: set[str] = set(
|
||||||
@ -389,11 +406,14 @@ class BirdsEyeFrameManager:
|
|||||||
cam
|
cam
|
||||||
for cam, cam_data in self.cameras.items()
|
for cam, cam_data in self.cameras.items()
|
||||||
if self.config.cameras[cam].birdseye.enabled
|
if self.config.cameras[cam].birdseye.enabled
|
||||||
|
and self.config.cameras[cam].enabled_in_config
|
||||||
|
and self._get_enabled_state(cam)
|
||||||
and cam_data["last_active_frame"] > 0
|
and cam_data["last_active_frame"] > 0
|
||||||
and cam_data["current_frame_time"] - cam_data["last_active_frame"]
|
and cam_data["current_frame_time"] - cam_data["last_active_frame"]
|
||||||
< self.inactivity_threshold
|
< self.inactivity_threshold
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
logger.debug(f"Active cameras: {active_cameras}")
|
||||||
|
|
||||||
max_cameras = self.config.birdseye.layout.max_cameras
|
max_cameras = self.config.birdseye.layout.max_cameras
|
||||||
max_camera_refresh = False
|
max_camera_refresh = False
|
||||||
@ -411,118 +431,125 @@ class BirdsEyeFrameManager:
|
|||||||
- self.cameras[active_camera]["last_active_frame"]
|
- self.cameras[active_camera]["last_active_frame"]
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
active_cameras = limited_active_cameras[
|
active_cameras = limited_active_cameras[:max_cameras]
|
||||||
: self.config.birdseye.layout.max_cameras
|
|
||||||
]
|
|
||||||
max_camera_refresh = True
|
max_camera_refresh = True
|
||||||
self.last_refresh_time = now
|
self.last_refresh_time = now
|
||||||
|
|
||||||
# if there are no active cameras
|
# Track if the frame changes
|
||||||
|
frame_changed = False
|
||||||
|
|
||||||
|
# If no active cameras and layout is already empty, no update needed
|
||||||
if len(active_cameras) == 0:
|
if len(active_cameras) == 0:
|
||||||
# if the layout is already cleared
|
# if the layout is already cleared
|
||||||
if len(self.camera_layout) == 0:
|
if len(self.camera_layout) == 0:
|
||||||
return False
|
return False
|
||||||
# if the layout needs to be cleared
|
# if the layout needs to be cleared
|
||||||
else:
|
self.camera_layout = []
|
||||||
self.camera_layout = []
|
self.active_cameras = set()
|
||||||
self.active_cameras = set()
|
|
||||||
self.clear_frame()
|
|
||||||
return True
|
|
||||||
|
|
||||||
# check if we need to reset the layout because there is a different number of cameras
|
|
||||||
if len(self.active_cameras) - len(active_cameras) == 0:
|
|
||||||
if len(self.active_cameras) == 1 and self.active_cameras != active_cameras:
|
|
||||||
reset_layout = True
|
|
||||||
elif max_camera_refresh:
|
|
||||||
reset_layout = True
|
|
||||||
else:
|
|
||||||
reset_layout = False
|
|
||||||
else:
|
|
||||||
reset_layout = True
|
|
||||||
|
|
||||||
# reset the layout if it needs to be different
|
|
||||||
if reset_layout:
|
|
||||||
logger.debug("Added new cameras, resetting layout...")
|
|
||||||
self.clear_frame()
|
self.clear_frame()
|
||||||
self.active_cameras = active_cameras
|
frame_changed = True
|
||||||
|
else:
|
||||||
# this also converts added_cameras from a set to a list since we need
|
# Determine if layout needs resetting
|
||||||
# to pop elements in order
|
if len(self.active_cameras) - len(active_cameras) == 0:
|
||||||
active_cameras_to_add = sorted(
|
if (
|
||||||
active_cameras,
|
len(self.active_cameras) == 1
|
||||||
# sort cameras by order and by name if the order is the same
|
and self.active_cameras != active_cameras
|
||||||
key=lambda active_camera: (
|
):
|
||||||
self.config.cameras[active_camera].birdseye.order,
|
reset_layout = True
|
||||||
active_camera,
|
elif max_camera_refresh:
|
||||||
),
|
reset_layout = True
|
||||||
)
|
|
||||||
|
|
||||||
if len(active_cameras) == 1:
|
|
||||||
# show single camera as fullscreen
|
|
||||||
camera = active_cameras_to_add[0]
|
|
||||||
camera_dims = self.cameras[camera]["dimensions"].copy()
|
|
||||||
scaled_width = int(self.canvas.height * camera_dims[0] / camera_dims[1])
|
|
||||||
|
|
||||||
# center camera view in canvas and ensure that it fits
|
|
||||||
if scaled_width < self.canvas.width:
|
|
||||||
coefficient = 1
|
|
||||||
x_offset = int((self.canvas.width - scaled_width) / 2)
|
|
||||||
else:
|
else:
|
||||||
coefficient = self.canvas.width / scaled_width
|
reset_layout = False
|
||||||
x_offset = int(
|
|
||||||
(self.canvas.width - (scaled_width * coefficient)) / 2
|
|
||||||
)
|
|
||||||
|
|
||||||
self.camera_layout = [
|
|
||||||
[
|
|
||||||
(
|
|
||||||
camera,
|
|
||||||
(
|
|
||||||
x_offset,
|
|
||||||
0,
|
|
||||||
int(scaled_width * coefficient),
|
|
||||||
int(self.canvas.height * coefficient),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
]
|
|
||||||
else:
|
else:
|
||||||
# calculate optimal layout
|
reset_layout = True
|
||||||
coefficient = self.canvas.get_coefficient(len(active_cameras))
|
|
||||||
calculating = True
|
|
||||||
|
|
||||||
# decrease scaling coefficient until height of all cameras can fit into the birdseye canvas
|
if reset_layout:
|
||||||
while calculating:
|
logger.debug("Resetting Birdseye layout...")
|
||||||
if self.stop_event.is_set():
|
self.clear_frame()
|
||||||
return
|
self.active_cameras = active_cameras
|
||||||
|
|
||||||
layout_candidate = self.calculate_layout(
|
# this also converts added_cameras from a set to a list since we need
|
||||||
active_cameras_to_add,
|
# to pop elements in order
|
||||||
coefficient,
|
active_cameras_to_add = sorted(
|
||||||
|
active_cameras,
|
||||||
|
# sort cameras by order and by name if the order is the same
|
||||||
|
key=lambda active_camera: (
|
||||||
|
self.config.cameras[active_camera].birdseye.order,
|
||||||
|
active_camera,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if len(active_cameras) == 1:
|
||||||
|
# show single camera as fullscreen
|
||||||
|
camera = active_cameras_to_add[0]
|
||||||
|
camera_dims = self.cameras[camera]["dimensions"].copy()
|
||||||
|
scaled_width = int(
|
||||||
|
self.canvas.height * camera_dims[0] / camera_dims[1]
|
||||||
)
|
)
|
||||||
|
|
||||||
if not layout_candidate:
|
# center camera view in canvas and ensure that it fits
|
||||||
if coefficient < 10:
|
if scaled_width < self.canvas.width:
|
||||||
coefficient += 1
|
coefficient = 1
|
||||||
continue
|
x_offset = int((self.canvas.width - scaled_width) / 2)
|
||||||
else:
|
else:
|
||||||
logger.error("Error finding appropriate birdseye layout")
|
coefficient = self.canvas.width / scaled_width
|
||||||
|
x_offset = int(
|
||||||
|
(self.canvas.width - (scaled_width * coefficient)) / 2
|
||||||
|
)
|
||||||
|
|
||||||
|
self.camera_layout = [
|
||||||
|
[
|
||||||
|
(
|
||||||
|
camera,
|
||||||
|
(
|
||||||
|
x_offset,
|
||||||
|
0,
|
||||||
|
int(scaled_width * coefficient),
|
||||||
|
int(self.canvas.height * coefficient),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
# calculate optimal layout
|
||||||
|
coefficient = self.canvas.get_coefficient(len(active_cameras))
|
||||||
|
calculating = True
|
||||||
|
|
||||||
|
# decrease scaling coefficient until height of all cameras can fit into the birdseye canvas
|
||||||
|
while calculating:
|
||||||
|
if self.stop_event.is_set():
|
||||||
return
|
return
|
||||||
|
|
||||||
calculating = False
|
layout_candidate = self.calculate_layout(
|
||||||
self.canvas.set_coefficient(len(active_cameras), coefficient)
|
active_cameras_to_add, coefficient
|
||||||
|
)
|
||||||
|
|
||||||
self.camera_layout = layout_candidate
|
if not layout_candidate:
|
||||||
|
if coefficient < 10:
|
||||||
|
coefficient += 1
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"Error finding appropriate birdseye layout"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
calculating = False
|
||||||
|
self.canvas.set_coefficient(len(active_cameras), coefficient)
|
||||||
|
|
||||||
for row in self.camera_layout:
|
self.camera_layout = layout_candidate
|
||||||
for position in row:
|
frame_changed = True
|
||||||
self.copy_to_position(
|
|
||||||
position[1],
|
|
||||||
position[0],
|
|
||||||
self.cameras[position[0]]["current_frame"],
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
# Draw the layout
|
||||||
|
for row in self.camera_layout:
|
||||||
|
for position in row:
|
||||||
|
src_frame = self.cameras[position[0]]["current_frame"]
|
||||||
|
if src_frame is None or src_frame.size == 0:
|
||||||
|
logger.debug(f"Skipping invalid frame for {position[0]}")
|
||||||
|
continue
|
||||||
|
self.copy_to_position(position[1], position[0], src_frame)
|
||||||
|
if frame is not None: # Frame presence indicates a potential change
|
||||||
|
frame_changed = True
|
||||||
|
|
||||||
|
return frame_changed
|
||||||
|
|
||||||
def calculate_layout(
|
def calculate_layout(
|
||||||
self,
|
self,
|
||||||
@ -678,11 +705,8 @@ class BirdsEyeFrameManager:
|
|||||||
# don't process if birdseye is disabled for this camera
|
# don't process if birdseye is disabled for this camera
|
||||||
camera_config = self.config.cameras[camera].birdseye
|
camera_config = self.config.cameras[camera].birdseye
|
||||||
|
|
||||||
if not camera_config.enabled:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# disabling birdseye is a little tricky
|
# disabling birdseye is a little tricky
|
||||||
if not camera_config.enabled:
|
if not camera_config.enabled or not self._get_enabled_state(camera):
|
||||||
# if we've rendered a frame (we have a value for last_active_frame)
|
# if we've rendered a frame (we have a value for last_active_frame)
|
||||||
# then we need to set it to zero
|
# then we need to set it to zero
|
||||||
if self.cameras[camera]["last_active_frame"] > 0:
|
if self.cameras[camera]["last_active_frame"] > 0:
|
||||||
@ -716,6 +740,11 @@ class BirdsEyeFrameManager:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Clean up subscribers when stopping."""
|
||||||
|
for subscriber in self.enabled_subscribers.values():
|
||||||
|
subscriber.stop()
|
||||||
|
|
||||||
|
|
||||||
class Birdseye:
|
class Birdseye:
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -743,6 +772,7 @@ class Birdseye:
|
|||||||
self.birdseye_manager = BirdsEyeFrameManager(config, stop_event)
|
self.birdseye_manager = BirdsEyeFrameManager(config, stop_event)
|
||||||
self.config_subscriber = ConfigSubscriber("config/birdseye/")
|
self.config_subscriber = ConfigSubscriber("config/birdseye/")
|
||||||
self.frame_manager = SharedMemoryFrameManager()
|
self.frame_manager = SharedMemoryFrameManager()
|
||||||
|
self.stop_event = stop_event
|
||||||
|
|
||||||
if config.birdseye.restream:
|
if config.birdseye.restream:
|
||||||
self.birdseye_buffer = self.frame_manager.create(
|
self.birdseye_buffer = self.frame_manager.create(
|
||||||
@ -794,5 +824,6 @@ class Birdseye:
|
|||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self.config_subscriber.stop()
|
self.config_subscriber.stop()
|
||||||
|
self.birdseye_manager.stop()
|
||||||
self.converter.join()
|
self.converter.join()
|
||||||
self.broadcaster.join()
|
self.broadcaster.join()
|
||||||
|
@ -17,6 +17,7 @@ from ws4py.server.wsgirefserver import (
|
|||||||
)
|
)
|
||||||
from ws4py.server.wsgiutils import WebSocketWSGIApplication
|
from ws4py.server.wsgiutils import WebSocketWSGIApplication
|
||||||
|
|
||||||
|
from frigate.comms.config_updater import ConfigSubscriber
|
||||||
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
|
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
|
||||||
from frigate.comms.ws import WebSocket
|
from frigate.comms.ws import WebSocket
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
@ -59,6 +60,12 @@ def output_frames(
|
|||||||
|
|
||||||
detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video)
|
detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video)
|
||||||
|
|
||||||
|
enabled_subscribers = {
|
||||||
|
camera: ConfigSubscriber(f"config/enabled/{camera}", True)
|
||||||
|
for camera in config.cameras.keys()
|
||||||
|
if config.cameras[camera].enabled_in_config
|
||||||
|
}
|
||||||
|
|
||||||
jsmpeg_cameras: dict[str, JsmpegCamera] = {}
|
jsmpeg_cameras: dict[str, JsmpegCamera] = {}
|
||||||
birdseye: Optional[Birdseye] = None
|
birdseye: Optional[Birdseye] = None
|
||||||
preview_recorders: dict[str, PreviewRecorder] = {}
|
preview_recorders: dict[str, PreviewRecorder] = {}
|
||||||
@ -80,6 +87,13 @@ def output_frames(
|
|||||||
|
|
||||||
websocket_thread.start()
|
websocket_thread.start()
|
||||||
|
|
||||||
|
def get_enabled_state(camera: str) -> bool:
|
||||||
|
_, config_data = enabled_subscribers[camera].check_for_update()
|
||||||
|
if config_data:
|
||||||
|
return config_data.enabled
|
||||||
|
# default
|
||||||
|
return config.cameras[camera].enabled
|
||||||
|
|
||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
(topic, data) = detection_subscriber.check_for_update(timeout=1)
|
(topic, data) = detection_subscriber.check_for_update(timeout=1)
|
||||||
|
|
||||||
@ -95,6 +109,9 @@ def output_frames(
|
|||||||
_,
|
_,
|
||||||
) = data
|
) = data
|
||||||
|
|
||||||
|
if not get_enabled_state(camera):
|
||||||
|
continue
|
||||||
|
|
||||||
frame = frame_manager.get(frame_name, config.cameras[camera].frame_shape_yuv)
|
frame = frame_manager.get(frame_name, config.cameras[camera].frame_shape_yuv)
|
||||||
|
|
||||||
if frame is None:
|
if frame is None:
|
||||||
@ -184,6 +201,9 @@ def output_frames(
|
|||||||
if birdseye is not None:
|
if birdseye is not None:
|
||||||
birdseye.stop()
|
birdseye.stop()
|
||||||
|
|
||||||
|
for subscriber in enabled_subscribers.values():
|
||||||
|
subscriber.stop()
|
||||||
|
|
||||||
websocket_server.manager.close_all()
|
websocket_server.manager.close_all()
|
||||||
websocket_server.manager.stop()
|
websocket_server.manager.stop()
|
||||||
websocket_server.manager.join()
|
websocket_server.manager.join()
|
||||||
|
142
frigate/video.py
142
frigate/video.py
@ -108,8 +108,20 @@ def capture_frames(
|
|||||||
frame_rate.start()
|
frame_rate.start()
|
||||||
skipped_eps = EventsPerSecond()
|
skipped_eps = EventsPerSecond()
|
||||||
skipped_eps.start()
|
skipped_eps.start()
|
||||||
|
config_subscriber = ConfigSubscriber(f"config/enabled/{config.name}", True)
|
||||||
|
|
||||||
|
def get_enabled_state():
|
||||||
|
"""Fetch the latest enabled state from ZMQ."""
|
||||||
|
_, config_data = config_subscriber.check_for_update()
|
||||||
|
if config_data:
|
||||||
|
return config_data.enabled
|
||||||
|
return config.enabled
|
||||||
|
|
||||||
|
while not stop_event.is_set():
|
||||||
|
if not get_enabled_state():
|
||||||
|
logger.debug(f"Stopping capture thread for disabled {config.name}")
|
||||||
|
break
|
||||||
|
|
||||||
while True:
|
|
||||||
fps.value = frame_rate.eps()
|
fps.value = frame_rate.eps()
|
||||||
skipped_fps.value = skipped_eps.eps()
|
skipped_fps.value = skipped_eps.eps()
|
||||||
current_frame.value = datetime.datetime.now().timestamp()
|
current_frame.value = datetime.datetime.now().timestamp()
|
||||||
@ -178,26 +190,37 @@ class CameraWatchdog(threading.Thread):
|
|||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.sleeptime = self.config.ffmpeg.retry_interval
|
self.sleeptime = self.config.ffmpeg.retry_interval
|
||||||
|
|
||||||
def run(self):
|
self.config_subscriber = ConfigSubscriber(f"config/enabled/{camera_name}", True)
|
||||||
self.start_ffmpeg_detect()
|
self.was_enabled = self.config.enabled
|
||||||
|
|
||||||
for c in self.config.ffmpeg_cmds:
|
def _update_enabled_state(self) -> bool:
|
||||||
if "detect" in c["roles"]:
|
"""Fetch the latest config and update enabled state."""
|
||||||
continue
|
_, config_data = self.config_subscriber.check_for_update()
|
||||||
logpipe = LogPipe(
|
if config_data:
|
||||||
f"ffmpeg.{self.camera_name}.{'_'.join(sorted(c['roles']))}"
|
enabled = config_data.enabled
|
||||||
)
|
return enabled
|
||||||
self.ffmpeg_other_processes.append(
|
return self.was_enabled if self.was_enabled is not None else self.config.enabled
|
||||||
{
|
|
||||||
"cmd": c["cmd"],
|
def run(self):
|
||||||
"roles": c["roles"],
|
if self._update_enabled_state():
|
||||||
"logpipe": logpipe,
|
self.start_all_ffmpeg()
|
||||||
"process": start_or_restart_ffmpeg(c["cmd"], self.logger, logpipe),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
time.sleep(self.sleeptime)
|
time.sleep(self.sleeptime)
|
||||||
while not self.stop_event.wait(self.sleeptime):
|
while not self.stop_event.wait(self.sleeptime):
|
||||||
|
enabled = self._update_enabled_state()
|
||||||
|
if enabled != self.was_enabled:
|
||||||
|
if enabled:
|
||||||
|
self.logger.debug(f"Enabling camera {self.camera_name}")
|
||||||
|
self.start_all_ffmpeg()
|
||||||
|
else:
|
||||||
|
self.logger.debug(f"Disabling camera {self.camera_name}")
|
||||||
|
self.stop_all_ffmpeg()
|
||||||
|
self.was_enabled = enabled
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
now = datetime.datetime.now().timestamp()
|
now = datetime.datetime.now().timestamp()
|
||||||
|
|
||||||
if not self.capture_thread.is_alive():
|
if not self.capture_thread.is_alive():
|
||||||
@ -279,11 +302,9 @@ class CameraWatchdog(threading.Thread):
|
|||||||
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
|
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
|
||||||
)
|
)
|
||||||
|
|
||||||
stop_ffmpeg(self.ffmpeg_detect_process, self.logger)
|
self.stop_all_ffmpeg()
|
||||||
for p in self.ffmpeg_other_processes:
|
|
||||||
stop_ffmpeg(p["process"], self.logger)
|
|
||||||
p["logpipe"].close()
|
|
||||||
self.logpipe.close()
|
self.logpipe.close()
|
||||||
|
self.config_subscriber.stop()
|
||||||
|
|
||||||
def start_ffmpeg_detect(self):
|
def start_ffmpeg_detect(self):
|
||||||
ffmpeg_cmd = [
|
ffmpeg_cmd = [
|
||||||
@ -306,6 +327,43 @@ class CameraWatchdog(threading.Thread):
|
|||||||
)
|
)
|
||||||
self.capture_thread.start()
|
self.capture_thread.start()
|
||||||
|
|
||||||
|
def start_all_ffmpeg(self):
|
||||||
|
"""Start all ffmpeg processes (detection and others)."""
|
||||||
|
logger.debug(f"Starting all ffmpeg processes for {self.camera_name}")
|
||||||
|
self.start_ffmpeg_detect()
|
||||||
|
for c in self.config.ffmpeg_cmds:
|
||||||
|
if "detect" in c["roles"]:
|
||||||
|
continue
|
||||||
|
logpipe = LogPipe(
|
||||||
|
f"ffmpeg.{self.camera_name}.{'_'.join(sorted(c['roles']))}"
|
||||||
|
)
|
||||||
|
self.ffmpeg_other_processes.append(
|
||||||
|
{
|
||||||
|
"cmd": c["cmd"],
|
||||||
|
"roles": c["roles"],
|
||||||
|
"logpipe": logpipe,
|
||||||
|
"process": start_or_restart_ffmpeg(c["cmd"], self.logger, logpipe),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop_all_ffmpeg(self):
|
||||||
|
"""Stop all ffmpeg processes (detection and others)."""
|
||||||
|
logger.debug(f"Stopping all ffmpeg processes for {self.camera_name}")
|
||||||
|
if self.capture_thread is not None and self.capture_thread.is_alive():
|
||||||
|
self.capture_thread.join(timeout=5)
|
||||||
|
if self.capture_thread.is_alive():
|
||||||
|
self.logger.warning(
|
||||||
|
f"Capture thread for {self.camera_name} did not stop gracefully."
|
||||||
|
)
|
||||||
|
if self.ffmpeg_detect_process is not None:
|
||||||
|
stop_ffmpeg(self.ffmpeg_detect_process, self.logger)
|
||||||
|
self.ffmpeg_detect_process = None
|
||||||
|
for p in self.ffmpeg_other_processes[:]:
|
||||||
|
if p["process"] is not None:
|
||||||
|
stop_ffmpeg(p["process"], self.logger)
|
||||||
|
p["logpipe"].close()
|
||||||
|
self.ffmpeg_other_processes.clear()
|
||||||
|
|
||||||
def get_latest_segment_datetime(self, latest_segment: datetime.datetime) -> int:
|
def get_latest_segment_datetime(self, latest_segment: datetime.datetime) -> int:
|
||||||
"""Checks if ffmpeg is still writing recording segments to cache."""
|
"""Checks if ffmpeg is still writing recording segments to cache."""
|
||||||
cache_files = sorted(
|
cache_files = sorted(
|
||||||
@ -539,7 +597,8 @@ def process_frames(
|
|||||||
exit_on_empty: bool = False,
|
exit_on_empty: bool = False,
|
||||||
):
|
):
|
||||||
next_region_update = get_tomorrow_at_time(2)
|
next_region_update = get_tomorrow_at_time(2)
|
||||||
config_subscriber = ConfigSubscriber(f"config/detect/{camera_name}", True)
|
detect_config_subscriber = ConfigSubscriber(f"config/detect/{camera_name}", True)
|
||||||
|
enabled_config_subscriber = ConfigSubscriber(f"config/enabled/{camera_name}", True)
|
||||||
|
|
||||||
fps_tracker = EventsPerSecond()
|
fps_tracker = EventsPerSecond()
|
||||||
fps_tracker.start()
|
fps_tracker.start()
|
||||||
@ -549,9 +608,43 @@ def process_frames(
|
|||||||
|
|
||||||
region_min_size = get_min_region_size(model_config)
|
region_min_size = get_min_region_size(model_config)
|
||||||
|
|
||||||
|
prev_enabled = None
|
||||||
|
|
||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
|
_, enabled_config = enabled_config_subscriber.check_for_update()
|
||||||
|
current_enabled = (
|
||||||
|
enabled_config.enabled
|
||||||
|
if enabled_config
|
||||||
|
else (prev_enabled if prev_enabled is not None else True)
|
||||||
|
)
|
||||||
|
if prev_enabled is None:
|
||||||
|
prev_enabled = current_enabled
|
||||||
|
|
||||||
|
if prev_enabled and not current_enabled and camera_metrics.frame_queue.empty():
|
||||||
|
logger.debug(f"Camera {camera_name} disabled, clearing tracked objects")
|
||||||
|
|
||||||
|
# Clear norfair's dictionaries
|
||||||
|
object_tracker.tracked_objects.clear()
|
||||||
|
object_tracker.disappeared.clear()
|
||||||
|
object_tracker.stationary_box_history.clear()
|
||||||
|
object_tracker.positions.clear()
|
||||||
|
object_tracker.track_id_map.clear()
|
||||||
|
|
||||||
|
# Clear internal norfair states
|
||||||
|
for trackers_by_type in object_tracker.trackers.values():
|
||||||
|
for tracker in trackers_by_type.values():
|
||||||
|
tracker.tracked_objects = []
|
||||||
|
for tracker in object_tracker.default_tracker.values():
|
||||||
|
tracker.tracked_objects = []
|
||||||
|
|
||||||
|
prev_enabled = current_enabled
|
||||||
|
|
||||||
|
if not current_enabled:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
# check for updated detect config
|
# check for updated detect config
|
||||||
_, updated_detect_config = config_subscriber.check_for_update()
|
_, updated_detect_config = detect_config_subscriber.check_for_update()
|
||||||
|
|
||||||
if updated_detect_config:
|
if updated_detect_config:
|
||||||
detect_config = updated_detect_config
|
detect_config = updated_detect_config
|
||||||
@ -845,4 +938,5 @@ def process_frames(
|
|||||||
|
|
||||||
motion_detector.stop()
|
motion_detector.stop()
|
||||||
requestor.stop()
|
requestor.stop()
|
||||||
config_subscriber.stop()
|
detect_config_subscriber.stop()
|
||||||
|
enabled_config_subscriber.stop()
|
||||||
|
@ -56,6 +56,7 @@ function useValue(): useValueReturn {
|
|||||||
const {
|
const {
|
||||||
record,
|
record,
|
||||||
detect,
|
detect,
|
||||||
|
enabled,
|
||||||
snapshots,
|
snapshots,
|
||||||
audio,
|
audio,
|
||||||
notifications,
|
notifications,
|
||||||
@ -67,6 +68,7 @@ function useValue(): useValueReturn {
|
|||||||
// @ts-expect-error we know this is correct
|
// @ts-expect-error we know this is correct
|
||||||
state["config"];
|
state["config"];
|
||||||
cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF";
|
cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF";
|
||||||
|
cameraStates[`${name}/enabled/state`] = enabled ? "ON" : "OFF";
|
||||||
cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF";
|
cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF";
|
||||||
cameraStates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF";
|
cameraStates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF";
|
||||||
cameraStates[`${name}/audio/state`] = audio ? "ON" : "OFF";
|
cameraStates[`${name}/audio/state`] = audio ? "ON" : "OFF";
|
||||||
@ -164,6 +166,17 @@ export function useWs(watchTopic: string, publishTopic: string) {
|
|||||||
return { value, send };
|
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): {
|
export function useDetectState(camera: string): {
|
||||||
payload: ToggleableSetting;
|
payload: ToggleableSetting;
|
||||||
send: (payload: ToggleableSetting, retain?: boolean) => void;
|
send: (payload: ToggleableSetting, retain?: boolean) => void;
|
||||||
|
@ -5,6 +5,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
|
|||||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useEnabledState } from "@/api/ws";
|
||||||
|
|
||||||
type CameraImageProps = {
|
type CameraImageProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -26,7 +27,8 @@ export default function CameraImage({
|
|||||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
const { name } = config ? config.cameras[camera] : "";
|
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 }] =
|
const [{ width: containerWidth, height: containerHeight }] =
|
||||||
useResizeObserver(containerRef);
|
useResizeObserver(containerRef);
|
||||||
@ -96,9 +98,7 @@ export default function CameraImage({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="pt-6 text-center">
|
<div className="size-full rounded-lg border-2 border-muted bg-background_alt text-center md:rounded-2xl" />
|
||||||
Camera is disabled in config, no stream or snapshot available!
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{!imageLoaded && enabled ? (
|
{!imageLoaded && enabled ? (
|
||||||
<div className="absolute bottom-0 left-0 right-0 top-0 flex items-center justify-center">
|
<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}
|
width={scaledWidth}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="pt-6 text-center">
|
<div className="pt-6 text-center">Camera is disabled.</div>
|
||||||
Camera is disabled in config, no stream or snapshot available!
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{!hasLoaded && enabled ? (
|
{!hasLoaded && enabled ? (
|
||||||
<div
|
<div
|
||||||
|
@ -11,11 +11,15 @@ const variants = {
|
|||||||
primary: {
|
primary: {
|
||||||
active: "font-bold text-white bg-selected rounded-lg",
|
active: "font-bold text-white bg-selected rounded-lg",
|
||||||
inactive: "text-secondary-foreground bg-secondary rounded-lg",
|
inactive: "text-secondary-foreground bg-secondary rounded-lg",
|
||||||
|
disabled:
|
||||||
|
"text-secondary-foreground bg-secondary rounded-lg cursor-not-allowed opacity-50",
|
||||||
},
|
},
|
||||||
overlay: {
|
overlay: {
|
||||||
active: "font-bold text-white bg-selected rounded-full",
|
active: "font-bold text-white bg-selected rounded-full",
|
||||||
inactive:
|
inactive:
|
||||||
"text-primary rounded-full bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500",
|
"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;
|
Icon: IconType;
|
||||||
title: string;
|
title: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
disabled?: boolean; // New prop for disabling
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CameraFeatureToggle({
|
export default function CameraFeatureToggle({
|
||||||
@ -35,18 +40,28 @@ export default function CameraFeatureToggle({
|
|||||||
Icon,
|
Icon,
|
||||||
title,
|
title,
|
||||||
onClick,
|
onClick,
|
||||||
|
disabled = false, // Default to false
|
||||||
}: CameraFeatureToggleProps) {
|
}: CameraFeatureToggleProps) {
|
||||||
const content = (
|
const content = (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={disabled ? undefined : onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center",
|
"flex flex-col items-center justify-center",
|
||||||
variants[variant][isActive ? "active" : "inactive"],
|
disabled
|
||||||
|
? variants[variant].disabled
|
||||||
|
: variants[variant][isActive ? "active" : "inactive"],
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
@ -54,7 +69,7 @@ export default function CameraFeatureToggle({
|
|||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>{content}</TooltipTrigger>
|
<TooltipTrigger disabled={disabled}>{content}</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
<p>{title}</p>
|
<p>{title}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
|
@ -39,7 +39,11 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
import { useNotifications, useNotificationSuspend } from "@/api/ws";
|
import {
|
||||||
|
useEnabledState,
|
||||||
|
useNotifications,
|
||||||
|
useNotificationSuspend,
|
||||||
|
} from "@/api/ws";
|
||||||
|
|
||||||
type LiveContextMenuProps = {
|
type LiveContextMenuProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -83,6 +87,11 @@ export default function LiveContextMenu({
|
|||||||
}: LiveContextMenuProps) {
|
}: LiveContextMenuProps) {
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
|
||||||
|
// camera enabled
|
||||||
|
|
||||||
|
const { payload: enabledState, send: sendEnabled } = useEnabledState(camera);
|
||||||
|
const isEnabled = enabledState === "ON";
|
||||||
|
|
||||||
// streaming settings
|
// streaming settings
|
||||||
|
|
||||||
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
|
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
|
||||||
@ -263,7 +272,7 @@ export default function LiveContextMenu({
|
|||||||
onClick={handleVolumeIconClick}
|
onClick={handleVolumeIconClick}
|
||||||
/>
|
/>
|
||||||
<VolumeSlider
|
<VolumeSlider
|
||||||
disabled={!audioState}
|
disabled={!audioState || !isEnabled}
|
||||||
className="my-3 ml-0.5 rounded-lg bg-background/60"
|
className="my-3 ml-0.5 rounded-lg bg-background/60"
|
||||||
value={[volumeState ?? 0]}
|
value={[volumeState ?? 0]}
|
||||||
min={0}
|
min={0}
|
||||||
@ -280,34 +289,49 @@ export default function LiveContextMenu({
|
|||||||
<ContextMenuItem>
|
<ContextMenuItem>
|
||||||
<div
|
<div
|
||||||
className="flex w-full cursor-pointer items-center justify-start gap-2"
|
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 className="text-primary">Mute All Cameras</div>
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem>
|
<ContextMenuItem disabled={!isEnabled}>
|
||||||
<div
|
<div
|
||||||
className="flex w-full cursor-pointer items-center justify-start gap-2"
|
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 className="text-primary">Unmute All Cameras</div>
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem>
|
<ContextMenuItem disabled={!isEnabled}>
|
||||||
<div
|
<div
|
||||||
className="flex w-full cursor-pointer items-center justify-start gap-2"
|
className="flex w-full cursor-pointer items-center justify-start gap-2"
|
||||||
onClick={toggleStats}
|
onClick={isEnabled ? toggleStats : undefined}
|
||||||
>
|
>
|
||||||
<div className="text-primary">
|
<div className="text-primary">
|
||||||
{statsState ? "Hide" : "Show"} Stream Stats
|
{statsState ? "Hide" : "Show"} Stream Stats
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem>
|
<ContextMenuItem disabled={!isEnabled}>
|
||||||
<div
|
<div
|
||||||
className="flex w-full cursor-pointer items-center justify-start gap-2"
|
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 className="text-primary">Debug View</div>
|
||||||
</div>
|
</div>
|
||||||
@ -315,10 +339,10 @@ export default function LiveContextMenu({
|
|||||||
{cameraGroup && cameraGroup !== "default" && (
|
{cameraGroup && cameraGroup !== "default" && (
|
||||||
<>
|
<>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem>
|
<ContextMenuItem disabled={!isEnabled}>
|
||||||
<div
|
<div
|
||||||
className="flex w-full cursor-pointer items-center justify-start gap-2"
|
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 className="text-primary">Streaming Settings</div>
|
||||||
</div>
|
</div>
|
||||||
@ -328,10 +352,10 @@ export default function LiveContextMenu({
|
|||||||
{preferredLiveMode == "jsmpeg" && isRestreamed && (
|
{preferredLiveMode == "jsmpeg" && isRestreamed && (
|
||||||
<>
|
<>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem>
|
<ContextMenuItem disabled={!isEnabled}>
|
||||||
<div
|
<div
|
||||||
className="flex w-full cursor-pointer items-center justify-start gap-2"
|
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 className="text-primary">Reset</div>
|
||||||
</div>
|
</div>
|
||||||
@ -342,7 +366,7 @@ export default function LiveContextMenu({
|
|||||||
<>
|
<>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuSub>
|
<ContextMenuSub>
|
||||||
<ContextMenuSubTrigger>
|
<ContextMenuSubTrigger disabled={!isEnabled}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>Notifications</span>
|
<span>Notifications</span>
|
||||||
</div>
|
</div>
|
||||||
@ -382,10 +406,15 @@ export default function LiveContextMenu({
|
|||||||
<>
|
<>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() => {
|
disabled={!isEnabled}
|
||||||
sendNotification("ON");
|
onClick={
|
||||||
sendNotificationSuspend(0);
|
isEnabled
|
||||||
}}
|
? () => {
|
||||||
|
sendNotification("ON");
|
||||||
|
sendNotificationSuspend(0);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="flex w-full flex-col gap-2">
|
<div className="flex w-full flex-col gap-2">
|
||||||
{notificationState === "ON" ? (
|
{notificationState === "ON" ? (
|
||||||
@ -405,36 +434,71 @@ export default function LiveContextMenu({
|
|||||||
Suspend for:
|
Suspend for:
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<ContextMenuItem onClick={() => handleSuspend("5")}>
|
<ContextMenuItem
|
||||||
|
disabled={!isEnabled}
|
||||||
|
onClick={
|
||||||
|
isEnabled ? () => handleSuspend("5") : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
5 minutes
|
5 minutes
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() => handleSuspend("10")}
|
disabled={!isEnabled}
|
||||||
|
onClick={
|
||||||
|
isEnabled
|
||||||
|
? () => handleSuspend("10")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
10 minutes
|
10 minutes
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() => handleSuspend("30")}
|
disabled={!isEnabled}
|
||||||
|
onClick={
|
||||||
|
isEnabled
|
||||||
|
? () => handleSuspend("30")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
30 minutes
|
30 minutes
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() => handleSuspend("60")}
|
disabled={!isEnabled}
|
||||||
|
onClick={
|
||||||
|
isEnabled
|
||||||
|
? () => handleSuspend("60")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
1 hour
|
1 hour
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() => handleSuspend("840")}
|
disabled={!isEnabled}
|
||||||
|
onClick={
|
||||||
|
isEnabled
|
||||||
|
? () => handleSuspend("840")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
12 hours
|
12 hours
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() => handleSuspend("1440")}
|
disabled={!isEnabled}
|
||||||
|
onClick={
|
||||||
|
isEnabled
|
||||||
|
? () => handleSuspend("1440")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
24 hours
|
24 hours
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() => handleSuspend("off")}
|
disabled={!isEnabled}
|
||||||
|
onClick={
|
||||||
|
isEnabled
|
||||||
|
? () => handleSuspend("off")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Until restart
|
Until restart
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
@ -22,6 +22,7 @@ import { TbExclamationCircle } from "react-icons/tb";
|
|||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { PlayerStats } from "./PlayerStats";
|
import { PlayerStats } from "./PlayerStats";
|
||||||
|
import { LuVideoOff } from "react-icons/lu";
|
||||||
|
|
||||||
type LivePlayerProps = {
|
type LivePlayerProps = {
|
||||||
cameraRef?: (ref: HTMLDivElement | null) => void;
|
cameraRef?: (ref: HTMLDivElement | null) => void;
|
||||||
@ -86,8 +87,13 @@ export default function LivePlayer({
|
|||||||
|
|
||||||
// camera activity
|
// camera activity
|
||||||
|
|
||||||
const { activeMotion, activeTracking, objects, offline } =
|
const {
|
||||||
useCameraActivity(cameraConfig);
|
enabled: cameraEnabled,
|
||||||
|
activeMotion,
|
||||||
|
activeTracking,
|
||||||
|
objects,
|
||||||
|
offline,
|
||||||
|
} = useCameraActivity(cameraConfig);
|
||||||
|
|
||||||
const cameraActive = useMemo(
|
const cameraActive = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -191,12 +197,37 @@ export default function LivePlayer({
|
|||||||
setLiveReady(true);
|
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) {
|
if (!cameraConfig) {
|
||||||
return <ActivityIndicator />;
|
return <ActivityIndicator />;
|
||||||
}
|
}
|
||||||
|
|
||||||
let player;
|
let player;
|
||||||
if (!autoLive || !streamName) {
|
if (!autoLive || !streamName || !cameraEnabled) {
|
||||||
player = null;
|
player = null;
|
||||||
} else if (preferredLiveMode == "webrtc") {
|
} else if (preferredLiveMode == "webrtc") {
|
||||||
player = (
|
player = (
|
||||||
@ -267,6 +298,22 @@ export default function LivePlayer({
|
|||||||
player = <ActivityIndicator />;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={cameraRef ?? internalContainerRef}
|
ref={cameraRef ?? internalContainerRef}
|
||||||
@ -287,16 +334,18 @@ export default function LivePlayer({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{((showStillWithoutActivity && !liveReady) || liveReady) && (
|
{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>
|
<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}
|
{player}
|
||||||
{!offline && !showStillWithoutActivity && !liveReady && (
|
{cameraEnabled &&
|
||||||
<ActivityIndicator />
|
!offline &&
|
||||||
)}
|
(!showStillWithoutActivity || isReEnabling) &&
|
||||||
|
!liveReady && <ActivityIndicator />}
|
||||||
|
|
||||||
{((showStillWithoutActivity && !liveReady) || liveReady) &&
|
{((showStillWithoutActivity && !liveReady) || liveReady) &&
|
||||||
objects.length > 0 && (
|
objects.length > 0 && (
|
||||||
@ -344,7 +393,9 @@ export default function LivePlayer({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 w-full",
|
"absolute inset-0 w-full",
|
||||||
showStillWithoutActivity && !liveReady ? "visible" : "invisible",
|
showStillWithoutActivity && !liveReady && !isReEnabling
|
||||||
|
? "visible"
|
||||||
|
: "invisible",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<AutoUpdatingCameraImage
|
<AutoUpdatingCameraImage
|
||||||
@ -371,6 +422,17 @@ export default function LivePlayer({
|
|||||||
</div>
|
</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">
|
<div className="absolute right-2 top-2">
|
||||||
{autoLive &&
|
{autoLive &&
|
||||||
!offline &&
|
!offline &&
|
||||||
@ -378,7 +440,7 @@ export default function LivePlayer({
|
|||||||
((showStillWithoutActivity && !liveReady) || liveReady) && (
|
((showStillWithoutActivity && !liveReady) || liveReady) && (
|
||||||
<MdCircle className="mr-2 size-2 animate-pulse text-danger shadow-danger drop-shadow-md" />
|
<MdCircle className="mr-2 size-2 animate-pulse text-danger shadow-danger drop-shadow-md" />
|
||||||
)}
|
)}
|
||||||
{offline && showStillWithoutActivity && (
|
{((offline && showStillWithoutActivity) || !cameraEnabled) && (
|
||||||
<Chip
|
<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`}
|
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)
|
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);
|
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
useEnabledState,
|
||||||
useFrigateEvents,
|
useFrigateEvents,
|
||||||
useInitialCameraState,
|
useInitialCameraState,
|
||||||
useMotionActivity,
|
useMotionActivity,
|
||||||
@ -15,6 +16,7 @@ import useSWR from "swr";
|
|||||||
import { getAttributeLabels } from "@/utils/iconUtil";
|
import { getAttributeLabels } from "@/utils/iconUtil";
|
||||||
|
|
||||||
type useCameraActivityReturn = {
|
type useCameraActivityReturn = {
|
||||||
|
enabled: boolean;
|
||||||
activeTracking: boolean;
|
activeTracking: boolean;
|
||||||
activeMotion: boolean;
|
activeMotion: boolean;
|
||||||
objects: ObjectType[];
|
objects: ObjectType[];
|
||||||
@ -56,6 +58,7 @@ export function useCameraActivity(
|
|||||||
[objects],
|
[objects],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { payload: cameraEnabled } = useEnabledState(camera.name);
|
||||||
const { payload: detectingMotion } = useMotionActivity(camera.name);
|
const { payload: detectingMotion } = useMotionActivity(camera.name);
|
||||||
const { payload: event } = useFrigateEvents();
|
const { payload: event } = useFrigateEvents();
|
||||||
const updatedEvent = useDeepMemo(event);
|
const updatedEvent = useDeepMemo(event);
|
||||||
@ -145,12 +148,17 @@ export function useCameraActivity(
|
|||||||
return cameras[camera.name].camera_fps == 0 && stats["service"].uptime > 60;
|
return cameras[camera.name].camera_fps == 0 && stats["service"].uptime > 60;
|
||||||
}, [camera, stats]);
|
}, [camera, stats]);
|
||||||
|
|
||||||
|
const isCameraEnabled = cameraEnabled === "ON";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeTracking: hasActiveObjects,
|
enabled: isCameraEnabled,
|
||||||
activeMotion: detectingMotion
|
activeTracking: isCameraEnabled ? hasActiveObjects : false,
|
||||||
? detectingMotion === "ON"
|
activeMotion: isCameraEnabled
|
||||||
: updatedCameraState?.motion === true,
|
? detectingMotion
|
||||||
objects,
|
? detectingMotion === "ON"
|
||||||
|
: updatedCameraState?.motion === true
|
||||||
|
: false,
|
||||||
|
objects: isCameraEnabled ? objects : [],
|
||||||
offline,
|
offline,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -101,12 +101,14 @@ function Live() {
|
|||||||
) {
|
) {
|
||||||
const group = config.camera_groups[cameraGroup];
|
const group = config.camera_groups[cameraGroup];
|
||||||
return Object.values(config.cameras)
|
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);
|
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.values(config.cameras)
|
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);
|
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||||
}, [config, cameraGroup]);
|
}, [config, cameraGroup]);
|
||||||
|
|
||||||
|
@ -39,6 +39,7 @@ import SearchSettingsView from "@/views/settings/SearchSettingsView";
|
|||||||
import UiSettingsView from "@/views/settings/UiSettingsView";
|
import UiSettingsView from "@/views/settings/UiSettingsView";
|
||||||
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { useInitialCameraState } from "@/api/ws";
|
||||||
|
|
||||||
const allSettingsViews = [
|
const allSettingsViews = [
|
||||||
"UI settings",
|
"UI settings",
|
||||||
@ -71,12 +72,33 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Object.values(config.cameras)
|
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);
|
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
const [selectedCamera, setSelectedCamera] = useState<string>("");
|
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 [filterZoneMask, setFilterZoneMask] = useState<PolygonType[]>();
|
||||||
|
|
||||||
const handleDialog = useCallback(
|
const handleDialog = useCallback(
|
||||||
@ -91,10 +113,25 @@ export default function Settings() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cameras.length > 0 && selectedCamera === "") {
|
if (cameras.length > 0) {
|
||||||
setSelectedCamera(cameras[0].name);
|
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(() => {
|
useEffect(() => {
|
||||||
if (tabsRef.current) {
|
if (tabsRef.current) {
|
||||||
@ -177,6 +214,8 @@ export default function Settings() {
|
|||||||
allCameras={cameras}
|
allCameras={cameras}
|
||||||
selectedCamera={selectedCamera}
|
selectedCamera={selectedCamera}
|
||||||
setSelectedCamera={setSelectedCamera}
|
setSelectedCamera={setSelectedCamera}
|
||||||
|
cameraEnabledStates={cameraEnabledStates}
|
||||||
|
currentPage={page}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -244,17 +283,21 @@ type CameraSelectButtonProps = {
|
|||||||
allCameras: CameraConfig[];
|
allCameras: CameraConfig[];
|
||||||
selectedCamera: string;
|
selectedCamera: string;
|
||||||
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
|
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
cameraEnabledStates: Record<string, boolean>;
|
||||||
|
currentPage: SettingsType;
|
||||||
};
|
};
|
||||||
|
|
||||||
function CameraSelectButton({
|
function CameraSelectButton({
|
||||||
allCameras,
|
allCameras,
|
||||||
selectedCamera,
|
selectedCamera,
|
||||||
setSelectedCamera,
|
setSelectedCamera,
|
||||||
|
cameraEnabledStates,
|
||||||
|
currentPage,
|
||||||
}: CameraSelectButtonProps) {
|
}: CameraSelectButtonProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
if (!allCameras.length) {
|
if (!allCameras.length) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trigger = (
|
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="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">
|
<div className="flex flex-col gap-2.5">
|
||||||
{allCameras.map((item) => (
|
{allCameras.map((item) => {
|
||||||
<FilterSwitch
|
const isEnabled = cameraEnabledStates[item.name];
|
||||||
key={item.name}
|
const isCameraSettingsPage = currentPage === "camera settings";
|
||||||
isChecked={item.name === selectedCamera}
|
return (
|
||||||
label={item.name.replaceAll("_", " ")}
|
<FilterSwitch
|
||||||
onCheckedChange={(isChecked) => {
|
key={item.name}
|
||||||
if (isChecked) {
|
isChecked={item.name === selectedCamera}
|
||||||
setSelectedCamera(item.name);
|
label={item.name.replaceAll("_", " ")}
|
||||||
setOpen(false);
|
onCheckedChange={(isChecked) => {
|
||||||
}
|
if (isChecked && (isEnabled || isCameraSettingsPage)) {
|
||||||
}}
|
setSelectedCamera(item.name);
|
||||||
/>
|
setOpen(false);
|
||||||
))}
|
}
|
||||||
|
}}
|
||||||
|
disabled={!isEnabled && !isCameraSettingsPage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -57,6 +57,7 @@ export interface CameraConfig {
|
|||||||
width: number;
|
width: number;
|
||||||
};
|
};
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
enabled_in_config: boolean;
|
||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
global_args: string[];
|
global_args: string[];
|
||||||
hwaccel_args: string;
|
hwaccel_args: string;
|
||||||
|
@ -52,6 +52,7 @@ export type ObjectType = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface FrigateCameraState {
|
export interface FrigateCameraState {
|
||||||
|
enabled: boolean;
|
||||||
motion: boolean;
|
motion: boolean;
|
||||||
objects: ObjectType[];
|
objects: ObjectType[];
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import {
|
|||||||
useAudioState,
|
useAudioState,
|
||||||
useAutotrackingState,
|
useAutotrackingState,
|
||||||
useDetectState,
|
useDetectState,
|
||||||
|
useEnabledState,
|
||||||
usePtzCommand,
|
usePtzCommand,
|
||||||
useRecordingsState,
|
useRecordingsState,
|
||||||
useSnapshotsState,
|
useSnapshotsState,
|
||||||
@ -82,6 +83,8 @@ import {
|
|||||||
LuHistory,
|
LuHistory,
|
||||||
LuInfo,
|
LuInfo,
|
||||||
LuPictureInPicture,
|
LuPictureInPicture,
|
||||||
|
LuPower,
|
||||||
|
LuPowerOff,
|
||||||
LuVideo,
|
LuVideo,
|
||||||
LuVideoOff,
|
LuVideoOff,
|
||||||
LuX,
|
LuX,
|
||||||
@ -185,6 +188,10 @@ export default function LiveCameraView({
|
|||||||
);
|
);
|
||||||
}, [cameraMetadata]);
|
}, [cameraMetadata]);
|
||||||
|
|
||||||
|
// camera enabled state
|
||||||
|
const { payload: enabledState } = useEnabledState(camera.name);
|
||||||
|
const cameraEnabled = enabledState === "ON";
|
||||||
|
|
||||||
// click overlay for ptzs
|
// click overlay for ptzs
|
||||||
|
|
||||||
const [clickOverlay, setClickOverlay] = useState(false);
|
const [clickOverlay, setClickOverlay] = useState(false);
|
||||||
@ -470,6 +477,7 @@ export default function LiveCameraView({
|
|||||||
setPip(false);
|
setPip(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
disabled={!cameraEnabled}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{supports2WayTalk && (
|
{supports2WayTalk && (
|
||||||
@ -481,11 +489,11 @@ export default function LiveCameraView({
|
|||||||
title={`${mic ? "Disable" : "Enable"} Two Way Talk`}
|
title={`${mic ? "Disable" : "Enable"} Two Way Talk`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMic(!mic);
|
setMic(!mic);
|
||||||
// Turn on audio when enabling the mic if audio is currently off
|
|
||||||
if (!mic && !audio) {
|
if (!mic && !audio) {
|
||||||
setAudio(true);
|
setAudio(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
disabled={!cameraEnabled}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{supportsAudioOutput && preferredLiveMode != "jsmpeg" && (
|
{supportsAudioOutput && preferredLiveMode != "jsmpeg" && (
|
||||||
@ -496,6 +504,7 @@ export default function LiveCameraView({
|
|||||||
isActive={audio ?? false}
|
isActive={audio ?? false}
|
||||||
title={`${audio ? "Disable" : "Enable"} Camera Audio`}
|
title={`${audio ? "Disable" : "Enable"} Camera Audio`}
|
||||||
onClick={() => setAudio(!audio)}
|
onClick={() => setAudio(!audio)}
|
||||||
|
disabled={!cameraEnabled}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<FrigateCameraFeatures
|
<FrigateCameraFeatures
|
||||||
@ -517,6 +526,7 @@ export default function LiveCameraView({
|
|||||||
setLowBandwidth={setLowBandwidth}
|
setLowBandwidth={setLowBandwidth}
|
||||||
supportsAudioOutput={supportsAudioOutput}
|
supportsAudioOutput={supportsAudioOutput}
|
||||||
supports2WayTalk={supports2WayTalk}
|
supports2WayTalk={supports2WayTalk}
|
||||||
|
cameraEnabled={cameraEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@ -913,6 +923,7 @@ type FrigateCameraFeaturesProps = {
|
|||||||
setLowBandwidth: React.Dispatch<React.SetStateAction<boolean>>;
|
setLowBandwidth: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
supportsAudioOutput: boolean;
|
supportsAudioOutput: boolean;
|
||||||
supports2WayTalk: boolean;
|
supports2WayTalk: boolean;
|
||||||
|
cameraEnabled: boolean;
|
||||||
};
|
};
|
||||||
function FrigateCameraFeatures({
|
function FrigateCameraFeatures({
|
||||||
camera,
|
camera,
|
||||||
@ -931,10 +942,14 @@ function FrigateCameraFeatures({
|
|||||||
setLowBandwidth,
|
setLowBandwidth,
|
||||||
supportsAudioOutput,
|
supportsAudioOutput,
|
||||||
supports2WayTalk,
|
supports2WayTalk,
|
||||||
|
cameraEnabled,
|
||||||
}: FrigateCameraFeaturesProps) {
|
}: FrigateCameraFeaturesProps) {
|
||||||
const { payload: detectState, send: sendDetect } = useDetectState(
|
const { payload: detectState, send: sendDetect } = useDetectState(
|
||||||
camera.name,
|
camera.name,
|
||||||
);
|
);
|
||||||
|
const { payload: enabledState, send: sendEnabled } = useEnabledState(
|
||||||
|
camera.name,
|
||||||
|
);
|
||||||
const { payload: recordState, send: sendRecord } = useRecordingsState(
|
const { payload: recordState, send: sendRecord } = useRecordingsState(
|
||||||
camera.name,
|
camera.name,
|
||||||
);
|
);
|
||||||
@ -1043,6 +1058,15 @@ function FrigateCameraFeatures({
|
|||||||
if (isDesktop || isTablet) {
|
if (isDesktop || isTablet) {
|
||||||
return (
|
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
|
<CameraFeatureToggle
|
||||||
className="p-2 md:p-0"
|
className="p-2 md:p-0"
|
||||||
variant={fullscreen ? "overlay" : "primary"}
|
variant={fullscreen ? "overlay" : "primary"}
|
||||||
@ -1050,6 +1074,7 @@ function FrigateCameraFeatures({
|
|||||||
isActive={detectState == "ON"}
|
isActive={detectState == "ON"}
|
||||||
title={`${detectState == "ON" ? "Disable" : "Enable"} Detect`}
|
title={`${detectState == "ON" ? "Disable" : "Enable"} Detect`}
|
||||||
onClick={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
|
onClick={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
|
||||||
|
disabled={!cameraEnabled}
|
||||||
/>
|
/>
|
||||||
<CameraFeatureToggle
|
<CameraFeatureToggle
|
||||||
className="p-2 md:p-0"
|
className="p-2 md:p-0"
|
||||||
@ -1058,6 +1083,7 @@ function FrigateCameraFeatures({
|
|||||||
isActive={recordState == "ON"}
|
isActive={recordState == "ON"}
|
||||||
title={`${recordState == "ON" ? "Disable" : "Enable"} Recording`}
|
title={`${recordState == "ON" ? "Disable" : "Enable"} Recording`}
|
||||||
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
|
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
|
||||||
|
disabled={!cameraEnabled}
|
||||||
/>
|
/>
|
||||||
<CameraFeatureToggle
|
<CameraFeatureToggle
|
||||||
className="p-2 md:p-0"
|
className="p-2 md:p-0"
|
||||||
@ -1066,6 +1092,7 @@ function FrigateCameraFeatures({
|
|||||||
isActive={snapshotState == "ON"}
|
isActive={snapshotState == "ON"}
|
||||||
title={`${snapshotState == "ON" ? "Disable" : "Enable"} Snapshots`}
|
title={`${snapshotState == "ON" ? "Disable" : "Enable"} Snapshots`}
|
||||||
onClick={() => sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")}
|
onClick={() => sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")}
|
||||||
|
disabled={!cameraEnabled}
|
||||||
/>
|
/>
|
||||||
{audioDetectEnabled && (
|
{audioDetectEnabled && (
|
||||||
<CameraFeatureToggle
|
<CameraFeatureToggle
|
||||||
@ -1075,6 +1102,7 @@ function FrigateCameraFeatures({
|
|||||||
isActive={audioState == "ON"}
|
isActive={audioState == "ON"}
|
||||||
title={`${audioState == "ON" ? "Disable" : "Enable"} Audio Detect`}
|
title={`${audioState == "ON" ? "Disable" : "Enable"} Audio Detect`}
|
||||||
onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
|
onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
|
||||||
|
disabled={!cameraEnabled}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{autotrackingEnabled && (
|
{autotrackingEnabled && (
|
||||||
@ -1087,6 +1115,7 @@ function FrigateCameraFeatures({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
|
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
|
||||||
}
|
}
|
||||||
|
disabled={!cameraEnabled}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<CameraFeatureToggle
|
<CameraFeatureToggle
|
||||||
@ -1099,6 +1128,7 @@ function FrigateCameraFeatures({
|
|||||||
isActive={isRecording}
|
isActive={isRecording}
|
||||||
title={`${isRecording ? "Stop" : "Start"} on-demand recording`}
|
title={`${isRecording ? "Stop" : "Start"} on-demand recording`}
|
||||||
onClick={handleEventButtonClick}
|
onClick={handleEventButtonClick}
|
||||||
|
disabled={!cameraEnabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
|
@ -29,7 +29,7 @@ import { MdCircle } from "react-icons/md";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useAlertsState, useDetectionsState } from "@/api/ws";
|
import { useAlertsState, useDetectionsState, useEnabledState } from "@/api/ws";
|
||||||
|
|
||||||
type CameraSettingsViewProps = {
|
type CameraSettingsViewProps = {
|
||||||
selectedCamera: string;
|
selectedCamera: string;
|
||||||
@ -108,6 +108,8 @@ export default function CameraSettingsView({
|
|||||||
const watchedAlertsZones = form.watch("alerts_zones");
|
const watchedAlertsZones = form.watch("alerts_zones");
|
||||||
const watchedDetectionsZones = form.watch("detections_zones");
|
const watchedDetectionsZones = form.watch("detections_zones");
|
||||||
|
|
||||||
|
const { payload: enabledState, send: sendEnabled } =
|
||||||
|
useEnabledState(selectedCamera);
|
||||||
const { payload: alertsState, send: sendAlerts } =
|
const { payload: alertsState, send: sendAlerts } =
|
||||||
useAlertsState(selectedCamera);
|
useAlertsState(selectedCamera);
|
||||||
const { payload: detectionsState, send: sendDetections } =
|
const { payload: detectionsState, send: sendDetections } =
|
||||||
@ -252,6 +254,31 @@ export default function CameraSettingsView({
|
|||||||
|
|
||||||
<Separator className="my-2 flex bg-secondary" />
|
<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">
|
<Heading as="h4" className="my-2">
|
||||||
Review
|
Review
|
||||||
</Heading>
|
</Heading>
|
||||||
|
@ -80,7 +80,7 @@ export default function NotificationView({
|
|||||||
return Object.values(config.cameras)
|
return Object.values(config.cameras)
|
||||||
.filter(
|
.filter(
|
||||||
(conf) =>
|
(conf) =>
|
||||||
conf.enabled &&
|
conf.enabled_in_config &&
|
||||||
conf.notifications &&
|
conf.notifications &&
|
||||||
conf.notifications.enabled_in_config,
|
conf.notifications.enabled_in_config,
|
||||||
)
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user