mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-20 12:06:43 +03:00
Compare commits
5 Commits
0ac7155942
...
8a1195e1bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a1195e1bf | ||
|
|
de066d0062 | ||
|
|
f1a05d0f9b | ||
|
|
a623150811 | ||
|
|
f6746c0d9a |
@ -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 (
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
19
web/package-lock.json
generated
19
web/package-lock.json
generated
@ -88,7 +88,7 @@
|
|||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@testing-library/jest-dom": "^6.6.2",
|
"@testing-library/jest-dom": "^6.6.2",
|
||||||
"@types/lodash": "^4.17.12",
|
"@types/lodash": "^4.17.12",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^24.9.2",
|
||||||
"@types/react": "^18.3.2",
|
"@types/react": "^18.3.2",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-grid-layout": "^1.3.5",
|
"@types/react-grid-layout": "^1.3.5",
|
||||||
@ -3867,13 +3867,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.14.10",
|
"version": "24.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz",
|
||||||
"integrity": "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==",
|
"integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~5.26.4"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
@ -9674,10 +9674,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "5.26.5",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
"dev": true
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/universalify": {
|
"node_modules/universalify": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
|
|||||||
@ -94,7 +94,7 @@
|
|||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@testing-library/jest-dom": "^6.6.2",
|
"@testing-library/jest-dom": "^6.6.2",
|
||||||
"@types/lodash": "^4.17.12",
|
"@types/lodash": "^4.17.12",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^24.9.2",
|
||||||
"@types/react": "^18.3.2",
|
"@types/react": "^18.3.2",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-grid-layout": "^1.3.5",
|
"@types/react-grid-layout": "^1.3.5",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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" ? (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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={() => {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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<{
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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), []);
|
||||||
|
|||||||
@ -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>
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user