Compare commits

...
2 Commits
Author SHA1 Message Date
Josh HawkinsandGitHub 0e3dca5915 Live view improvements (#24967)
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Build (push) Waiting to run
CI / AMD64 Smoke Test (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* hold the last frame on screen while switching live streams

Switching streams in the single camera view blanked the player until the new stream played, because the old player unmounts in the same render and the hidden new player gave the card no height. The outgoing video's last frame is now drawn to a canvas before the remount and covers the gap until the new stream plays, and the card takes the camera's aspect ratio so it keeps its size while the video is hidden.

* show live streams as a one-tap grid in the mobile drawer

Switching streams on mobile meant scrolling past the camera toggles, opening a dropdown, and picking a stream. The drawer now opens with the stream section, and streams are a wrapping grid of toggles sized to their names, so a switch is two taps. A single stream shows as plain text. The audio and two-way talk lines show a spinner while the stream's go2rtc metadata loads instead of reporting audio as unavailable, which showed on the first switch to a transcoded stream.

* don't capture the switch frame on tiles that show the camera still
2026-11-13 09:51:16 -07:00
Josh HawkinsandGitHub 9276502e4f Switch Debug Replay scenes at runtime (#24958)
* allow switching the detect scene of a debug replay camera without a restart

- show the scene field in the replay detect settings
- drop the replay camera's cached model on a detect change so the refresh recycle picks up the new scene's model
- recreate the tracked object state on refresh when the camera's model changed
- reject a scene no model can serve with a 400 instead of crashing the tracker and maintainer threads
- size each camera's detection input shm for the largest model and recreate leftover smaller segments, so a camera re-added under a larger model no longer overflows it
- ignore event ends for objects already dropped by a state reset

* end tracked events before replacing replay camera state on a scene switch

- test the replay model rebind against two configured models
2026-11-13 09:08:29 -07:00
13 changed files with 346 additions and 157 deletions
+17 -1
View File
@@ -45,7 +45,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
from frigate.const import REDACTED_CREDENTIAL_SENTINEL, REPLAY_CAMERA_PREFIX
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
from frigate.genai import PROVIDERS, load_providers
from frigate.jobs.media_sync import (
@@ -979,6 +979,17 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
status_code=400,
)
# an unresolvable scene would crash the threads that look up the model
detect_update = sections.get("detect")
if isinstance(detect_update, dict) and "scene" in detect_update:
try:
config._resolve_camera_model(cam_name, detect_update["scene"])
except (TypeError, ValueError):
return JSONResponse(
content={"success": False, "message": "Invalid detect scene"},
status_code=400,
)
for section_name, update in sections.items():
err = apply_section_update(camera_config, section_name, update)
if err is not None:
@@ -987,6 +998,11 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
status_code=400,
)
# the model is resolved again from the scene when the replay
# camera is recycled by the refresh below
if "detect" in sections and cam_name.startswith(REPLAY_CAMERA_PREFIX):
config.drop_camera_model(cam_name)
# Publish ZMQ updates so processing threads pick up changes
if body.update_topic and body.update_topic.startswith("config/cameras/"):
_, _, camera, field = body.update_topic.split("/")
+2 -9
View File
@@ -76,7 +76,7 @@ from frigate.models import (
from frigate.notices import install_registry
from frigate.notices.registry import NoticeRegistry
from frigate.object_detection.base import ObjectDetectProcess
from frigate.object_detection.util import detection_frame_size
from frigate.object_detection.util import create_detection_input_shm
from frigate.output.output import OutputProcess
from frigate.ptz.autotrack import PtzAutoTracker
from frigate.ptz.onvif import OnvifController
@@ -399,14 +399,7 @@ class FrigateApp:
model = self.config.model_for_camera(name)
model_cameras[model.scene].append(name)
try:
shm_in = UntrackedSharedMemory(
name=name,
create=True,
size=detection_frame_size(model),
)
except FileExistsError:
shm_in = UntrackedSharedMemory(name=name)
shm_in = create_detection_input_shm(name, self.config.models)
try:
shm_out = UntrackedSharedMemory(
+3 -6
View File
@@ -16,7 +16,7 @@ from frigate.config.camera.updater import (
)
from frigate.const import REPLAY_CAMERA_PREFIX
from frigate.models import Regions
from frigate.object_detection.util import detection_frame_size
from frigate.object_detection.util import create_detection_input_shm
from frigate.util.builtin import empty_and_close_queue
from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory
from frigate.util.object import get_camera_regions_grid
@@ -127,13 +127,10 @@ class CameraMaintainer(threading.Thread):
max(model.width, model.height),
)
create_detection_input_shm(name, self.config.models)
try:
UntrackedSharedMemory(name=f"out-{name}", create=True, size=20 * 6 * 4)
UntrackedSharedMemory(
name=name,
create=True,
size=detection_frame_size(model),
)
except FileExistsError:
pass
+2 -1
View File
@@ -296,7 +296,8 @@ class CameraState:
return frame_copy
def finished(self, obj_id: str) -> None:
del self.tracked_objects[obj_id]
# state rebuilt after a resolution or scene change no longer has the object
self.tracked_objects.pop(obj_id, None)
def on(self, event_type: str, callback: Callable[..., Any]) -> None:
self.callbacks[event_type].append(callback)
+23 -14
View File
@@ -113,6 +113,23 @@ class AsyncLocalObjectDetector(BaseLocalDetector):
return self.detect_api.receive_output()
def map_input_frame(
frame_manager: SharedMemoryFrameManager, camera: str, model: ModelConfig
) -> np.ndarray | None:
"""Map a camera's detection input segment as this model's input tensor.
The segment is sized for the largest configured model, so it can be larger
than this model's input.
"""
buf = frame_manager.write(camera)
shape = (1, model.height, model.width, 3)
if buf is None or len(buf) < model.height * model.width * 3:
return None
return np.ndarray(shape, dtype=np.uint8, buffer=buf)
class DetectorRunner(FrigateProcess):
def __init__(
self,
@@ -154,14 +171,10 @@ class DetectorRunner(FrigateProcess):
connection_id = self.detection_queue.get(timeout=1)
except queue.Empty:
continue
input_frame = frame_manager.get(
input_frame = map_input_frame(
frame_manager,
connection_id,
(
1,
self.detector_config.model.height, # type: ignore[union-attr]
self.detector_config.model.width, # type: ignore[union-attr]
3,
),
self.detector_config.model, # type: ignore[arg-type]
)
if input_frame is None:
@@ -227,14 +240,10 @@ class AsyncDetectorRunner(FrigateProcess):
continue
assert self._frame_manager is not None
input_frame = self._frame_manager.get(
input_frame = map_input_frame(
self._frame_manager,
connection_id,
(
1,
self.detector_config.model.height, # type: ignore[union-attr]
self.detector_config.model.width, # type: ignore[union-attr]
3,
),
self.detector_config.model, # type: ignore[arg-type]
)
if input_frame is None:
+26 -5
View File
@@ -6,18 +6,39 @@ import threading
from numpy import ndarray
from frigate.detectors.detector_config import InputTensorEnum, ModelConfig
from frigate.util.image import UntrackedSharedMemory
def detection_frame_size(model: ModelConfig) -> int:
"""Get the shared memory size a camera needs to hand frames to a model.
def create_detection_input_shm(
camera: str, models: list[ModelConfig]
) -> UntrackedSharedMemory:
"""Create the shared memory a camera hands detection frames through.
It is sized for the largest model so a camera re-added under the same name
with another scene can reuse it. Detectors keep mappings of it, so it is
never unlinked while they run.
Args:
model: The model the camera runs on
camera: Name of the camera
models: Every configured model
Returns:
Size in bytes of one model input frame
The camera's detection input segment
"""
return model.height * model.width * 3
size = max(model.height * model.width * 3 for model in models)
try:
return UntrackedSharedMemory(name=camera, create=True, size=size)
except FileExistsError:
shm = UntrackedSharedMemory(name=camera)
if shm.size >= size:
return shm
# left by an earlier run with smaller models, so no detector maps it yet
shm.close()
shm.unlink()
return UntrackedSharedMemory(name=camera, create=True, size=size)
class RequestStore:
+40
View File
@@ -98,6 +98,46 @@ class TestHttpApp(BaseTestHttp):
assert response.status_code == 200
assert app.frigate_config.cameras["front_door"].objects.track == ["person"]
@patch("frigate.detectors.detector_config.load_labels")
def test_config_set_in_memory_rebinds_replay_model_on_scene_change(
self, mock_labels
):
mock_labels.return_value = {}
replay = "_replay_front_door"
self.minimal_config["models"] = [
{"devices": ["cpu"]},
{"scene": "thermal", "devices": ["openvino:CPU"], "width": 300},
]
self.minimal_config["cameras"][replay] = self.minimal_config["cameras"].pop(
"front_door"
)
app = super().create_app()
app.config_publisher = Mock()
assert app.frigate_config.model_for_camera(replay).scene == "default"
with AuthTestClient(app) as client:
response = client.put(
"/config/set",
json={
"requires_restart": 0,
"skip_save": True,
"update_topic": f"config/cameras/{replay}/detect",
"config_data": {
"cameras": {replay: {"detect": {"scene": "thermal"}}}
},
},
)
assert response.status_code == 200
assert app.frigate_config.cameras[replay].detect.scene == "thermal"
assert app.frigate_config.model_for_camera(replay).scene == "thermal"
topics = [
call.args[0].update_type.name
for call in app.config_publisher.publish_update.call_args_list
]
assert topics[0] == "detect"
assert topics[-1] == "refresh"
####################################################################################################################
################################### Credential redaction sentinel ################################################
####################################################################################################################
+16
View File
@@ -91,6 +91,7 @@ class TrackedObjectProcessor(threading.Thread):
CameraConfigUpdateEnum.enabled,
CameraConfigUpdateEnum.motion,
CameraConfigUpdateEnum.objects,
CameraConfigUpdateEnum.refresh,
CameraConfigUpdateEnum.remove,
CameraConfigUpdateEnum.timestamp_style,
CameraConfigUpdateEnum.zones,
@@ -784,6 +785,21 @@ class TrackedObjectProcessor(threading.Thread):
self.requestor.send_data(UPDATE_CAMERA_ACTIVITY, self.camera_activity)
# a replay camera recycled onto another scene's model needs new state
for camera in updated_topics.get("refresh", []):
camera_state = self.camera_states.get(camera)
if (
camera_state is None
or camera_state.model is self.config.model_for_camera(camera)
):
continue
self.force_end_all_events(camera, camera_state)
camera_state.shutdown()
self.frame_manager.close_camera(camera)
self.create_camera_state(camera)
# manage camera disabled state
for camera, config in self.config.cameras.items():
if not config.enabled_in_config:
+3
View File
@@ -173,6 +173,9 @@
"label": "Play in background",
"tips": "Enable this option to continue streaming when the player is hidden."
},
"metadata": {
"loading": "Fetching stream metadata…"
},
"mode": "Streaming Technology",
"technology": {
"description": "Choose your preferred streaming technology. Frigate may still fall back to low bandwidth mode on playback or network errors.",
@@ -240,14 +240,14 @@ const detect: SectionConfigOverrides = {
},
replay: {
restartRequired: [],
fieldOrder: ["width", "height", "fps"],
fieldOrder: ["scene", "width", "height", "fps"],
fieldGroups: {
resolution: ["width", "height", "fps"],
},
hiddenFields: [
"enabled",
"enabled_in_config",
"scene",
hideSceneWithoutChoice,
"min_initialized",
"max_disappeared",
"annotation_offset",
+34
View File
@@ -248,10 +248,21 @@ export default function LivePlayer({
// remounts them in the same render and the new player is hidden until it
// plays. jsmpeg plays the camera, not the stream, and keeps playing
const [renderedStream, setRenderedStream] = useState(streamName);
const [switchFrame, setSwitchFrame] = useState<HTMLCanvasElement>();
if (renderedStream !== streamName) {
setRenderedStream(streamName);
if (preferredLiveMode !== "jsmpeg") {
setLiveReady(false);
// the old video is still in the DOM until this render commits, so its
// last frame can cover the gap until the new stream plays. Tiles that
// show the camera still already cover it
if (!showStillWithoutActivity) {
const frame = captureVideoFrame(overlayRef.current);
if (frame) {
setSwitchFrame(frame);
}
}
}
}
@@ -263,6 +274,7 @@ export default function LivePlayer({
const playerIsPlaying = useCallback(() => {
setLiveReady(true);
setSwitchFrame(undefined);
}, []);
// enabled states
@@ -416,6 +428,12 @@ export default function LivePlayer({
</div>
)}
{player}
{switchFrame && !liveReady && (
<div
className="absolute inset-0"
ref={(node) => node?.replaceChildren(switchFrame)}
/>
)}
{cameraEnabled &&
!offline &&
(!showStillWithoutActivity || isReEnabling) &&
@@ -582,3 +600,19 @@ export default function LivePlayer({
</div>
);
}
function captureVideoFrame(
container: HTMLElement | null,
): HTMLCanvasElement | undefined {
const video = container?.querySelector("video");
if (!video || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
return undefined;
}
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.className = "size-full object-contain";
canvas.getContext("2d")?.drawImage(video, 0, 0);
return canvas;
}
+46 -10
View File
@@ -12,11 +12,15 @@ import { useTranslation } from "react-i18next";
import { LuInfo } from "react-icons/lu";
import { MdOutlineRestartAlt } from "react-icons/md";
import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
// Radix Select needs a non-empty value. Auto itself is stored as the
// absence of a pinned stream
const AUTO_VALUE = "__frigate_auto__";
const GRID_ITEM =
"h-auto min-h-10 min-w-[calc((100%-1rem)/3)] grow whitespace-normal bg-secondary [overflow-wrap:anywhere] px-2 py-2 text-secondary-foreground hover:bg-secondary hover:text-secondary-foreground data-[state=on]:bg-selected data-[state=on]:text-selected-foreground data-[state=on]:hover:bg-selected data-[state=on]:hover:text-selected-foreground";
type LiveStreamSelectProps = {
// friendly name to go2rtc stream name, in ladder order
streams: Record<string, string>;
@@ -29,6 +33,7 @@ type LiveStreamSelectProps = {
// sends auto back to the top of the ladder
onRetry: () => void;
disabled: boolean;
variant?: "select" | "grid";
};
export default function LiveStreamSelect({
@@ -40,6 +45,7 @@ export default function LiveStreamSelect({
onSelect,
onRetry,
disabled,
variant = "select",
}: LiveStreamSelectProps) {
const { t } = useTranslation(["views/live"]);
@@ -52,8 +58,46 @@ export default function LiveStreamSelect({
const auto = autoAvailable && pinnedStream == undefined;
const value = auto ? AUTO_VALUE : (pinnedStream ?? playingStream);
const autoLabel = auto
? t("stream.auto.selected", {
stream:
autoReason === "floor"
? t("stream.auto.floorLabel")
: labelFor(playingStream),
})
: t("stream.auto.label");
return (
<>
{variant === "grid" &&
!autoAvailable &&
Object.keys(streams).length <= 1 ? (
<div className="text-sm">{labelFor(playingStream)}</div>
) : variant === "grid" ? (
<ToggleGroup
type="single"
className="flex flex-wrap items-stretch gap-2"
value={value}
disabled={disabled}
onValueChange={(next) => {
// radix sends an empty value when the active item is tapped again
if (next) {
onSelect(next === AUTO_VALUE ? undefined : next);
}
}}
>
{autoAvailable && (
<ToggleGroupItem value={AUTO_VALUE} className={GRID_ITEM}>
{autoLabel}
</ToggleGroupItem>
)}
{Object.entries(streams).map(([label, name]) => (
<ToggleGroupItem key={label} value={name} className={GRID_ITEM}>
{label}
</ToggleGroupItem>
))}
</ToggleGroup>
) : (
<Select
value={value}
disabled={disabled}
@@ -62,16 +106,7 @@ export default function LiveStreamSelect({
}
>
<SelectTrigger className="w-full">
<SelectValue>
{auto
? t("stream.auto.selected", {
stream:
autoReason === "floor"
? t("stream.auto.floorLabel")
: labelFor(playingStream),
})
: labelFor(value)}
</SelectValue>
<SelectValue>{auto ? autoLabel : labelFor(value)}</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -94,6 +129,7 @@ export default function LiveStreamSelect({
</SelectGroup>
</SelectContent>
</Select>
)}
{auto && autoReason && !disabled && (
<div className="flex flex-col items-center gap-3">
+95 -72
View File
@@ -230,7 +230,8 @@ export default function LiveCameraView({
}
}, [streamNameLoaded, liveStreams, pinnedStream, clearPinnedStream]);
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>(
const { data: cameraMetadata, isLoading: cameraMetadataLoading } =
useSWR<LiveStreamMetadata>(
isRestreamed ? `go2rtc/streams/${streamName}` : null,
{
revalidateOnFocus: false,
@@ -847,6 +848,7 @@ export default function LiveCameraView({
onResetStream={resetStream}
supportsAudioOutput={supportsAudioOutput}
supports2WayTalk={supports2WayTalk}
streamMetadataLoading={cameraMetadataLoading}
cameraEnabled={cameraEnabled}
debug={debug}
setDebug={setDebug}
@@ -873,9 +875,12 @@ export default function LiveCameraView({
growClassName,
)}
ref={clickOverlayRef}
style={{
style={
{
aspectRatio: constrainedAspectRatio,
}}
"--camera-aspect": cameraAspectRatio,
} as React.CSSProperties
}
>
{clickOverlay && overlaySize.width > 0 && (
<div
@@ -914,7 +919,10 @@ export default function LiveCameraView({
)}
<LivePlayer
key={camera.name}
className={fullscreen ? "rounded-none" : ""}
className={cn(
"aspect-[var(--camera-aspect)]",
fullscreen && "rounded-none",
)}
windowVisible
showStillWithoutActivity={false}
alwaysShowCameraName={false}
@@ -1007,6 +1015,7 @@ type FrigateCameraFeaturesProps = {
onResetStream: () => void;
supportsAudioOutput: boolean;
supports2WayTalk: boolean;
streamMetadataLoading: boolean;
cameraEnabled: boolean;
debug: boolean;
setDebug: (debug: boolean) => void;
@@ -1038,6 +1047,7 @@ function FrigateCameraFeatures({
onResetStream,
supportsAudioOutput,
supports2WayTalk,
streamMetadataLoading,
cameraEnabled,
debug,
setDebug,
@@ -1434,7 +1444,12 @@ function FrigateCameraFeatures({
!debug &&
isRestreamed && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutput ? (
{streamMetadataLoading ? (
<>
<ActivityIndicator className="size-4" />
<div>{t("stream.metadata.loading")}</div>
</>
) : supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>{t("stream.audio.available")}</div>
@@ -1693,72 +1708,7 @@ function FrigateCameraFeatures({
<DrawerContent className="max-h-[75dvh] overflow-hidden rounded-2xl">
<div className="scrollbar-container mt-2 flex h-auto flex-col gap-2 overflow-y-auto px-2 py-4">
<>
{isAdmin && (
<>
<FilterSwitch
label={t("cameraSettings.camera")}
isChecked={enabledState == "ON"}
onCheckedChange={() =>
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
}
/>
<FilterSwitch
label={t("cameraSettings.objectDetection")}
isChecked={detectState == "ON"}
onCheckedChange={() =>
sendDetect(detectState == "ON" ? "OFF" : "ON")
}
/>
{recordingEnabled && (
<FilterSwitch
label={t("cameraSettings.recording")}
isChecked={recordState == "ON"}
onCheckedChange={() =>
sendRecord(recordState == "ON" ? "OFF" : "ON")
}
/>
)}
<FilterSwitch
label={t("cameraSettings.snapshots")}
isChecked={snapshotState == "ON"}
onCheckedChange={() =>
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
}
/>
{audioDetectEnabled && (
<FilterSwitch
label={t("cameraSettings.audioDetection")}
isChecked={audioState == "ON"}
onCheckedChange={() =>
sendAudio(audioState == "ON" ? "OFF" : "ON")
}
/>
)}
{audioDetectEnabled && transcriptionEnabled && (
<FilterSwitch
label={t("cameraSettings.transcription")}
disabled={audioState == "OFF"}
isChecked={transcriptionState == "ON"}
onCheckedChange={() =>
sendTranscription(
transcriptionState == "ON" ? "OFF" : "ON",
)
}
/>
)}
{autotrackingEnabled && (
<FilterSwitch
label={t("cameraSettings.autotracking")}
isChecked={autotrackingState == "ON"}
onCheckedChange={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
}
/>
)}
</>
)}
<div className="mt-3 flex flex-col gap-5">
<div className="mb-3 flex flex-col gap-5">
{!isRestreamed && (
<div className="flex flex-col gap-2 p-2">
<Label>{t("stream.title")}</Label>
@@ -1811,6 +1761,7 @@ function FrigateCameraFeatures({
onSelect={onSelectStream}
onRetry={onResetStream}
disabled={debug || forceLowBandwidth}
variant="grid"
/>
{debug && (
@@ -1826,7 +1777,12 @@ function FrigateCameraFeatures({
!debug &&
isRestreamed && (
<div className="mt-1 flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutput ? (
{streamMetadataLoading ? (
<>
<ActivityIndicator className="size-4" size={14} />
<div>{t("stream.metadata.loading")}</div>
</>
) : supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>{t("stream.audio.available")}</div>
@@ -1955,6 +1911,73 @@ function FrigateCameraFeatures({
</p>
</div>
)}
</div>
{isAdmin && (
<>
<FilterSwitch
label={t("cameraSettings.camera")}
isChecked={enabledState == "ON"}
onCheckedChange={() =>
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
}
/>
<FilterSwitch
label={t("cameraSettings.objectDetection")}
isChecked={detectState == "ON"}
onCheckedChange={() =>
sendDetect(detectState == "ON" ? "OFF" : "ON")
}
/>
{recordingEnabled && (
<FilterSwitch
label={t("cameraSettings.recording")}
isChecked={recordState == "ON"}
onCheckedChange={() =>
sendRecord(recordState == "ON" ? "OFF" : "ON")
}
/>
)}
<FilterSwitch
label={t("cameraSettings.snapshots")}
isChecked={snapshotState == "ON"}
onCheckedChange={() =>
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
}
/>
{audioDetectEnabled && (
<FilterSwitch
label={t("cameraSettings.audioDetection")}
isChecked={audioState == "ON"}
onCheckedChange={() =>
sendAudio(audioState == "ON" ? "OFF" : "ON")
}
/>
)}
{audioDetectEnabled && transcriptionEnabled && (
<FilterSwitch
label={t("cameraSettings.transcription")}
disabled={audioState == "OFF"}
isChecked={transcriptionState == "ON"}
onCheckedChange={() =>
sendTranscription(
transcriptionState == "ON" ? "OFF" : "ON",
)
}
/>
)}
{autotrackingEnabled && (
<FilterSwitch
label={t("cameraSettings.autotracking")}
isChecked={autotrackingState == "ON"}
onCheckedChange={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
}
/>
)}
</>
)}
<div className="mt-3 flex flex-col gap-5">
{isRestreamed &&
Object.values(camera.live.streams).length > 0 && (
<div className="flex flex-col gap-2">