Compare commits

...

11 Commits

Author SHA1 Message Date
Daniel
9bf89993d7
Merge f68ff53bd9 into de066d0062 2025-11-14 16:59:57 +08:00
GuoQing Liu
de066d0062
Fix i18n (#20857)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* fix: fix the missing i18n key

* fix: fix trackedObject i18n keys count variable

* fix: fix some pages audio label missing i18n

* fix: add 6214d52 missing variable

* fix: add more missing i18n

* fix: add menu missing key
2025-11-11 17:23:30 -06:00
Nicolas Mowen
f1a05d0f9b
Miscellaneous fixes (#20875)
* Improve stream fetching logic

* Reduce need to revalidate stream info

* fix frigate+ frame submission

* add UI setting to configure jsmpeg fallback timeout

* hide settings dropdown when fullscreen

* Fix arcface running on OpenVINO

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2025-11-11 17:00:54 -06:00
Josh Hawkins
a623150811
Add Camera Wizard tweaks (#20889)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (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
* digest auth backend

* frontend

* i18n

* update field description language to include note about onvif specific credentials

* mask util helper function

* language

* mask passwords in http-flv and others where a url param is password
2025-11-11 06:46:23 -07:00
Daniel
f68ff53bd9 fix MQTT disconnect reason code comparison bug
The mypy fixes introduced a bug where disconnect callback was comparing
reason_code == 0 instead of reason_value == 0. Since reason_code is a
ReasonCode object, it never equals integer 0, causing clean disconnects
to never be detected and infinite retry loops to occur.

Fixed by using reason_value (extracted from reason_code.value) for
numeric comparison, consistent with how we extract the value for logging.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 20:07:22 -04:00
Daniel
b0f189056a fix MQTT retry loop race condition with cleanup disconnect
Previously, the reconnection loop was calling client.disconnect() during cleanup,
which triggered the disconnect callback with code 0 ("Normal disconnection").
The callback would then exit early, preventing further reconnection attempts.

This creates a cleanup flag that prevents the disconnect callback from
stopping reconnection when we're intentionally cleaning up the old client
during retry attempts.

Fixes issue where MQTT would get stuck in retry loop without actually
attempting fresh connections.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 19:03:34 -04:00
Dan
91d3a7b245 format MQTT code with ruff 2025-09-01 18:12:51 -04:00
Dan
44c07aac12 fix mypy errors with improved MQTT reliability implementation
- Add _reason_info helper method to extract reason names safely
- Update connect/disconnect callbacks to use string comparison instead of numeric
- Fix type annotations with proper Optional[Client] typing
- Resolve unreachable code warnings with type ignore comments for threading
- Improve error handling with robust try/finally blocks in stop method
- Maintain fresh client creation approach for reliable reconnection

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 18:08:01 -04:00
Dan
3952035579 format MQTT code with ruff 2025-09-01 15:54:48 -04:00
Dan
a45915517f improve MQTT reconnection with fresh client creation approach
- Replace client.reconnect() with fresh client creation for each retry attempt
- Add proper cleanup of old client before creating new one
- Enhanced logging with detailed debugging info for disconnect reasons
- Use proven aiomqtt-style retry pattern for better reliability
- Each reconnection attempt now creates completely new MQTT client instance

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 15:32:04 -04:00
Dan
f95a49b81d implement automatic MQTT reconnection with 10s retry interval
- Fix connection state management: only set connected=True on successful connection
- Add automatic reconnection loop that retries every 10 seconds indefinitely
- Proper cleanup of reconnection thread on stop
- Enhanced disconnect logging with reason codes
- Thread-safe reconnection handling to avoid blocking main MQTT thread

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 12:57:02 -04:00
26 changed files with 768 additions and 333 deletions

View File

@ -7,11 +7,13 @@ from importlib.util import find_spec
from pathlib import Path from pathlib import Path
from urllib.parse import quote_plus from urllib.parse import quote_plus
import httpx
import requests import requests
from fastapi import APIRouter, Depends, Query, Request, Response from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from onvif import ONVIFCamera, ONVIFError from onvif import ONVIFCamera, ONVIFError
from zeep.exceptions import Fault, TransportError from zeep.exceptions import Fault, TransportError
from zeep.transports import AsyncTransport
from frigate.api.auth import require_role from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
@ -464,7 +466,8 @@ def _extract_fps(r_frame_rate: str) -> float | None:
summary="Probe ONVIF device", summary="Probe ONVIF device",
description=( description=(
"Probe an ONVIF device to determine capabilities and optionally test available stream URIs. " "Probe an ONVIF device to determine capabilities and optionally test available stream URIs. "
"Query params: host (required), port (default 80), username, password, test (boolean)." "Query params: host (required), port (default 80), username, password, test (boolean), "
"auth_type (basic or digest, default basic)."
), ),
) )
async def onvif_probe( async def onvif_probe(
@ -474,6 +477,7 @@ async def onvif_probe(
username: str = Query(""), username: str = Query(""),
password: str = Query(""), password: str = Query(""),
test: bool = Query(False), test: bool = Query(False),
auth_type: str = Query("basic"), # Add auth_type parameter
): ):
""" """
Probe a single ONVIF device to determine capabilities. Probe a single ONVIF device to determine capabilities.
@ -491,6 +495,7 @@ async def onvif_probe(
username: ONVIF username (optional) username: ONVIF username (optional)
password: ONVIF password (optional) password: ONVIF password (optional)
test: run ffprobe on the stream (optional) test: run ffprobe on the stream (optional)
auth_type: Authentication type - "basic" or "digest" (default "basic")
Returns: Returns:
JSON with device capabilities information JSON with device capabilities information
@ -508,10 +513,20 @@ async def onvif_probe(
status_code=400, status_code=400,
) )
# Validate auth_type
if auth_type not in ["basic", "digest"]:
return JSONResponse(
content={
"success": False,
"message": "auth_type must be 'basic' or 'digest'",
},
status_code=400,
)
onvif_camera = None onvif_camera = None
try: try:
logger.debug(f"Probing ONVIF device at {host}:{port}") logger.debug(f"Probing ONVIF device at {host}:{port} with {auth_type} auth")
try: try:
wsdl_base = None wsdl_base = None
@ -525,7 +540,29 @@ async def onvif_probe(
host, port, username or "", password or "", wsdl_dir=wsdl_base host, port, username or "", password or "", wsdl_dir=wsdl_base
) )
await onvif_camera.update_xaddrs() # Configure digest authentication if requested
if auth_type == "digest" and username and password:
# Create httpx client with digest auth
auth = httpx.DigestAuth(username, password)
client = httpx.AsyncClient(auth=auth, timeout=10.0)
# Replace the transport in the zeep client
transport = AsyncTransport(client=client)
# Update the xaddr before setting transport
await onvif_camera.update_xaddrs()
# Replace transport in all services
if hasattr(onvif_camera, "devicemgmt"):
onvif_camera.devicemgmt.zeep_client.transport = transport
if hasattr(onvif_camera, "media"):
onvif_camera.media.zeep_client.transport = transport
if hasattr(onvif_camera, "ptz"):
onvif_camera.ptz.zeep_client.transport = transport
logger.debug("Configured digest authentication")
else:
await onvif_camera.update_xaddrs()
# Get device information # Get device information
device_info = { device_info = {
@ -535,6 +572,14 @@ async def onvif_probe(
} }
try: try:
device_service = await onvif_camera.create_devicemgmt_service() device_service = await onvif_camera.create_devicemgmt_service()
# Update transport for device service if digest auth
if auth_type == "digest" and username and password:
auth = httpx.DigestAuth(username, password)
client = httpx.AsyncClient(auth=auth, timeout=10.0)
transport = AsyncTransport(client=client)
device_service.zeep_client.transport = transport
device_info_resp = await device_service.GetDeviceInformation() device_info_resp = await device_service.GetDeviceInformation()
manufacturer = getattr(device_info_resp, "Manufacturer", None) or ( manufacturer = getattr(device_info_resp, "Manufacturer", None) or (
device_info_resp.get("Manufacturer") device_info_resp.get("Manufacturer")
@ -558,8 +603,8 @@ async def onvif_probe(
"firmware_version": firmware or "Unknown", "firmware_version": firmware or "Unknown",
} }
) )
except Exception: except Exception as e:
logger.debug("Failed to get device info") logger.debug(f"Failed to get device info: {e}")
# Get media profiles # Get media profiles
profiles = [] profiles = []
@ -568,6 +613,14 @@ async def onvif_probe(
ptz_config_token = None ptz_config_token = None
try: try:
media_service = await onvif_camera.create_media_service() media_service = await onvif_camera.create_media_service()
# Update transport for media service if digest auth
if auth_type == "digest" and username and password:
auth = httpx.DigestAuth(username, password)
client = httpx.AsyncClient(auth=auth, timeout=10.0)
transport = AsyncTransport(client=client)
media_service.zeep_client.transport = transport
profiles = await media_service.GetProfiles() profiles = await media_service.GetProfiles()
profiles_count = len(profiles) if profiles else 0 profiles_count = len(profiles) if profiles else 0
if profiles and len(profiles) > 0: if profiles and len(profiles) > 0:
@ -585,8 +638,8 @@ async def onvif_probe(
if isinstance(ptz_configuration, dict) if isinstance(ptz_configuration, dict)
else None else None
) )
except Exception: except Exception as e:
logger.debug("Failed to get media profiles") logger.debug(f"Failed to get media profiles: {e}")
# Check PTZ support and capabilities # Check PTZ support and capabilities
ptz_supported = False ptz_supported = False
@ -596,6 +649,13 @@ async def onvif_probe(
try: try:
ptz_service = await onvif_camera.create_ptz_service() ptz_service = await onvif_camera.create_ptz_service()
# Update transport for PTZ service if digest auth
if auth_type == "digest" and username and password:
auth = httpx.DigestAuth(username, password)
client = httpx.AsyncClient(auth=auth, timeout=10.0)
transport = AsyncTransport(client=client)
ptz_service.zeep_client.transport = transport
# Check if PTZ service is available # Check if PTZ service is available
try: try:
await ptz_service.GetServiceCapabilities() await ptz_service.GetServiceCapabilities()
@ -744,6 +804,14 @@ async def onvif_probe(
rtsp_candidates: list[dict] = [] rtsp_candidates: list[dict] = []
try: try:
media_service = await onvif_camera.create_media_service() media_service = await onvif_camera.create_media_service()
# Update transport for media service if digest auth
if auth_type == "digest" and username and password:
auth = httpx.DigestAuth(username, password)
client = httpx.AsyncClient(auth=auth, timeout=10.0)
transport = AsyncTransport(client=client)
media_service.zeep_client.transport = transport
if profiles_count and media_service: if profiles_count and media_service:
for p in profiles or []: for p in profiles or []:
token = getattr(p, "token", None) or ( token = getattr(p, "token", None) or (

View File

@ -1,6 +1,7 @@
import logging import logging
import threading import threading
from typing import Any, Callable import time
from typing import Any, Callable, Optional
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion from paho.mqtt.enums import CallbackAPIVersion
@ -18,15 +19,20 @@ class MqttClient(Communicator):
self.config = config self.config = config
self.mqtt_config = config.mqtt self.mqtt_config = config.mqtt
self.connected = False self.connected = False
self.client: Optional[mqtt.Client] = None
self._dispatcher: Callable[[str, str], None] = lambda *_: None
self._reconnect_thread: Optional[threading.Thread] = None
self._reconnect_delay = 10 # Retry every 10 seconds
self._stop_reconnect: bool = False
def subscribe(self, receiver: Callable) -> None: def subscribe(self, receiver: Callable[[str, str], None]) -> None:
"""Wrapper for allowing dispatcher to subscribe.""" """Wrapper for allowing dispatcher to subscribe."""
self._dispatcher = receiver self._dispatcher = receiver
self._start() self._start()
def publish(self, topic: str, payload: Any, retain: bool = False) -> None: def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""Wrapper for publishing when client is in valid state.""" """Wrapper for publishing when client is in valid state."""
if not self.connected: if not self.connected or self.client is None:
logger.debug(f"Unable to publish to {topic}: client is not connected") logger.debug(f"Unable to publish to {topic}: client is not connected")
return return
@ -38,7 +44,17 @@ class MqttClient(Communicator):
) )
def stop(self) -> None: def stop(self) -> None:
self.client.disconnect() self._stop_reconnect = True
if self._reconnect_thread is not None and self._reconnect_thread.is_alive():
self._reconnect_thread.join(timeout=5)
if self.client is not None:
try:
self.client.disconnect()
finally:
try:
self.client.loop_stop()
except Exception:
pass
def _set_initial_topics(self) -> None: def _set_initial_topics(self) -> None:
"""Set initial state topics.""" """Set initial state topics."""
@ -142,6 +158,22 @@ class MqttClient(Communicator):
self.publish("available", "online", retain=True) self.publish("available", "online", retain=True)
@staticmethod
def _reason_info(reason_code: object) -> str:
"""Return human_readable_name for a Paho reason code."""
# Name string
if hasattr(reason_code, "getName") and callable(
getattr(reason_code, "getName")
):
try:
name = str(getattr(reason_code, "getName")())
except Exception:
name = str(reason_code)
else:
name = str(reason_code)
return name
def on_mqtt_command( def on_mqtt_command(
self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage
) -> None: ) -> None:
@ -155,27 +187,31 @@ class MqttClient(Communicator):
client: mqtt.Client, client: mqtt.Client,
userdata: Any, userdata: Any,
flags: Any, flags: Any,
reason_code: mqtt.ReasonCode, # type: ignore[name-defined] reason_code: object,
properties: Any, properties: Any,
) -> None: ) -> None:
"""Mqtt connection callback.""" """Mqtt connection callback."""
threading.current_thread().name = "mqtt" threading.current_thread().name = "mqtt"
if reason_code != 0: reason_name = self._reason_info(reason_code)
if reason_code == "Server unavailable":
# Check for connection failure by comparing reason name
if reason_name != "Success":
if reason_name == "Server unavailable":
logger.error( logger.error(
"Unable to connect to MQTT server: MQTT Server unavailable" "Unable to connect to MQTT server: MQTT Server unavailable"
) )
elif reason_code == "Bad user name or password": elif reason_name == "Bad user name or password":
logger.error( logger.error(
"Unable to connect to MQTT server: MQTT Bad username or password" "Unable to connect to MQTT server: MQTT Bad username or password"
) )
elif reason_code == "Not authorized": elif reason_name == "Not authorized":
logger.error("Unable to connect to MQTT server: MQTT Not authorized") logger.error("Unable to connect to MQTT server: MQTT Not authorized")
else: else:
logger.error( logger.error(
"Unable to connect to MQTT server: Connection refused. Error code: " f"Unable to connect to MQTT server: Connection refused. Error: {reason_name}"
+ reason_code.getName()
) )
# Don't set connected = True on connection failure
return
self.connected = True self.connected = True
logger.debug("MQTT connected") logger.debug("MQTT connected")
@ -192,7 +228,34 @@ class MqttClient(Communicator):
) -> None: ) -> None:
"""Mqtt disconnection callback.""" """Mqtt disconnection callback."""
self.connected = False self.connected = False
logger.error("MQTT disconnected") # Debug reason code thoroughly
reason_name = (
reason_code.getName()
if hasattr(reason_code, "getName")
else str(reason_code)
)
reason_value = getattr(reason_code, "value", reason_code)
logger.error(
f"MQTT disconnected - reason: '{reason_name}', code: {reason_value}, type: {type(reason_code)}"
)
# Don't attempt reconnection if we're stopping or if it was a clean disconnect
if self._stop_reconnect:
logger.error("MQTT not reconnecting - stop flag set")
return
if reason_value == 0:
logger.error("MQTT not reconnecting - clean disconnect (code 0)")
return
logger.error("MQTT will attempt reconnection...")
# Start reconnection in a separate thread to avoid blocking
if self._reconnect_thread is None or not self._reconnect_thread.is_alive():
self._reconnect_thread = threading.Thread(
target=self._reconnect_loop, name="mqtt-reconnect", daemon=True
)
self._reconnect_thread.start()
def _start(self) -> None: def _start(self) -> None:
"""Start mqtt client.""" """Start mqtt client."""
@ -281,3 +344,66 @@ class MqttClient(Communicator):
except Exception as e: except Exception as e:
logger.error(f"Unable to connect to MQTT server: {e}") logger.error(f"Unable to connect to MQTT server: {e}")
return return
def _reconnect_loop(self) -> None:
"""Handle MQTT reconnection using fresh client creation, retrying every 10 seconds indefinitely."""
logger.error("MQTT reconnection loop started")
attempt = 0
while not self._stop_reconnect and not self.connected:
attempt += 1
logger.error(
f"Will attempt MQTT reconnection in {self._reconnect_delay} seconds (attempt {attempt})"
)
# Wait with ability to exit early if stopping
delay_count = 0
while delay_count < self._reconnect_delay:
if self._stop_reconnect:
logger.error("MQTT reconnection stopped during delay") # type: ignore[unreachable]
return
time.sleep(1)
delay_count += 1
# Double-check stop flag after delay
if self._stop_reconnect:
logger.error("MQTT reconnection stopped after delay") # type: ignore[unreachable]
return
try:
logger.error(
f"Creating fresh MQTT client for reconnection attempt {attempt}..."
)
# Clean up old client if it exists
if self.client is not None:
try:
self.client.disconnect()
self.client.loop_stop()
except Exception:
pass # Ignore cleanup errors
# Create completely fresh client and attempt connection
self._start()
# Give the connection attempt some time to complete
wait_count = 0
while wait_count < 5: # Wait up to 5 seconds for connection
if self.connected:
logger.error( # type: ignore[unreachable]
f"MQTT fresh connection successful on attempt {attempt}!"
)
return
time.sleep(1)
wait_count += 1
logger.error(
f"MQTT fresh connection attempt {attempt} timed out, will retry"
)
# Continue the outer while loop to retry
except Exception as e:
logger.error(f"MQTT fresh connection attempt {attempt} failed: {e}")
# Continue the outer while loop to retry
logger.error("MQTT reconnection loop finished")

View File

@ -255,6 +255,7 @@ class OpenVINOModelRunner(BaseModelRunner):
def __init__(self, model_path: str, device: str, model_type: str, **kwargs): def __init__(self, model_path: str, device: str, model_type: str, **kwargs):
self.model_path = model_path self.model_path = model_path
self.device = device self.device = device
self.model_type = model_type
if device == "NPU" and not OpenVINOModelRunner.is_model_npu_supported( if device == "NPU" and not OpenVINOModelRunner.is_model_npu_supported(
model_type model_type
@ -341,6 +342,13 @@ class OpenVINOModelRunner(BaseModelRunner):
# Lock prevents concurrent access to infer_request # Lock prevents concurrent access to infer_request
# Needed for JinaV2: genai thread (text) + embeddings thread (vision) # Needed for JinaV2: genai thread (text) + embeddings thread (vision)
with self._inference_lock: with self._inference_lock:
from frigate.embeddings.types import EnrichmentModelTypeEnum
if self.model_type in [EnrichmentModelTypeEnum.arcface.value]:
# For face recognition models, create a fresh infer_request
# for each inference to avoid state pollution that causes incorrect results.
self.infer_request = self.compiled_model.create_infer_request()
# Handle single input case for backward compatibility # Handle single input case for backward compatibility
if ( if (
len(inputs) == 1 len(inputs) == 1

View File

@ -72,7 +72,10 @@
"formattedTimestampFilename": { "formattedTimestampFilename": {
"12hour": "MM-dd-yy-h-mm-ss-a", "12hour": "MM-dd-yy-h-mm-ss-a",
"24hour": "MM-dd-yy-HH-mm-ss" "24hour": "MM-dd-yy-HH-mm-ss"
} },
"inProgress": "In progress",
"invalidStartTime": "Invalid start time",
"invalidEndTime": "Invalid end time"
}, },
"unit": { "unit": {
"speed": { "speed": {
@ -144,7 +147,8 @@
"unselect": "Unselect", "unselect": "Unselect",
"export": "Export", "export": "Export",
"deleteNow": "Delete Now", "deleteNow": "Delete Now",
"next": "Next" "next": "Next",
"continue": "Continue"
}, },
"menu": { "menu": {
"system": "System", "system": "System",
@ -237,6 +241,7 @@
"export": "Export", "export": "Export",
"uiPlayground": "UI Playground", "uiPlayground": "UI Playground",
"faceLibrary": "Face Library", "faceLibrary": "Face Library",
"classification": "Classification",
"user": { "user": {
"title": "User", "title": "User",
"account": "Account", "account": "Account",

View File

@ -24,8 +24,8 @@
"label": "Detail", "label": "Detail",
"noDataFound": "No detail data to review", "noDataFound": "No detail data to review",
"aria": "Toggle detail view", "aria": "Toggle detail view",
"trackedObject_one": "object", "trackedObject_one": "{{count}} object",
"trackedObject_other": "objects", "trackedObject_other": "{{count}} objects",
"noObjectDetailData": "No object detail data available.", "noObjectDetailData": "No object detail data available.",
"settings": "Detail View Settings", "settings": "Detail View Settings",
"alwaysExpandActive": { "alwaysExpandActive": {

View File

@ -35,7 +35,7 @@
"snapshot": "snapshot", "snapshot": "snapshot",
"thumbnail": "thumbnail", "thumbnail": "thumbnail",
"video": "video", "video": "video",
"object_lifecycle": "object lifecycle" "tracking_details": "tracking details"
}, },
"trackingDetails": { "trackingDetails": {
"title": "Tracking Details", "title": "Tracking Details",

View File

@ -8,7 +8,7 @@
"masksAndZones": "Mask and Zone Editor - Frigate", "masksAndZones": "Mask and Zone Editor - Frigate",
"motionTuner": "Motion Tuner - Frigate", "motionTuner": "Motion Tuner - Frigate",
"object": "Debug - Frigate", "object": "Debug - Frigate",
"general": "General Settings - Frigate", "general": "UI Settings - Frigate",
"frigatePlus": "Frigate+ Settings - Frigate", "frigatePlus": "Frigate+ Settings - Frigate",
"notifications": "Notification Settings - Frigate" "notifications": "Notification Settings - Frigate"
}, },
@ -37,7 +37,7 @@
"noCamera": "No Camera" "noCamera": "No Camera"
}, },
"general": { "general": {
"title": "General Settings", "title": "UI Settings",
"liveDashboard": { "liveDashboard": {
"title": "Live Dashboard", "title": "Live Dashboard",
"automaticLiveView": { "automaticLiveView": {
@ -51,6 +51,10 @@
"displayCameraNames": { "displayCameraNames": {
"label": "Always Show Camera Names", "label": "Always Show Camera Names",
"desc": "Always show the camera names in a chip in the multi-camera live view dashboard." "desc": "Always show the camera names in a chip in the multi-camera live view dashboard."
},
"liveFallbackTimeout": {
"label": "Live Player Fallback Timeout",
"desc": "When a camera's high quality live stream is unavailable, fall back to low bandwidth mode after this many seconds. Default: 3."
} }
}, },
"storedLayouts": { "storedLayouts": {
@ -196,6 +200,8 @@
"manualMode": "Manual selection", "manualMode": "Manual selection",
"detectionMethodDescription": "Probe the camera with ONVIF (if supported) to find camera stream URLs, or manually select the camera brand to use pre-defined URLs. To enter a custom RTSP URL, choose the manual method and select \"Other\".", "detectionMethodDescription": "Probe the camera with ONVIF (if supported) to find camera stream URLs, or manually select the camera brand to use pre-defined URLs. To enter a custom RTSP URL, choose the manual method and select \"Other\".",
"onvifPortDescription": "For cameras that support ONVIF, this is usually 80 or 8080.", "onvifPortDescription": "For cameras that support ONVIF, this is usually 80 or 8080.",
"useDigestAuth": "Use digest authentication",
"useDigestAuthDescription": "Use HTTP digest authentication for ONVIF. Some cameras may require a dedicated ONVIF username/password instead of the standard admin user.",
"errors": { "errors": {
"brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL", "brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL",
"nameRequired": "Camera name is required", "nameRequired": "Camera name is required",

View File

@ -454,6 +454,24 @@ export function GeneralFilterContent({
onClose, onClose,
}: GeneralFilterContentProps) { }: GeneralFilterContentProps) {
const { t } = useTranslation(["components/filter", "views/events"]); const { t } = useTranslation(["components/filter", "views/events"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const allAudioListenLabels = useMemo<string[]>(() => {
if (!config) {
return [];
}
const labels = new Set<string>();
Object.values(config.cameras).forEach((camera) => {
if (camera?.audio?.enabled) {
camera.audio.listen.forEach((label) => {
labels.add(label);
});
}
});
return [...labels].sort();
}, [config]);
return ( return (
<> <>
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden"> <div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
@ -504,7 +522,10 @@ export function GeneralFilterContent({
{allLabels.map((item) => ( {allLabels.map((item) => (
<FilterSwitch <FilterSwitch
key={item} key={item}
label={getTranslatedLabel(item)} label={getTranslatedLabel(
item,
allAudioListenLabels.includes(item) ? "audio" : "object",
)}
isChecked={filter.labels?.includes(item) ?? false} isChecked={filter.labels?.includes(item) ?? false}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {
if (isChecked) { if (isChecked) {

View File

@ -81,6 +81,43 @@ export default function InputWithTags({
revalidateOnFocus: false, revalidateOnFocus: false,
}); });
const allAudioListenLabels = useMemo<Set<string>>(() => {
if (!config) {
return new Set<string>();
}
const labels = new Set<string>();
Object.values(config.cameras).forEach((camera) => {
if (camera?.audio?.enabled) {
camera.audio.listen.forEach((label) => {
labels.add(label);
});
}
});
return labels;
}, [config]);
const translatedAudioLabelMap = useMemo<Map<string, string>>(() => {
const map = new Map<string, string>();
if (!config) return map;
allAudioListenLabels.forEach((label) => {
// getTranslatedLabel likely depends on i18n internally; including `lang`
// in deps ensures this map is rebuilt when language changes
map.set(label, getTranslatedLabel(label, "audio"));
});
return map;
}, [allAudioListenLabels, config]);
function resolveLabel(value: string) {
const mapped = translatedAudioLabelMap.get(value);
if (mapped) return mapped;
return getTranslatedLabel(
value,
allAudioListenLabels.has(value) ? "audio" : "object",
);
}
const [inputValue, setInputValue] = useState(search || ""); const [inputValue, setInputValue] = useState(search || "");
const [currentFilterType, setCurrentFilterType] = useState<FilterType | null>( const [currentFilterType, setCurrentFilterType] = useState<FilterType | null>(
null, null,
@ -421,7 +458,8 @@ export default function InputWithTags({
? t("button.yes", { ns: "common" }) ? t("button.yes", { ns: "common" })
: t("button.no", { ns: "common" }); : t("button.no", { ns: "common" });
} else if (filterType === "labels") { } else if (filterType === "labels") {
return getTranslatedLabel(String(filterValues)); const value = String(filterValues);
return resolveLabel(value);
} else if (filterType === "search_type") { } else if (filterType === "search_type") {
return t("filter.searchType." + String(filterValues)); return t("filter.searchType." + String(filterValues));
} else { } else {
@ -828,7 +866,7 @@ export default function InputWithTags({
> >
{t("filter.label." + filterType)}:{" "} {t("filter.label." + filterType)}:{" "}
{filterType === "labels" ? ( {filterType === "labels" ? (
getTranslatedLabel(value) resolveLabel(value)
) : filterType === "cameras" ? ( ) : filterType === "cameras" ? (
<CameraNameLabel camera={value} /> <CameraNameLabel camera={value} />
) : filterType === "zones" ? ( ) : filterType === "zones" ? (

View File

@ -1155,7 +1155,7 @@ function ObjectDetailsTab({
</div> </div>
<div className="flex flex-row items-center gap-2 text-sm smart-capitalize"> <div className="flex flex-row items-center gap-2 text-sm smart-capitalize">
{getIconForLabel(search.label, "size-4 text-primary")} {getIconForLabel(search.label, "size-4 text-primary")}
{getTranslatedLabel(search.label)} {getTranslatedLabel(search.label, search.data.type)}
{search.sub_label && ` (${search.sub_label})`} {search.sub_label && ` (${search.sub_label})`}
{isAdmin && search.end_time && ( {isAdmin && search.end_time && (
<Tooltip> <Tooltip>
@ -1394,7 +1394,9 @@ function ObjectDetailsTab({
{state == "submitted" && ( {state == "submitted" && (
<div className="flex flex-row items-center justify-center gap-2"> <div className="flex flex-row items-center justify-center gap-2">
<FaCheckCircle className="size-4 text-success" /> <FaCheckCircle className="size-4 text-success" />
{t("explore.plus.review.state.submitted")} {t("explore.plus.review.state.submitted", {
ns: "components/dialog",
})}
</div> </div>
)} )}
</div> </div>

View File

@ -343,6 +343,10 @@ export function TrackingDetails({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [displayedRecordTime]); }, [displayedRecordTime]);
const onUploadFrameToPlus = useCallback(() => {
return axios.post(`/${event.camera}/plus/${currentTime}`);
}, [event.camera, currentTime]);
if (!config) { if (!config) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
@ -388,6 +392,7 @@ export function TrackingDetails({
frigateControls={true} frigateControls={true}
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
onSeekToTime={handleSeekToTime} onSeekToTime={handleSeekToTime}
onUploadFrame={onUploadFrameToPlus}
isDetailMode={true} isDetailMode={true}
camera={event.camera} camera={event.camera}
currentTimeOverride={currentTime} currentTimeOverride={currentTime}

View File

@ -1,4 +1,5 @@
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { usePersistence } from "@/hooks/use-persistence";
import { import {
LivePlayerError, LivePlayerError,
PlayerStatsType, PlayerStatsType,
@ -71,6 +72,8 @@ function MSEPlayer({
const [errorCount, setErrorCount] = useState<number>(0); const [errorCount, setErrorCount] = useState<number>(0);
const totalBytesLoaded = useRef(0); const totalBytesLoaded = useRef(0);
const [fallbackTimeout] = usePersistence<number>("liveFallbackTimeout", 3);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const reconnectTIDRef = useRef<number | null>(null); const reconnectTIDRef = useRef<number | null>(null);
@ -475,7 +478,10 @@ function MSEPlayer({
setBufferTimeout(undefined); setBufferTimeout(undefined);
} }
const timeoutDuration = bufferTime == 0 ? 5000 : 3000; const timeoutDuration =
bufferTime == 0
? (fallbackTimeout ?? 3) * 2 * 1000
: (fallbackTimeout ?? 3) * 1000;
setBufferTimeout( setBufferTimeout(
setTimeout(() => { setTimeout(() => {
if ( if (
@ -500,6 +506,7 @@ function MSEPlayer({
onError, onError,
onPlaying, onPlaying,
playbackEnabled, playbackEnabled,
fallbackTimeout,
]); ]);
useEffect(() => { useEffect(() => {

View File

@ -16,6 +16,7 @@ import type {
} from "@/types/cameraWizard"; } from "@/types/cameraWizard";
import { FaCircleCheck } from "react-icons/fa6"; import { FaCircleCheck } from "react-icons/fa6";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { maskUri } from "@/utils/cameraUtil";
type OnvifProbeResultsProps = { type OnvifProbeResultsProps = {
isLoading: boolean; isLoading: boolean;
@ -258,12 +259,6 @@ function CandidateItem({
const { t } = useTranslation(["views/settings"]); const { t } = useTranslation(["views/settings"]);
const [showFull, setShowFull] = useState(false); const [showFull, setShowFull] = useState(false);
const maskUri = (uri: string) => {
const match = uri.match(/rtsp:\/\/([^:]+):([^@]+)@(.+)/);
if (match) return `rtsp://${match[1]}:••••@${match[3]}`;
return uri;
};
return ( return (
<Card <Card
className={cn( className={cn(

View File

@ -8,6 +8,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Select, Select,
@ -81,6 +82,7 @@ export default function Step1NameCamera({
password: z.string().optional(), password: z.string().optional(),
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(), brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
onvifPort: z.coerce.number().int().min(1).max(65535).optional(), onvifPort: z.coerce.number().int().min(1).max(65535).optional(),
useDigestAuth: z.boolean().optional(),
customUrl: z customUrl: z
.string() .string()
.optional() .optional()
@ -118,6 +120,7 @@ export default function Step1NameCamera({
: "dahua", : "dahua",
customUrl: wizardData.customUrl || "", customUrl: wizardData.customUrl || "",
onvifPort: wizardData.onvifPort ?? 80, onvifPort: wizardData.onvifPort ?? 80,
useDigestAuth: wizardData.useDigestAuth ?? false,
}, },
mode: "onChange", mode: "onChange",
}); });
@ -330,6 +333,32 @@ export default function Step1NameCamera({
/> />
)} )}
{probeMode && (
<FormField
control={form.control}
name="useDigestAuth"
render={({ field }) => (
<FormItem className="flex items-start space-x-2">
<FormControl>
<Checkbox
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={!!field.value}
onCheckedChange={(val) => field.onChange(!!val)}
/>
</FormControl>
<div className="flex flex-1 flex-col space-y-1">
<FormLabel className="mb-0 text-primary-variant">
{t("cameraWizard.step1.useDigestAuth")}
</FormLabel>
<FormDescription className="mt-0">
{t("cameraWizard.step1.useDigestAuthDescription")}
</FormDescription>
</div>
</FormItem>
)}
/>
)}
{!probeMode && ( {!probeMode && (
<div className="space-y-4"> <div className="space-y-4">
<FormField <FormField

View File

@ -191,6 +191,7 @@ export default function Step2ProbeOrSnapshot({
username: wizardData.username || "", username: wizardData.username || "",
password: wizardData.password || "", password: wizardData.password || "",
test: false, test: false,
auth_type: wizardData.useDigestAuth ? "digest" : "basic",
}, },
timeout: 30000, timeout: 30000,
}); });

View File

@ -18,6 +18,7 @@ import { PlayerStatsType } from "@/types/live";
import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6"; import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
import { LuX } from "react-icons/lu"; import { LuX } from "react-icons/lu";
import { Card, CardContent } from "../../ui/card"; import { Card, CardContent } from "../../ui/card";
import { maskUri } from "@/utils/cameraUtil";
type Step4ValidationProps = { type Step4ValidationProps = {
wizardData: Partial<WizardFormData>; wizardData: Partial<WizardFormData>;
@ -374,7 +375,7 @@ export default function Step4Validation({
<div className="mb-2 flex flex-col justify-between gap-1 md:flex-row md:items-center"> <div className="mb-2 flex flex-col justify-between gap-1 md:flex-row md:items-center">
<span className="break-all text-sm text-muted-foreground"> <span className="break-all text-sm text-muted-foreground">
{stream.url} {maskUri(stream.url)}
</span> </span>
<Button <Button
onClick={() => { onClick={() => {

View File

@ -349,7 +349,7 @@ function ReviewGroup({
? fetchedEvents.length ? fetchedEvents.length
: (review.data.objects ?? []).length; : (review.data.objects ?? []).length;
return `${objectCount} ${t("detail.trackedObject", { count: objectCount })}`; return `${t("detail.trackedObject", { count: objectCount })}`;
}, [review, t, fetchedEvents]); }, [review, t, fetchedEvents]);
const reviewDuration = useMemo( const reviewDuration = useMemo(
@ -478,7 +478,7 @@ function ReviewGroup({
<div className="rounded-full bg-muted-foreground p-1"> <div className="rounded-full bg-muted-foreground p-1">
{getIconForLabel(audioLabel, "size-3 text-white")} {getIconForLabel(audioLabel, "size-3 text-white")}
</div> </div>
<span>{getTranslatedLabel(audioLabel)}</span> <span>{getTranslatedLabel(audioLabel, "audio")}</span>
</div> </div>
</div> </div>
))} ))}
@ -513,7 +513,8 @@ function EventList({
const isSelected = selectedObjectIds.includes(event.id); const isSelected = selectedObjectIds.includes(event.id);
const label = event.sub_label || getTranslatedLabel(event.label); const label =
event.sub_label || getTranslatedLabel(event.label, event.data.type);
const handleObjectSelect = (event: Event | undefined) => { const handleObjectSelect = (event: Event | undefined) => {
if (event) { if (event) {

View File

@ -6,6 +6,7 @@ import { LivePlayerMode, LiveStreamMetadata } from "@/types/live";
export default function useCameraLiveMode( export default function useCameraLiveMode(
cameras: CameraConfig[], cameras: CameraConfig[],
windowVisible: boolean, windowVisible: boolean,
activeStreams?: { [cameraName: string]: string },
) { ) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -20,16 +21,20 @@ export default function useCameraLiveMode(
); );
if (isRestreamed) { if (isRestreamed) {
Object.values(camera.live.streams).forEach((streamName) => { if (activeStreams && activeStreams[camera.name]) {
streamNames.add(streamName); streamNames.add(activeStreams[camera.name]);
}); } else {
Object.values(camera.live.streams).forEach((streamName) => {
streamNames.add(streamName);
});
}
} }
}); });
return streamNames.size > 0 return streamNames.size > 0
? Array.from(streamNames).sort().join(",") ? Array.from(streamNames).sort().join(",")
: null; : null;
}, [cameras, config]); }, [cameras, config, activeStreams]);
const streamsFetcher = useCallback(async (key: string) => { const streamsFetcher = useCallback(async (key: string) => {
const streamNames = key.split(","); const streamNames = key.split(",");
@ -68,7 +73,9 @@ export default function useCameraLiveMode(
[key: string]: LiveStreamMetadata; [key: string]: LiveStreamMetadata;
}>(restreamedStreamsKey, streamsFetcher, { }>(restreamedStreamsKey, streamsFetcher, {
revalidateOnFocus: false, revalidateOnFocus: false,
dedupingInterval: 10000, revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 60000,
}); });
const [preferredLiveModes, setPreferredLiveModes] = useState<{ const [preferredLiveModes, setPreferredLiveModes] = useState<{

View File

@ -114,6 +114,7 @@ export type WizardFormData = {
streams?: StreamConfig[]; streams?: StreamConfig[];
probeMode?: boolean; // true for probe, false for manual probeMode?: boolean; // true for probe, false for manual
onvifPort?: number; onvifPort?: number;
useDigestAuth?: boolean;
probeResult?: OnvifProbeResponse; probeResult?: OnvifProbeResponse;
probeCandidates?: string[]; // candidate URLs from probe probeCandidates?: string[]; // candidate URLs from probe
candidateTests?: CandidateTestMap; // test results for candidates candidateTests?: CandidateTestMap; // test results for candidates

View File

@ -71,3 +71,26 @@ export async function detectReolinkCamera(
return null; return null;
} }
} }
/**
* Mask credentials in RTSP URIs for display
*/
export function maskUri(uri: string): string {
try {
// Handle RTSP URLs with user:pass@host format
const rtspMatch = uri.match(/rtsp:\/\/([^:]+):([^@]+)@(.+)/);
if (rtspMatch) {
return `rtsp://${rtspMatch[1]}:${"*".repeat(4)}@${rtspMatch[3]}`;
}
// Handle HTTP/HTTPS URLs with password query parameter
const urlObj = new URL(uri);
if (urlObj.searchParams.has("password")) {
urlObj.searchParams.set("password", "*".repeat(4));
return urlObj.toString();
}
} catch (e) {
// ignore
}
return uri;
}

View File

@ -244,12 +244,12 @@ export const getDurationFromTimestamps = (
abbreviated: boolean = false, abbreviated: boolean = false,
): string => { ): string => {
if (isNaN(start_time)) { if (isNaN(start_time)) {
return "Invalid start time"; return i18n.t("time.invalidStartTime", { ns: "common" });
} }
let duration = "In Progress"; let duration = i18n.t("time.inProgress", { ns: "common" });
if (end_time !== null) { if (end_time !== null) {
if (isNaN(end_time)) { if (isNaN(end_time)) {
return "Invalid end time"; return i18n.t("time.invalidEndTime", { ns: "common" });
} }
const start = fromUnixTime(start_time); const start = fromUnixTime(start_time);
const end = fromUnixTime(end_time); const end = fromUnixTime(end_time);

View File

@ -86,14 +86,6 @@ export default function DraggableGridLayout({
// preferred live modes per camera // preferred live modes per camera
const {
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
} = useCameraLiveMode(cameras, windowVisible);
const [globalAutoLive] = usePersistence("autoLiveView", true); const [globalAutoLive] = usePersistence("autoLiveView", true);
const [displayCameraNames] = usePersistence("displayCameraNames", false); const [displayCameraNames] = usePersistence("displayCameraNames", false);
@ -106,6 +98,33 @@ export default function DraggableGridLayout({
} }
}, [allGroupsStreamingSettings, cameraGroup]); }, [allGroupsStreamingSettings, cameraGroup]);
const activeStreams = useMemo(() => {
const streams: { [cameraName: string]: string } = {};
cameras.forEach((camera) => {
const availableStreams = camera.live.streams || {};
const streamNameFromSettings =
currentGroupStreamingSettings?.[camera.name]?.streamName || "";
const streamExists =
streamNameFromSettings &&
Object.values(availableStreams).includes(streamNameFromSettings);
const streamName = streamExists
? streamNameFromSettings
: Object.values(availableStreams)[0] || "";
streams[camera.name] = streamName;
});
return streams;
}, [cameras, currentGroupStreamingSettings]);
const {
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
// grid layout // grid layout
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []); const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);

View File

@ -162,6 +162,9 @@ export default function LiveCameraView({
isRestreamed ? `go2rtc/streams/${streamName}` : null, isRestreamed ? `go2rtc/streams/${streamName}` : null,
{ {
revalidateOnFocus: false, revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 60000,
}, },
); );
@ -1027,294 +1030,298 @@ function FrigateCameraFeatures({
disabled={!cameraEnabled || debug || isSnapshotLoading} disabled={!cameraEnabled || debug || isSnapshotLoading}
loading={isSnapshotLoading} loading={isSnapshotLoading}
/> />
<DropdownMenu modal={false}> {!fullscreen && (
<DropdownMenuTrigger> <DropdownMenu modal={false}>
<div <DropdownMenuTrigger>
className={cn( <div
"flex flex-col items-center justify-center rounded-lg bg-secondary p-2 text-secondary-foreground md:p-0", className={cn(
)} "flex flex-col items-center justify-center rounded-lg bg-secondary p-2 text-secondary-foreground md:p-0",
> )}
<FaCog >
className={`text-secondary-foreground" size-5 md:m-[6px]`} <FaCog
/> className={`text-secondary-foreground" size-5 md:m-[6px]`}
</div> />
</DropdownMenuTrigger> </div>
<DropdownMenuContent className="max-w-96"> </DropdownMenuTrigger>
<div className="flex flex-col gap-5 p-4"> <DropdownMenuContent className="max-w-96">
{!isRestreamed && ( <div className="flex flex-col gap-5 p-4">
<div className="flex flex-col gap-2"> {!isRestreamed && (
<Label> <div className="flex flex-col gap-2">
{t("streaming.label", { ns: "components/dialog" })} <Label>
</Label> {t("streaming.label", { ns: "components/dialog" })}
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground"> </Label>
<LuX className="size-4 text-danger" /> <div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<div> <LuX className="size-4 text-danger" />
{t("streaming.restreaming.disabled", { <div>
ns: "components/dialog", {t("streaming.restreaming.disabled", {
})}
</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("streaming.restreaming.desc.title", {
ns: "components/dialog", ns: "components/dialog",
})} })}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/live")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</div>
</div>
)}
{isRestreamed &&
Object.values(camera.live.streams).length > 0 && (
<div className="flex flex-col gap-1">
<Label htmlFor="streaming-method">
{t("stream.title")}
</Label>
<Select
value={streamName}
disabled={debug}
onValueChange={(value) => {
setStreamName?.(value);
}}
>
<SelectTrigger className="w-full">
<SelectValue>
{Object.keys(camera.live.streams).find(
(key) => camera.live.streams[key] === streamName,
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(camera.live.streams).map(
([stream, name]) => (
<SelectItem
key={stream}
className="cursor-pointer"
value={name}
>
{stream}
</SelectItem>
),
)}
</SelectGroup>
</SelectContent>
</Select>
{debug && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<>
<LuX className="size-8 text-danger" />
<div>{t("stream.debug.picker")}</div>
</>
</div> </div>
)} <Popover>
<PopoverTrigger asChild>
{preferredLiveMode != "jsmpeg" && <div className="cursor-pointer p-0">
!debug && <LuInfo className="size-4" />
isRestreamed && ( <span className="sr-only">
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground"> {t("button.info", { ns: "common" })}
{supportsAudioOutput ? ( </span>
<>
<LuCheck className="size-4 text-success" />
<div>{t("stream.audio.available")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>{t("stream.audio.unavailable")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("stream.audio.tips.title")}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/live")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", {
ns: "common",
})}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
)}
{preferredLiveMode != "jsmpeg" &&
!debug &&
isRestreamed &&
supportsAudioOutput && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supports2WayTalk ? (
<>
<LuCheck className="size-4 text-success" />
<div>{t("stream.twoWayTalk.available")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>{t("stream.twoWayTalk.unavailable")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("stream.twoWayTalk.tips")}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl(
"configuration/live/#webrtc-extra-configuration",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", {
ns: "common",
})}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
)}
{preferredLiveMode == "jsmpeg" &&
!debug &&
isRestreamed && (
<div className="flex flex-col items-center gap-3">
<div className="flex flex-row items-center gap-2">
<IoIosWarning className="mr-1 size-8 text-danger" />
<p className="text-sm">
{t("stream.lowBandwidth.tips")}
</p>
</div> </div>
<Button </PopoverTrigger>
className={`flex items-center gap-2.5 rounded-lg`} <PopoverContent className="w-80 text-xs">
aria-label={t("stream.lowBandwidth.resetStream")} {t("streaming.restreaming.desc.title", {
variant="outline" ns: "components/dialog",
size="sm" })}
onClick={() => setLowBandwidth(false)} <div className="mt-2 flex items-center text-primary">
> <Link
<MdOutlineRestartAlt className="size-5 text-primary-variant" /> to={getLocaleDocUrl("configuration/live")}
<div className="text-primary-variant"> target="_blank"
{t("stream.lowBandwidth.resetStream")} rel="noopener noreferrer"
</div> className="inline"
</Button> >
</div> {t("readTheDocumentation", { ns: "common" })}
)} <LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</div>
</div>
)}
{isRestreamed &&
Object.values(camera.live.streams).length > 0 && (
<div className="flex flex-col gap-1">
<Label htmlFor="streaming-method">
{t("stream.title")}
</Label>
<Select
value={streamName}
disabled={debug}
onValueChange={(value) => {
setStreamName?.(value);
}}
>
<SelectTrigger className="w-full">
<SelectValue>
{Object.keys(camera.live.streams).find(
(key) => camera.live.streams[key] === streamName,
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(camera.live.streams).map(
([stream, name]) => (
<SelectItem
key={stream}
className="cursor-pointer"
value={name}
>
{stream}
</SelectItem>
),
)}
</SelectGroup>
</SelectContent>
</Select>
{debug && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<>
<LuX className="size-8 text-danger" />
<div>{t("stream.debug.picker")}</div>
</>
</div>
)}
{preferredLiveMode != "jsmpeg" &&
!debug &&
isRestreamed && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>{t("stream.audio.available")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>{t("stream.audio.unavailable")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("stream.audio.tips.title")}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl(
"configuration/live",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", {
ns: "common",
})}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
)}
{preferredLiveMode != "jsmpeg" &&
!debug &&
isRestreamed &&
supportsAudioOutput && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supports2WayTalk ? (
<>
<LuCheck className="size-4 text-success" />
<div>{t("stream.twoWayTalk.available")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>{t("stream.twoWayTalk.unavailable")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("stream.twoWayTalk.tips")}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl(
"configuration/live/#webrtc-extra-configuration",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", {
ns: "common",
})}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
)}
{preferredLiveMode == "jsmpeg" &&
!debug &&
isRestreamed && (
<div className="flex flex-col items-center gap-3">
<div className="flex flex-row items-center gap-2">
<IoIosWarning className="mr-1 size-8 text-danger" />
<p className="text-sm">
{t("stream.lowBandwidth.tips")}
</p>
</div>
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label={t("stream.lowBandwidth.resetStream")}
variant="outline"
size="sm"
onClick={() => setLowBandwidth(false)}
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">
{t("stream.lowBandwidth.resetStream")}
</div>
</Button>
</div>
)}
</div>
)}
{isRestreamed && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Label
className="mx-0 cursor-pointer text-primary"
htmlFor="backgroundplay"
>
{t("stream.playInBackground.label")}
</Label>
<Switch
className="ml-1"
id="backgroundplay"
disabled={debug}
checked={playInBackground}
onCheckedChange={(checked) =>
setPlayInBackground(checked)
}
/>
</div>
<p className="text-sm text-muted-foreground">
{t("stream.playInBackground.tips")}
</p>
</div> </div>
)} )}
{isRestreamed && (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label <Label
className="mx-0 cursor-pointer text-primary" className="mx-0 cursor-pointer text-primary"
htmlFor="backgroundplay" htmlFor="showstats"
> >
{t("stream.playInBackground.label")} {t("streaming.showStats.label", {
ns: "components/dialog",
})}
</Label> </Label>
<Switch <Switch
className="ml-1" className="ml-1"
id="backgroundplay" id="showstats"
disabled={debug} disabled={debug}
checked={playInBackground} checked={showStats}
onCheckedChange={(checked) => onCheckedChange={(checked) => setShowStats(checked)}
setPlayInBackground(checked)
}
/> />
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("stream.playInBackground.tips")} {t("streaming.showStats.desc", {
ns: "components/dialog",
})}
</p> </p>
</div> </div>
)} <div className="flex flex-col gap-1">
<div className="flex flex-col gap-1"> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <Label
<Label className="mx-0 cursor-pointer text-primary"
className="mx-0 cursor-pointer text-primary" htmlFor="debug"
htmlFor="showstats" >
> {t("streaming.debugView", {
{t("streaming.showStats.label", { ns: "components/dialog",
ns: "components/dialog", })}
})} </Label>
</Label> <Switch
<Switch className="ml-1"
className="ml-1" id="debug"
id="showstats" checked={debug}
disabled={debug} onCheckedChange={(checked) => setDebug(checked)}
checked={showStats} />
onCheckedChange={(checked) => setShowStats(checked)} </div>
/>
</div>
<p className="text-sm text-muted-foreground">
{t("streaming.showStats.desc", {
ns: "components/dialog",
})}
</p>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Label
className="mx-0 cursor-pointer text-primary"
htmlFor="debug"
>
{t("streaming.debugView", {
ns: "components/dialog",
})}
</Label>
<Switch
className="ml-1"
id="debug"
checked={debug}
onCheckedChange={(checked) => setDebug(checked)}
/>
</div> </div>
</div> </div>
</div> </DropdownMenuContent>
</DropdownMenuContent> </DropdownMenu>
</DropdownMenu> )}
</> </>
); );
} }

View File

@ -202,14 +202,6 @@ export default function LiveDashboardView({
}; };
}, []); }, []);
const {
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
} = useCameraLiveMode(cameras, windowVisible);
const [globalAutoLive] = usePersistence("autoLiveView", true); const [globalAutoLive] = usePersistence("autoLiveView", true);
const [displayCameraNames] = usePersistence("displayCameraNames", false); const [displayCameraNames] = usePersistence("displayCameraNames", false);
@ -239,6 +231,33 @@ export default function LiveDashboardView({
[visibleCameraObserver.current], [visibleCameraObserver.current],
); );
const activeStreams = useMemo(() => {
const streams: { [cameraName: string]: string } = {};
cameras.forEach((camera) => {
const availableStreams = camera.live.streams || {};
const streamNameFromSettings =
currentGroupStreamingSettings?.[camera.name]?.streamName || "";
const streamExists =
streamNameFromSettings &&
Object.values(availableStreams).includes(streamNameFromSettings);
const streamName = streamExists
? streamNameFromSettings
: Object.values(availableStreams)[0] || "";
streams[camera.name] = streamName;
});
return streams;
}, [cameras, currentGroupStreamingSettings]);
const {
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
const birdseyeConfig = useMemo(() => config?.birdseye, [config]); const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
const handleError = useCallback( const handleError = useCallback(

View File

@ -649,7 +649,7 @@ export function RecordingView({
value="detail" value="detail"
aria-label="Detail Stream" aria-label="Detail Stream"
> >
<div className="">Detail</div> <div className="">{t("detail.label")}</div>
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
) : ( ) : (

View File

@ -99,6 +99,10 @@ export default function UiSettingsView() {
const [playbackRate, setPlaybackRate] = usePersistence("playbackRate", 1); const [playbackRate, setPlaybackRate] = usePersistence("playbackRate", 1);
const [weekStartsOn, setWeekStartsOn] = usePersistence("weekStartsOn", 0); const [weekStartsOn, setWeekStartsOn] = usePersistence("weekStartsOn", 0);
const [alertVideos, setAlertVideos] = usePersistence("alertVideos", true); const [alertVideos, setAlertVideos] = usePersistence("alertVideos", true);
const [fallbackTimeout, setFallbackTimeout] = usePersistence(
"liveFallbackTimeout",
3,
);
return ( return (
<> <>
@ -161,6 +165,48 @@ export default function UiSettingsView() {
<p>{t("general.liveDashboard.displayCameraNames.desc")}</p> <p>{t("general.liveDashboard.displayCameraNames.desc")}</p>
</div> </div>
</div> </div>
<div className="space-y-3">
<div className="flex flex-row items-center justify-start gap-2">
<Label
className="cursor-pointer"
htmlFor="live-fallback-timeout"
>
{t("general.liveDashboard.liveFallbackTimeout.label")}
</Label>
</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>{t("general.liveDashboard.liveFallbackTimeout.desc")}</p>
</div>
<Select
value={fallbackTimeout?.toString()}
onValueChange={(value) => setFallbackTimeout(parseInt(value))}
>
<SelectTrigger className="w-36">
{t("time.second", {
ns: "common",
time: fallbackTimeout,
count: fallbackTimeout,
})}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((timeout) => (
<SelectItem
key={timeout}
className="cursor-pointer"
value={timeout.toString()}
>
{t("time.second", {
ns: "common",
time: timeout,
count: timeout,
})}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div> </div>
<div className="my-3 flex w-full flex-col space-y-6"> <div className="my-3 flex w-full flex-col space-y-6">