mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 05:24:11 +03:00
Add Camera Wizard improvements (#20876)
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
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
* backend api endpoint * don't add no-credentials version of streams to rtsp_candidates * frontend types * improve types * add optional probe dialog to wizard step 1 * i18n * form description and field change * add onvif form description * match onvif probe pane with other steps in the wizard * refactor to add probe and snapshot as step 2 * consolidate probe dialog * don't change dialog size * radio button style * refactor to select onvif urls via combobox in step 3 * i18n * add scrollbar container * i18n cleanup * fix button activity indicator * match test parsing in step 3 with step 2 * hide resolution if both width and height are zero * use drawer for stream selection on mobile in step 3 * suppress double toasts * api endpoint description
This commit is contained in:
parent
c371fc0c87
commit
e4eac4ac81
@ -3,11 +3,15 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from onvif import ONVIFCamera, ONVIFError
|
||||
from zeep.exceptions import Fault, TransportError
|
||||
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
@ -452,3 +456,471 @@ def _extract_fps(r_frame_rate: str) -> float | None:
|
||||
return round(float(num) / float(den), 2)
|
||||
except (ValueError, ZeroDivisionError):
|
||||
return None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/onvif/probe",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Probe ONVIF device",
|
||||
description=(
|
||||
"Probe an ONVIF device to determine capabilities and optionally test available stream URIs. "
|
||||
"Query params: host (required), port (default 80), username, password, test (boolean)."
|
||||
),
|
||||
)
|
||||
async def onvif_probe(
|
||||
request: Request,
|
||||
host: str = Query(None),
|
||||
port: int = Query(80),
|
||||
username: str = Query(""),
|
||||
password: str = Query(""),
|
||||
test: bool = Query(False),
|
||||
):
|
||||
"""
|
||||
Probe a single ONVIF device to determine capabilities.
|
||||
|
||||
Connects to an ONVIF device and queries for:
|
||||
- Device information (manufacturer, model)
|
||||
- Media profiles count
|
||||
- PTZ support
|
||||
- Available presets
|
||||
- Autotracking support
|
||||
|
||||
Query Parameters:
|
||||
host: Device host/IP address (required)
|
||||
port: Device port (default 80)
|
||||
username: ONVIF username (optional)
|
||||
password: ONVIF password (optional)
|
||||
test: run ffprobe on the stream (optional)
|
||||
|
||||
Returns:
|
||||
JSON with device capabilities information
|
||||
"""
|
||||
if not host:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "host parameter is required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate host format
|
||||
if not _is_valid_host(host):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Invalid host format"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
onvif_camera = None
|
||||
|
||||
try:
|
||||
logger.debug(f"Probing ONVIF device at {host}:{port}")
|
||||
|
||||
try:
|
||||
wsdl_base = None
|
||||
spec = find_spec("onvif")
|
||||
if spec and getattr(spec, "origin", None):
|
||||
wsdl_base = str(Path(spec.origin).parent / "wsdl")
|
||||
except Exception:
|
||||
wsdl_base = None
|
||||
|
||||
onvif_camera = ONVIFCamera(
|
||||
host, port, username or "", password or "", wsdl_dir=wsdl_base
|
||||
)
|
||||
|
||||
await onvif_camera.update_xaddrs()
|
||||
|
||||
# Get device information
|
||||
device_info = {
|
||||
"manufacturer": "Unknown",
|
||||
"model": "Unknown",
|
||||
"firmware_version": "Unknown",
|
||||
}
|
||||
try:
|
||||
device_service = await onvif_camera.create_devicemgmt_service()
|
||||
device_info_resp = await device_service.GetDeviceInformation()
|
||||
manufacturer = getattr(device_info_resp, "Manufacturer", None) or (
|
||||
device_info_resp.get("Manufacturer")
|
||||
if isinstance(device_info_resp, dict)
|
||||
else None
|
||||
)
|
||||
model = getattr(device_info_resp, "Model", None) or (
|
||||
device_info_resp.get("Model")
|
||||
if isinstance(device_info_resp, dict)
|
||||
else None
|
||||
)
|
||||
firmware = getattr(device_info_resp, "FirmwareVersion", None) or (
|
||||
device_info_resp.get("FirmwareVersion")
|
||||
if isinstance(device_info_resp, dict)
|
||||
else None
|
||||
)
|
||||
device_info.update(
|
||||
{
|
||||
"manufacturer": manufacturer or "Unknown",
|
||||
"model": model or "Unknown",
|
||||
"firmware_version": firmware or "Unknown",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to get device info")
|
||||
|
||||
# Get media profiles
|
||||
profiles = []
|
||||
profiles_count = 0
|
||||
first_profile_token = None
|
||||
ptz_config_token = None
|
||||
try:
|
||||
media_service = await onvif_camera.create_media_service()
|
||||
profiles = await media_service.GetProfiles()
|
||||
profiles_count = len(profiles) if profiles else 0
|
||||
if profiles and len(profiles) > 0:
|
||||
p = profiles[0]
|
||||
first_profile_token = getattr(p, "token", None) or (
|
||||
p.get("token") if isinstance(p, dict) else None
|
||||
)
|
||||
# Get PTZ configuration token from the profile
|
||||
ptz_configuration = getattr(p, "PTZConfiguration", None) or (
|
||||
p.get("PTZConfiguration") if isinstance(p, dict) else None
|
||||
)
|
||||
if ptz_configuration:
|
||||
ptz_config_token = getattr(ptz_configuration, "token", None) or (
|
||||
ptz_configuration.get("token")
|
||||
if isinstance(ptz_configuration, dict)
|
||||
else None
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to get media profiles")
|
||||
|
||||
# Check PTZ support and capabilities
|
||||
ptz_supported = False
|
||||
presets_count = 0
|
||||
autotrack_supported = False
|
||||
|
||||
try:
|
||||
ptz_service = await onvif_camera.create_ptz_service()
|
||||
|
||||
# Check if PTZ service is available
|
||||
try:
|
||||
await ptz_service.GetServiceCapabilities()
|
||||
ptz_supported = True
|
||||
logger.debug("PTZ service is available")
|
||||
except Exception as e:
|
||||
logger.debug(f"PTZ service not available: {e}")
|
||||
ptz_supported = False
|
||||
|
||||
# Try to get presets if PTZ is supported and we have a profile
|
||||
if ptz_supported and first_profile_token:
|
||||
try:
|
||||
presets_resp = await ptz_service.GetPresets(
|
||||
{"ProfileToken": first_profile_token}
|
||||
)
|
||||
presets_count = len(presets_resp) if presets_resp else 0
|
||||
logger.debug(f"Found {presets_count} presets")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get presets: {e}")
|
||||
presets_count = 0
|
||||
|
||||
# Check for autotracking support - requires both FOV relative movement and MoveStatus
|
||||
if ptz_supported and first_profile_token and ptz_config_token:
|
||||
# First check for FOV relative movement support
|
||||
pt_r_fov_supported = False
|
||||
try:
|
||||
config_request = ptz_service.create_type("GetConfigurationOptions")
|
||||
config_request.ConfigurationToken = ptz_config_token
|
||||
ptz_config = await ptz_service.GetConfigurationOptions(
|
||||
config_request
|
||||
)
|
||||
|
||||
if ptz_config:
|
||||
# Check for pt-r-fov support
|
||||
spaces = getattr(ptz_config, "Spaces", None) or (
|
||||
ptz_config.get("Spaces")
|
||||
if isinstance(ptz_config, dict)
|
||||
else None
|
||||
)
|
||||
|
||||
if spaces:
|
||||
rel_pan_tilt_space = getattr(
|
||||
spaces, "RelativePanTiltTranslationSpace", None
|
||||
) or (
|
||||
spaces.get("RelativePanTiltTranslationSpace")
|
||||
if isinstance(spaces, dict)
|
||||
else None
|
||||
)
|
||||
|
||||
if rel_pan_tilt_space:
|
||||
# Look for FOV space
|
||||
for i, space in enumerate(rel_pan_tilt_space):
|
||||
uri = None
|
||||
if isinstance(space, dict):
|
||||
uri = space.get("URI")
|
||||
else:
|
||||
uri = getattr(space, "URI", None)
|
||||
|
||||
if uri and "TranslationSpaceFov" in uri:
|
||||
pt_r_fov_supported = True
|
||||
logger.debug(
|
||||
"FOV relative movement (pt-r-fov) supported"
|
||||
)
|
||||
break
|
||||
|
||||
logger.debug(f"PTZ config spaces: {ptz_config}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to check FOV relative movement: {e}")
|
||||
pt_r_fov_supported = False
|
||||
|
||||
# Now check for MoveStatus support via GetServiceCapabilities
|
||||
if pt_r_fov_supported:
|
||||
try:
|
||||
service_capabilities_request = ptz_service.create_type(
|
||||
"GetServiceCapabilities"
|
||||
)
|
||||
service_capabilities = await ptz_service.GetServiceCapabilities(
|
||||
service_capabilities_request
|
||||
)
|
||||
|
||||
# Look for MoveStatus in the capabilities
|
||||
move_status_capable = False
|
||||
if service_capabilities:
|
||||
# Try to find MoveStatus key recursively
|
||||
def find_move_status(obj, key="MoveStatus"):
|
||||
if isinstance(obj, dict):
|
||||
if key in obj:
|
||||
return obj[key]
|
||||
for v in obj.values():
|
||||
result = find_move_status(v, key)
|
||||
if result is not None:
|
||||
return result
|
||||
elif hasattr(obj, key):
|
||||
return getattr(obj, key)
|
||||
elif hasattr(obj, "__dict__"):
|
||||
for v in vars(obj).values():
|
||||
result = find_move_status(v, key)
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
move_status_value = find_move_status(service_capabilities)
|
||||
|
||||
# MoveStatus should return "true" if supported
|
||||
if isinstance(move_status_value, bool):
|
||||
move_status_capable = move_status_value
|
||||
elif isinstance(move_status_value, str):
|
||||
move_status_capable = (
|
||||
move_status_value.lower() == "true"
|
||||
)
|
||||
|
||||
logger.debug(f"MoveStatus capability: {move_status_value}")
|
||||
|
||||
# Autotracking is supported if both conditions are met
|
||||
autotrack_supported = pt_r_fov_supported and move_status_capable
|
||||
|
||||
if autotrack_supported:
|
||||
logger.debug(
|
||||
"Autotracking fully supported (pt-r-fov + MoveStatus)"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Autotracking not fully supported - pt-r-fov: {pt_r_fov_supported}, MoveStatus: {move_status_capable}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to check MoveStatus support: {e}")
|
||||
autotrack_supported = False
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to probe PTZ service: {e}")
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"host": host,
|
||||
"port": port,
|
||||
"manufacturer": device_info["manufacturer"],
|
||||
"model": device_info["model"],
|
||||
"firmware_version": device_info["firmware_version"],
|
||||
"profiles_count": profiles_count,
|
||||
"ptz_supported": ptz_supported,
|
||||
"presets_count": presets_count,
|
||||
"autotrack_supported": autotrack_supported,
|
||||
}
|
||||
|
||||
# Gather RTSP candidates
|
||||
rtsp_candidates: list[dict] = []
|
||||
try:
|
||||
media_service = await onvif_camera.create_media_service()
|
||||
if profiles_count and media_service:
|
||||
for p in profiles or []:
|
||||
token = getattr(p, "token", None) or (
|
||||
p.get("token") if isinstance(p, dict) else None
|
||||
)
|
||||
if not token:
|
||||
continue
|
||||
try:
|
||||
stream_setup = {
|
||||
"Stream": "RTP-Unicast",
|
||||
"Transport": {"Protocol": "RTSP"},
|
||||
}
|
||||
stream_req = {
|
||||
"ProfileToken": token,
|
||||
"StreamSetup": stream_setup,
|
||||
}
|
||||
stream_uri_resp = await media_service.GetStreamUri(stream_req)
|
||||
uri = (
|
||||
stream_uri_resp.get("Uri")
|
||||
if isinstance(stream_uri_resp, dict)
|
||||
else getattr(stream_uri_resp, "Uri", None)
|
||||
)
|
||||
if uri:
|
||||
logger.debug(
|
||||
f"GetStreamUri returned for token {token}: {uri}"
|
||||
)
|
||||
# If credentials were provided, do NOT add the unauthenticated URI.
|
||||
try:
|
||||
if isinstance(uri, str) and uri.startswith("rtsp://"):
|
||||
if username and password and "@" not in uri:
|
||||
# Inject URL-encoded credentials and add only the
|
||||
# authenticated version.
|
||||
cred = f"{quote_plus(username)}:{quote_plus(password)}@"
|
||||
injected = uri.replace(
|
||||
"rtsp://", f"rtsp://{cred}", 1
|
||||
)
|
||||
rtsp_candidates.append(
|
||||
{
|
||||
"source": "GetStreamUri",
|
||||
"profile_token": token,
|
||||
"uri": injected,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# No credentials provided or URI already contains
|
||||
# credentials — add the URI as returned.
|
||||
rtsp_candidates.append(
|
||||
{
|
||||
"source": "GetStreamUri",
|
||||
"profile_token": token,
|
||||
"uri": uri,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Non-RTSP URIs (e.g., http-flv) — add as returned.
|
||||
rtsp_candidates.append(
|
||||
{
|
||||
"source": "GetStreamUri",
|
||||
"profile_token": token,
|
||||
"uri": uri,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Skipping stream URI for token {token} due to processing error: {e}"
|
||||
)
|
||||
continue
|
||||
except Exception:
|
||||
logger.debug(
|
||||
f"GetStreamUri failed for token {token}", exc_info=True
|
||||
)
|
||||
continue
|
||||
|
||||
# Add common RTSP patterns as fallback
|
||||
if not rtsp_candidates:
|
||||
common_paths = [
|
||||
"/h264",
|
||||
"/live.sdp",
|
||||
"/media.amp",
|
||||
"/Streaming/Channels/101",
|
||||
"/Streaming/Channels/1",
|
||||
"/stream1",
|
||||
"/cam/realmonitor?channel=1&subtype=0",
|
||||
"/11",
|
||||
]
|
||||
# Use URL-encoded credentials for pattern fallback URIs when provided
|
||||
auth_str = (
|
||||
f"{quote_plus(username)}:{quote_plus(password)}@"
|
||||
if username and password
|
||||
else ""
|
||||
)
|
||||
rtsp_port = 554
|
||||
for path in common_paths:
|
||||
uri = f"rtsp://{auth_str}{host}:{rtsp_port}{path}"
|
||||
rtsp_candidates.append({"source": "pattern", "uri": uri})
|
||||
except Exception:
|
||||
logger.debug("Failed to collect RTSP candidates")
|
||||
|
||||
# Optionally test RTSP candidates using ffprobe_stream
|
||||
tested_candidates = []
|
||||
if test and rtsp_candidates:
|
||||
for c in rtsp_candidates:
|
||||
uri = c["uri"]
|
||||
to_test = [uri]
|
||||
try:
|
||||
if (
|
||||
username
|
||||
and password
|
||||
and isinstance(uri, str)
|
||||
and uri.startswith("rtsp://")
|
||||
and "@" not in uri
|
||||
):
|
||||
cred = f"{quote_plus(username)}:{quote_plus(password)}@"
|
||||
cred_uri = uri.replace("rtsp://", f"rtsp://{cred}", 1)
|
||||
if cred_uri not in to_test:
|
||||
to_test.append(cred_uri)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for test_uri in to_test:
|
||||
try:
|
||||
probe = ffprobe_stream(
|
||||
request.app.frigate_config.ffmpeg, test_uri, detailed=False
|
||||
)
|
||||
print(probe)
|
||||
ok = probe is not None and getattr(probe, "returncode", 1) == 0
|
||||
tested_candidates.append(
|
||||
{
|
||||
"uri": test_uri,
|
||||
"source": c.get("source"),
|
||||
"ok": ok,
|
||||
"profile_token": c.get("profile_token"),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Unable to probe stream: {e}")
|
||||
tested_candidates.append(
|
||||
{
|
||||
"uri": test_uri,
|
||||
"source": c.get("source"),
|
||||
"ok": False,
|
||||
"profile_token": c.get("profile_token"),
|
||||
}
|
||||
)
|
||||
|
||||
result["rtsp_candidates"] = rtsp_candidates
|
||||
if test:
|
||||
result["rtsp_tested"] = tested_candidates
|
||||
|
||||
logger.debug(f"ONVIF probe successful: {result}")
|
||||
return JSONResponse(content=result)
|
||||
|
||||
except ONVIFError as e:
|
||||
logger.warning(f"ONVIF error probing {host}:{port}: {e}")
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "ONVIF error"},
|
||||
status_code=400,
|
||||
)
|
||||
except (Fault, TransportError) as e:
|
||||
logger.warning(f"Connection error probing {host}:{port}: {e}")
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Connection error"},
|
||||
status_code=503,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error probing ONVIF device at {host}:{port}, {e}")
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Probe failed"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
finally:
|
||||
# Best-effort cleanup of ONVIF camera client session
|
||||
if onvif_camera is not None:
|
||||
try:
|
||||
# Check if the camera has a close method and call it
|
||||
if hasattr(onvif_camera, "close"):
|
||||
await onvif_camera.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing ONVIF camera session: {e}")
|
||||
|
||||
@ -154,6 +154,7 @@
|
||||
"description": "Follow the steps below to add a new camera to your Frigate installation.",
|
||||
"steps": {
|
||||
"nameAndConnection": "Name & Connection",
|
||||
"probeOrSnapshot": "Probe or Snapshot",
|
||||
"streamConfiguration": "Stream Configuration",
|
||||
"validationAndTesting": "Validation & Testing"
|
||||
},
|
||||
@ -172,7 +173,7 @@
|
||||
"testFailed": "Stream test failed: {{error}}"
|
||||
},
|
||||
"step1": {
|
||||
"description": "Enter your camera details and test the connection.",
|
||||
"description": "Enter your camera details and choose to probe the camera or manually select the brand.",
|
||||
"cameraName": "Camera Name",
|
||||
"cameraNamePlaceholder": "e.g., front_door or Back Yard Overview",
|
||||
"host": "Host/IP Address",
|
||||
@ -188,33 +189,63 @@
|
||||
"brandInformation": "Brand information",
|
||||
"brandUrlFormat": "For cameras with the RTSP URL format as: {{exampleUrl}}",
|
||||
"customUrlPlaceholder": "rtsp://username:password@host:port/path",
|
||||
"testConnection": "Test Connection",
|
||||
"testSuccess": "Connection test successful!",
|
||||
"testFailed": "Connection test failed. Please check your input and try again.",
|
||||
"streamDetails": "Stream Details",
|
||||
"testing": {
|
||||
"probingMetadata": "Probing camera metadata...",
|
||||
"fetchingSnapshot": "Fetching camera snapshot..."
|
||||
},
|
||||
"warnings": {
|
||||
"noSnapshot": "Unable to fetch a snapshot from the configured stream."
|
||||
},
|
||||
"connectionSettings": "Connection Settings",
|
||||
"detectionMethod": "Stream Detection Method",
|
||||
"onvifPort": "ONVIF Port",
|
||||
"probeMode": "Probe camera",
|
||||
"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\".",
|
||||
"onvifPortDescription": "For cameras that support ONVIF, this is usually 80 or 8080.",
|
||||
"errors": {
|
||||
"brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL",
|
||||
"nameRequired": "Camera name is required",
|
||||
"nameLength": "Camera name must be 64 characters or less",
|
||||
"invalidCharacters": "Camera name contains invalid characters",
|
||||
"nameExists": "Camera name already exists",
|
||||
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\". Manual configuration is required for non-RTSP camera streams.",
|
||||
"brands": {
|
||||
"reolink-rtsp": "Reolink RTSP is not recommended. Enable HTTP in the camera's firmware settings and restart the wizard."
|
||||
}
|
||||
},
|
||||
"docs": {
|
||||
"reolink": "https://docs.frigate.video/configuration/camera_specific.html#reolink-cameras"
|
||||
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\". Manual configuration is required for non-RTSP camera streams."
|
||||
}
|
||||
},
|
||||
"step2": {
|
||||
"description": "Probe the camera for available streams or configure manual settings based on your selected detection method.",
|
||||
"testSuccess": "Connection test successful!",
|
||||
"testFailed": "Connection test failed. Please check your input and try again.",
|
||||
"testFailedTitle": "Test Failed",
|
||||
"streamDetails": "Stream Details",
|
||||
"probing": "Probing camera...",
|
||||
"retry": "Retry",
|
||||
"testing": {
|
||||
"probingMetadata": "Probing camera metadata...",
|
||||
"fetchingSnapshot": "Fetching camera snapshot..."
|
||||
},
|
||||
"probeFailed": "Failed to probe camera: {{error}}",
|
||||
"probingDevice": "Probing device...",
|
||||
"probeSuccessful": "Probe successful",
|
||||
"probeError": "Probe Error",
|
||||
"probeNoSuccess": "Probe unsuccessful",
|
||||
"deviceInfo": "Device Information",
|
||||
"manufacturer": "Manufacturer",
|
||||
"model": "Model",
|
||||
"firmware": "Firmware",
|
||||
"profiles": "Profiles",
|
||||
"ptzSupport": "PTZ Support",
|
||||
"autotrackingSupport": "Autotracking Support",
|
||||
"presets": "Presets",
|
||||
"rtspCandidates": "RTSP Candidates",
|
||||
"rtspCandidatesDescription": "The following RTSP URLs were found from the camera probe. Test the connection to view stream metadata.",
|
||||
"noRtspCandidates": "No RTSP URLs were found from the camera. Your credentials may be incorrect, or the camera may not support ONVIF or the method used to retrieve RTSP URLs. Go back and enter the RTSP URL manually.",
|
||||
"candidateStreamTitle": "Candidate {{number}}",
|
||||
"useCandidate": "Use",
|
||||
"uriCopy": "Copy",
|
||||
"uriCopied": "URI copied to clipboard",
|
||||
"testConnection": "Test Connection",
|
||||
"toggleUriView": "Click to toggle full URI view",
|
||||
"connected": "Connected",
|
||||
"notConnected": "Not Connected",
|
||||
"errors": {
|
||||
"hostRequired": "Host/IP address is required"
|
||||
}
|
||||
},
|
||||
"step3": {
|
||||
"description": "Configure stream roles and add additional streams for your camera.",
|
||||
"streamsTitle": "Camera Streams",
|
||||
"addStream": "Add Stream",
|
||||
@ -222,6 +253,9 @@
|
||||
"streamTitle": "Stream {{number}}",
|
||||
"streamUrl": "Stream URL",
|
||||
"streamUrlPlaceholder": "rtsp://username:password@host:port/path",
|
||||
"selectStream": "Select a stream",
|
||||
"searchCandidates": "Search candidates...",
|
||||
"noStreamFound": "No stream found",
|
||||
"url": "URL",
|
||||
"resolution": "Resolution",
|
||||
"selectResolution": "Select resolution",
|
||||
@ -253,7 +287,7 @@
|
||||
"description": "Use go2rtc restreaming to reduce connections to your camera."
|
||||
}
|
||||
},
|
||||
"step3": {
|
||||
"step4": {
|
||||
"description": "Final validation and analysis before saving your new camera. Connect each stream before saving.",
|
||||
"validationTitle": "Stream Validation",
|
||||
"connectAllStreams": "Connect All Streams",
|
||||
@ -289,6 +323,9 @@
|
||||
"audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.",
|
||||
"audioCodecRequired": "An audio stream is required to support audio detection.",
|
||||
"restreamingWarning": "Reducing connections to the camera for the record stream may increase CPU usage slightly.",
|
||||
"brands": {
|
||||
"reolink-rtsp": "Reolink RTSP is not recommended. Enable HTTP in the camera's firmware settings and restart the wizard."
|
||||
},
|
||||
"dahua": {
|
||||
"substreamWarning": "Substream 1 is locked to a low resolution. Many Dahua / Amcrest / EmpireTech cameras support additional substreams that need to be enabled in the camera's settings. It is recommended to check and utilize those streams if available."
|
||||
},
|
||||
|
||||
@ -12,15 +12,15 @@ import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import axios from "axios";
|
||||
import Step1NameCamera from "@/components/settings/wizard/Step1NameCamera";
|
||||
import Step2StreamConfig from "@/components/settings/wizard/Step2StreamConfig";
|
||||
import Step3Validation from "@/components/settings/wizard/Step3Validation";
|
||||
import Step2ProbeOrSnapshot from "@/components/settings/wizard/Step2ProbeOrSnapshot";
|
||||
import Step3StreamConfig from "@/components/settings/wizard/Step3StreamConfig";
|
||||
import Step4Validation from "@/components/settings/wizard/Step4Validation";
|
||||
import type {
|
||||
WizardFormData,
|
||||
CameraConfigData,
|
||||
ConfigSetBody,
|
||||
} from "@/types/cameraWizard";
|
||||
import { processCameraName } from "@/utils/cameraUtil";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type WizardState = {
|
||||
@ -57,6 +57,7 @@ const wizardReducer = (
|
||||
|
||||
const STEPS = [
|
||||
"cameraWizard.steps.nameAndConnection",
|
||||
"cameraWizard.steps.probeOrSnapshot",
|
||||
"cameraWizard.steps.streamConfiguration",
|
||||
"cameraWizard.steps.validationAndTesting",
|
||||
];
|
||||
@ -100,20 +101,20 @@ export default function CameraWizardDialog({
|
||||
const canProceedToNext = useCallback((): boolean => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
// Can proceed if camera name is set and at least one stream exists
|
||||
return !!(
|
||||
state.wizardData.cameraName &&
|
||||
(state.wizardData.streams?.length ?? 0) > 0
|
||||
);
|
||||
// Step 1: Can proceed if camera name is set
|
||||
return !!state.wizardData.cameraName;
|
||||
case 1:
|
||||
// Can proceed if at least one stream has 'detect' role
|
||||
// Step 2: Can proceed if at least one stream exists (from probe or manual test)
|
||||
return (state.wizardData.streams?.length ?? 0) > 0;
|
||||
case 2:
|
||||
// Step 3: Can proceed if at least one stream has 'detect' role
|
||||
return !!(
|
||||
state.wizardData.streams?.some((stream) =>
|
||||
stream.roles.includes("detect"),
|
||||
) ?? false
|
||||
);
|
||||
case 2:
|
||||
// Always can proceed from final step (save will be handled there)
|
||||
case 3:
|
||||
// Step 4: Always can proceed from final step (save will be handled there)
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
@ -340,13 +341,7 @@ export default function CameraWizardDialog({
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"max-h-[90dvh] max-w-xl overflow-y-auto",
|
||||
isDesktop &&
|
||||
currentStep == 0 &&
|
||||
state.wizardData?.streams?.[0]?.testResult?.snapshot &&
|
||||
"max-w-4xl",
|
||||
isDesktop && currentStep == 1 && "max-w-2xl",
|
||||
isDesktop && currentStep > 1 && "max-w-4xl",
|
||||
"scrollbar-container max-h-[90dvh] max-w-3xl overflow-y-auto",
|
||||
)}
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault();
|
||||
@ -385,7 +380,16 @@ export default function CameraWizardDialog({
|
||||
/>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<Step2StreamConfig
|
||||
<Step2ProbeOrSnapshot
|
||||
wizardData={state.wizardData}
|
||||
onUpdate={onUpdate}
|
||||
onNext={handleNext}
|
||||
onBack={handleBack}
|
||||
probeMode={state.wizardData.probeMode ?? true}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<Step3StreamConfig
|
||||
wizardData={state.wizardData}
|
||||
onUpdate={onUpdate}
|
||||
onBack={handleBack}
|
||||
@ -393,8 +397,8 @@ export default function CameraWizardDialog({
|
||||
canProceed={canProceedToNext()}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<Step3Validation
|
||||
{currentStep === 3 && (
|
||||
<Step4Validation
|
||||
wizardData={state.wizardData}
|
||||
onUpdate={onUpdate}
|
||||
onSave={handleSave}
|
||||
|
||||
368
web/src/components/settings/wizard/OnvifProbeResults.tsx
Normal file
368
web/src/components/settings/wizard/OnvifProbeResults.tsx
Normal file
@ -0,0 +1,368 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { FaCopy, FaCheck } from "react-icons/fa";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { CiCircleAlert } from "react-icons/ci";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
OnvifProbeResponse,
|
||||
OnvifRtspCandidate,
|
||||
TestResult,
|
||||
CandidateTestMap,
|
||||
} from "@/types/cameraWizard";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type OnvifProbeResultsProps = {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error?: string;
|
||||
probeResult?: OnvifProbeResponse;
|
||||
onRetry: () => void;
|
||||
selectedUris?: string[];
|
||||
testCandidate?: (uri: string) => void;
|
||||
candidateTests?: CandidateTestMap;
|
||||
testingCandidates?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export default function OnvifProbeResults({
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
probeResult,
|
||||
onRetry,
|
||||
selectedUris,
|
||||
testCandidate,
|
||||
candidateTests,
|
||||
testingCandidates,
|
||||
}: OnvifProbeResultsProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [copiedUri, setCopiedUri] = useState<string | null>(null);
|
||||
|
||||
const handleCopyUri = (uri: string) => {
|
||||
navigator.clipboard.writeText(uri);
|
||||
setCopiedUri(uri);
|
||||
toast.success(t("cameraWizard.step2.uriCopied"));
|
||||
setTimeout(() => setCopiedUri(null), 2000);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||
<ActivityIndicator className="size-6" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("cameraWizard.step2.probingDevice")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<CiCircleAlert className="size-5" />
|
||||
<AlertTitle>{t("cameraWizard.step2.probeError")}</AlertTitle>
|
||||
{error && <AlertDescription>{error}</AlertDescription>}
|
||||
</Alert>
|
||||
<Button onClick={onRetry} variant="outline" className="w-full">
|
||||
{t("button.retry", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!probeResult?.success) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<CiCircleAlert className="size-5" />
|
||||
<AlertTitle>{t("cameraWizard.step2.probeNoSuccess")}</AlertTitle>
|
||||
{probeResult?.message && (
|
||||
<AlertDescription>{probeResult.message}</AlertDescription>
|
||||
)}
|
||||
</Alert>
|
||||
<Button onClick={onRetry} variant="outline" className="w-full">
|
||||
{t("button.retry", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rtspCandidates = (probeResult.rtsp_candidates || []).filter(
|
||||
(c) => c.source === "GetStreamUri",
|
||||
);
|
||||
|
||||
if (probeResult?.success && rtspCandidates.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<CiCircleAlert className="size-5" />
|
||||
<AlertTitle>{t("cameraWizard.step2.noRtspCandidates")}</AlertTitle>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{probeResult?.success && (
|
||||
<div className="mb-3 flex flex-row items-center gap-2 text-sm text-success">
|
||||
<FaCircleCheck className="size-4" />
|
||||
<span>{t("cameraWizard.step2.probeSuccessful")}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm">{t("cameraWizard.step2.deviceInfo")}</div>
|
||||
<Card>
|
||||
<CardContent className="space-y-2 p-4 text-sm">
|
||||
{probeResult.manufacturer && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.manufacturer")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{probeResult.manufacturer}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{probeResult.model && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.model")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{probeResult.model}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{probeResult.firmware_version && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.firmware")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{probeResult.firmware_version}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{probeResult.profiles_count !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.profiles")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{probeResult.profiles_count}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{probeResult.ptz_supported !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.ptzSupport")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{probeResult.ptz_supported
|
||||
? t("yes", { ns: "common" })
|
||||
: t("no", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{probeResult.ptz_supported && probeResult.autotrack_supported && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.autotrackingSupport")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{t("yes", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{probeResult.ptz_supported &&
|
||||
probeResult.presets_count !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.presets")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{probeResult.presets_count}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{rtspCandidates.length > 0 && (
|
||||
<div className="mt-5 space-y-2">
|
||||
<div className="text-sm">
|
||||
{t("cameraWizard.step2.rtspCandidates")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("cameraWizard.step2.rtspCandidatesDescription")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{rtspCandidates.map((candidate, idx) => {
|
||||
const isSelected = !!selectedUris?.includes(candidate.uri);
|
||||
const candidateTest = candidateTests?.[candidate.uri];
|
||||
const isTesting = testingCandidates?.[candidate.uri];
|
||||
|
||||
return (
|
||||
<CandidateItem
|
||||
key={idx}
|
||||
index={idx}
|
||||
candidate={candidate}
|
||||
copiedUri={copiedUri}
|
||||
onCopy={() => handleCopyUri(candidate.uri)}
|
||||
isSelected={isSelected}
|
||||
testCandidate={testCandidate}
|
||||
candidateTest={candidateTest}
|
||||
isTesting={isTesting}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type CandidateItemProps = {
|
||||
candidate: OnvifRtspCandidate;
|
||||
index?: number;
|
||||
copiedUri: string | null;
|
||||
onCopy: () => void;
|
||||
isSelected?: boolean;
|
||||
testCandidate?: (uri: string) => void;
|
||||
candidateTest?: TestResult | { success: false; error: string };
|
||||
isTesting?: boolean;
|
||||
};
|
||||
|
||||
function CandidateItem({
|
||||
index,
|
||||
candidate,
|
||||
copiedUri,
|
||||
onCopy,
|
||||
isSelected,
|
||||
testCandidate,
|
||||
candidateTest,
|
||||
isTesting,
|
||||
}: CandidateItemProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
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 (
|
||||
<Card
|
||||
className={cn(
|
||||
isSelected &&
|
||||
"outline outline-[3px] -outline-offset-[2.8px] outline-selected duration-200",
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
{t("cameraWizard.step2.candidateStreamTitle", {
|
||||
number: (index ?? 0) + 1,
|
||||
})}
|
||||
</h4>
|
||||
{candidateTest?.success && (
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{[
|
||||
candidateTest.resolution,
|
||||
candidateTest.fps
|
||||
? `${candidateTest.fps} ${t(
|
||||
"cameraWizard.testResultLabels.fps",
|
||||
)}`
|
||||
: null,
|
||||
candidateTest.videoCodec,
|
||||
candidateTest.audioCodec,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{candidateTest?.success && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<FaCircleCheck className="size-4 text-success" />
|
||||
<span className="text-success">
|
||||
{t("cameraWizard.step2.connected")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidateTest && !candidateTest.success && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<LuX className="size-4 text-danger" />
|
||||
<span className="text-danger">
|
||||
{t("cameraWizard.step2.notConnected")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-start gap-2">
|
||||
<p
|
||||
className="flex-1 cursor-pointer break-all text-sm text-primary-variant hover:underline"
|
||||
onClick={() => setShowFull((s) => !s)}
|
||||
title={t("cameraWizard.step2.toggleUriView")}
|
||||
>
|
||||
{showFull ? candidate.uri : maskUri(candidate.uri)}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onCopy}
|
||||
className="mr-4 size-8 p-0"
|
||||
title={t("cameraWizard.step2.uriCopy")}
|
||||
>
|
||||
{copiedUri === candidate.uri ? (
|
||||
<FaCheck className="size-3" />
|
||||
) : (
|
||||
<FaCopy className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isTesting}
|
||||
onClick={() => testCandidate?.(candidate.uri)}
|
||||
className="h-8 px-3 text-sm"
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<ActivityIndicator className="mr-2 size-4" />{" "}
|
||||
{t("cameraWizard.step2.testConnection")}
|
||||
</>
|
||||
) : (
|
||||
t("cameraWizard.step2.testConnection")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@ -15,15 +16,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import {
|
||||
@ -31,20 +30,13 @@ import {
|
||||
CameraBrand,
|
||||
CAMERA_BRANDS,
|
||||
CAMERA_BRAND_VALUES,
|
||||
TestResult,
|
||||
FfprobeStream,
|
||||
StreamRole,
|
||||
StreamConfig,
|
||||
} from "@/types/cameraWizard";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import { Card, CardContent, CardTitle } from "../../ui/card";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { LuInfo } from "react-icons/lu";
|
||||
import { detectReolinkCamera } from "@/utils/cameraUtil";
|
||||
|
||||
type Step1NameCameraProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
@ -63,9 +55,9 @@ export default function Step1NameCamera({
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testStatus, setTestStatus] = useState<string>("");
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
const [probeMode, setProbeMode] = useState<boolean>(
|
||||
wizardData.probeMode ?? true,
|
||||
);
|
||||
|
||||
const existingCameraNames = useMemo(() => {
|
||||
if (!config?.cameras) {
|
||||
@ -88,6 +80,7 @@ export default function Step1NameCamera({
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
|
||||
onvifPort: z.coerce.number().int().min(1).max(65535).optional(),
|
||||
customUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
@ -124,6 +117,7 @@ export default function Step1NameCamera({
|
||||
? (wizardData.brandTemplate as CameraBrand)
|
||||
: "dahua",
|
||||
customUrl: wizardData.customUrl || "",
|
||||
onvifPort: wizardData.onvifPort ?? 80,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
@ -132,243 +126,32 @@ export default function Step1NameCamera({
|
||||
const watchedHost = form.watch("host");
|
||||
const watchedCustomUrl = form.watch("customUrl");
|
||||
|
||||
const isTestButtonEnabled =
|
||||
watchedBrand === "other"
|
||||
? !!(watchedCustomUrl && watchedCustomUrl.trim())
|
||||
: !!(watchedHost && watchedHost.trim());
|
||||
const hostPresent = !!(watchedHost && watchedHost.trim());
|
||||
const customPresent = !!(watchedCustomUrl && watchedCustomUrl.trim());
|
||||
const cameraNamePresent = !!(form.getValues().cameraName || "").trim();
|
||||
|
||||
const generateDynamicStreamUrl = useCallback(
|
||||
async (data: z.infer<typeof step1FormData>): Promise<string | null> => {
|
||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||
if (!brand || !data.host) return null;
|
||||
|
||||
let protocol = undefined;
|
||||
if (data.brandTemplate === "reolink" && data.username && data.password) {
|
||||
try {
|
||||
protocol = await detectReolinkCamera(
|
||||
data.host,
|
||||
data.username,
|
||||
data.password,
|
||||
);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Use detected protocol or fallback to rtsp
|
||||
const protocolKey = protocol || "rtsp";
|
||||
const templates: Record<string, string> = brand.dynamicTemplates || {};
|
||||
|
||||
if (Object.keys(templates).includes(protocolKey)) {
|
||||
const template =
|
||||
templates[protocolKey as keyof typeof brand.dynamicTemplates];
|
||||
return template
|
||||
.replace("{username}", data.username || "")
|
||||
.replace("{password}", data.password || "")
|
||||
.replace("{host}", data.host);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const generateStreamUrl = useCallback(
|
||||
async (data: z.infer<typeof step1FormData>): Promise<string> => {
|
||||
if (data.brandTemplate === "other") {
|
||||
return data.customUrl || "";
|
||||
}
|
||||
|
||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||
if (!brand || !data.host) return "";
|
||||
|
||||
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
|
||||
const dynamicUrl = await generateDynamicStreamUrl(data);
|
||||
|
||||
if (dynamicUrl) {
|
||||
return dynamicUrl;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
return brand.template
|
||||
.replace("{username}", data.username || "")
|
||||
.replace("{password}", data.password || "")
|
||||
.replace("{host}", data.host);
|
||||
},
|
||||
[generateDynamicStreamUrl],
|
||||
);
|
||||
|
||||
const testConnection = useCallback(async () => {
|
||||
const data = form.getValues();
|
||||
const streamUrl = await generateStreamUrl(data);
|
||||
|
||||
if (!streamUrl) {
|
||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTesting(true);
|
||||
setTestStatus("");
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
// First get probe data for metadata
|
||||
setTestStatus(t("cameraWizard.step1.testing.probingMetadata"));
|
||||
const probeResponse = await axios.get("ffprobe", {
|
||||
params: { paths: streamUrl, detailed: true },
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
let probeData = null;
|
||||
if (
|
||||
probeResponse.data &&
|
||||
probeResponse.data.length > 0 &&
|
||||
probeResponse.data[0].return_code === 0
|
||||
) {
|
||||
probeData = probeResponse.data[0];
|
||||
}
|
||||
|
||||
// Then get snapshot for preview (only if probe succeeded)
|
||||
let snapshotBlob = null;
|
||||
if (probeData) {
|
||||
setTestStatus(t("cameraWizard.step1.testing.fetchingSnapshot"));
|
||||
try {
|
||||
const snapshotResponse = await axios.get("ffprobe/snapshot", {
|
||||
params: { url: streamUrl },
|
||||
responseType: "blob",
|
||||
timeout: 10000,
|
||||
});
|
||||
snapshotBlob = snapshotResponse.data;
|
||||
} catch (snapshotError) {
|
||||
// Snapshot is optional, don't fail if it doesn't work
|
||||
toast.warning(t("cameraWizard.step1.warnings.noSnapshot"));
|
||||
}
|
||||
}
|
||||
|
||||
if (probeData) {
|
||||
const ffprobeData = probeData.stdout;
|
||||
const streams = ffprobeData.streams || [];
|
||||
|
||||
const videoStream = streams.find(
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "video" ||
|
||||
s.codec_name?.includes("h264") ||
|
||||
s.codec_name?.includes("hevc"),
|
||||
);
|
||||
|
||||
const audioStream = streams.find(
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "audio" ||
|
||||
s.codec_name?.includes("aac") ||
|
||||
s.codec_name?.includes("mp3") ||
|
||||
s.codec_name?.includes("pcm_mulaw") ||
|
||||
s.codec_name?.includes("pcm_alaw"),
|
||||
);
|
||||
|
||||
const resolution = videoStream
|
||||
? `${videoStream.width}x${videoStream.height}`
|
||||
: undefined;
|
||||
|
||||
// Extract FPS from rational (e.g., "15/1" -> 15)
|
||||
const fps = videoStream?.avg_frame_rate
|
||||
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
|
||||
parseFloat(videoStream.avg_frame_rate.split("/")[1])
|
||||
: undefined;
|
||||
|
||||
// Convert snapshot blob to base64 if available
|
||||
let snapshotBase64 = undefined;
|
||||
if (snapshotBlob) {
|
||||
snapshotBase64 = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(snapshotBlob);
|
||||
});
|
||||
}
|
||||
|
||||
const testResult: TestResult = {
|
||||
success: true,
|
||||
snapshot: snapshotBase64,
|
||||
resolution,
|
||||
videoCodec: videoStream?.codec_name,
|
||||
audioCodec: audioStream?.codec_name,
|
||||
fps: fps && !isNaN(fps) ? fps : undefined,
|
||||
};
|
||||
|
||||
setTestResult(testResult);
|
||||
onUpdate({ streams: [{ id: "", url: "", roles: [], testResult }] });
|
||||
toast.success(t("cameraWizard.step1.testSuccess"));
|
||||
} else {
|
||||
const error =
|
||||
Array.isArray(probeResponse.data?.[0]?.stderr) &&
|
||||
probeResponse.data[0].stderr.length > 0
|
||||
? probeResponse.data[0].stderr.join("\n")
|
||||
: "Unable to probe stream";
|
||||
setTestResult({
|
||||
success: false,
|
||||
error: error,
|
||||
});
|
||||
toast.error(t("cameraWizard.commonErrors.testFailed", { error }), {
|
||||
duration: 6000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const errorMessage =
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.detail ||
|
||||
axiosError.message ||
|
||||
"Connection failed";
|
||||
setTestResult({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
});
|
||||
toast.error(
|
||||
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
|
||||
{
|
||||
duration: 10000,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
setTestStatus("");
|
||||
}
|
||||
}, [form, generateStreamUrl, t, onUpdate]);
|
||||
const isContinueButtonEnabled =
|
||||
cameraNamePresent &&
|
||||
(probeMode
|
||||
? hostPresent
|
||||
: watchedBrand === "other"
|
||||
? customPresent
|
||||
: hostPresent);
|
||||
|
||||
const onSubmit = (data: z.infer<typeof step1FormData>) => {
|
||||
onUpdate(data);
|
||||
onUpdate({ ...data, probeMode });
|
||||
};
|
||||
|
||||
const handleContinue = useCallback(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (isValid) {
|
||||
const data = form.getValues();
|
||||
const streamUrl = await generateStreamUrl(data);
|
||||
const streamId = `stream_${Date.now()}`;
|
||||
|
||||
const streamConfig: StreamConfig = {
|
||||
id: streamId,
|
||||
url: streamUrl,
|
||||
roles: ["detect" as StreamRole],
|
||||
resolution: testResult?.resolution,
|
||||
testResult: testResult || undefined,
|
||||
userTested: false,
|
||||
};
|
||||
|
||||
const updatedData = {
|
||||
...data,
|
||||
streams: [streamConfig],
|
||||
};
|
||||
|
||||
onNext(updatedData);
|
||||
}, [form, generateStreamUrl, testResult, onNext]);
|
||||
onNext({ ...data, probeMode });
|
||||
}
|
||||
}, [form, probeMode, onNext]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!testResult?.success && (
|
||||
<>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("cameraWizard.step1.description")}
|
||||
</div>
|
||||
@ -386,9 +169,7 @@ export default function Step1NameCamera({
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
placeholder={t(
|
||||
"cameraWizard.step1.cameraNamePlaceholder",
|
||||
)}
|
||||
placeholder={t("cameraWizard.step1.cameraNamePlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
@ -397,6 +178,160 @@ export default function Step1NameCamera({
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.host")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
placeholder="192.168.1.100"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.username")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
placeholder={t("cameraWizard.step1.usernamePlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="text-md h-8 pr-10"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t(
|
||||
"cameraWizard.step1.passwordPlaceholder",
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<LuEyeOff className="size-4" />
|
||||
) : (
|
||||
<LuEye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-4">
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.detectionMethod")}
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
value={probeMode ? "probe" : "manual"}
|
||||
onValueChange={(value) => {
|
||||
setProbeMode(value === "probe");
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="probe"
|
||||
id="probe-mode"
|
||||
className={
|
||||
probeMode
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
/>
|
||||
<label htmlFor="probe-mode" className="cursor-pointer text-sm">
|
||||
{t("cameraWizard.step1.probeMode")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="manual"
|
||||
id="manual-mode"
|
||||
className={
|
||||
!probeMode
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
/>
|
||||
<label htmlFor="manual-mode" className="cursor-pointer text-sm">
|
||||
{t("cameraWizard.step1.manualMode")}
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<FormDescription>
|
||||
{t("cameraWizard.step1.detectionMethodDescription")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
{probeMode && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="onvifPort"
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.onvifPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
type="text"
|
||||
{...field}
|
||||
placeholder="80"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("cameraWizard.step1.onvifPortDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage>
|
||||
{fieldState.error ? fieldState.error.message : null}
|
||||
</FormMessage>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!probeMode && (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandTemplate"
|
||||
@ -463,90 +398,6 @@ export default function Step1NameCamera({
|
||||
)}
|
||||
/>
|
||||
|
||||
{watchedBrand !== "other" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.host")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
placeholder="192.168.1.100"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.username")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
placeholder={t(
|
||||
"cameraWizard.step1.usernamePlaceholder",
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="text-md h-8 pr-10"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t(
|
||||
"cameraWizard.step1.passwordPlaceholder",
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<LuEyeOff className="size-4" />
|
||||
) : (
|
||||
<LuEye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBrand == "other" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
@ -568,124 +419,25 @@ export default function Step1NameCamera({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{testResult?.success && (
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex flex-row items-center gap-2 text-sm font-medium text-success">
|
||||
<FaCircleCheck className="size-4" />
|
||||
{t("cameraWizard.step1.testSuccess")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{testResult.snapshot ? (
|
||||
<div className="relative flex justify-center">
|
||||
<img
|
||||
src={testResult.snapshot}
|
||||
alt="Camera snapshot"
|
||||
className="max-h-[50dvh] max-w-full rounded-lg object-contain"
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 rounded-md bg-black/70 p-3 text-sm backdrop-blur-sm">
|
||||
<div className="space-y-1">
|
||||
<StreamDetails testResult={testResult} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-4">
|
||||
<CardTitle className="mb-2 text-sm">
|
||||
{t("cameraWizard.step1.streamDetails")}
|
||||
</CardTitle>
|
||||
<CardContent className="p-0 text-sm">
|
||||
<StreamDetails testResult={testResult} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTesting && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ActivityIndicator className="size-4" />
|
||||
{testStatus}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={testResult?.success ? () => setTestResult(null) : onCancel}
|
||||
className="sm:flex-1"
|
||||
>
|
||||
{testResult?.success
|
||||
? t("button.back", { ns: "common" })
|
||||
: t("button.cancel", { ns: "common" })}
|
||||
<Button type="button" onClick={onCancel} className="sm:flex-1">
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
{testResult?.success ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
disabled={!isContinueButtonEnabled}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
{t("button.continue", { ns: "common" })}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={testConnection}
|
||||
disabled={isTesting || !isTestButtonEnabled}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
{t("cameraWizard.step1.testConnection")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamDetails({ testResult }: { testResult: TestResult }) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{testResult.resolution && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.resolution")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.resolution}</span>
|
||||
</div>
|
||||
)}
|
||||
{testResult.fps && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.fps")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.fps}</span>
|
||||
</div>
|
||||
)}
|
||||
{testResult.videoCodec && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.video")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.videoCodec}</span>
|
||||
</div>
|
||||
)}
|
||||
{testResult.audioCodec && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.audio")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.audioCodec}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
724
web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx
Normal file
724
web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx
Normal file
@ -0,0 +1,724 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
WizardFormData,
|
||||
TestResult,
|
||||
StreamConfig,
|
||||
StreamRole,
|
||||
OnvifProbeResponse,
|
||||
CandidateTestMap,
|
||||
FfprobeStream,
|
||||
FfprobeData,
|
||||
FfprobeResponse,
|
||||
} from "@/types/cameraWizard";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import { Card, CardContent, CardTitle } from "../../ui/card";
|
||||
import OnvifProbeResults from "./OnvifProbeResults";
|
||||
import { CAMERA_BRANDS } from "@/types/cameraWizard";
|
||||
import { detectReolinkCamera } from "@/utils/cameraUtil";
|
||||
|
||||
type Step2ProbeOrSnapshotProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||
onNext: (data?: Partial<WizardFormData>) => void;
|
||||
onBack: () => void;
|
||||
probeMode: boolean;
|
||||
};
|
||||
|
||||
export default function Step2ProbeOrSnapshot({
|
||||
wizardData,
|
||||
onUpdate,
|
||||
onNext,
|
||||
onBack,
|
||||
probeMode,
|
||||
}: Step2ProbeOrSnapshotProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testStatus, setTestStatus] = useState<string>("");
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
const [isProbing, setIsProbing] = useState(false);
|
||||
const [probeError, setProbeError] = useState<string | null>(null);
|
||||
const [probeResult, setProbeResult] = useState<OnvifProbeResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [testingCandidates, setTestingCandidates] = useState<
|
||||
Record<string, boolean>
|
||||
>({} as Record<string, boolean>);
|
||||
const [candidateTests, setCandidateTests] = useState<CandidateTestMap>(
|
||||
{} as CandidateTestMap,
|
||||
);
|
||||
|
||||
const probeUri = useCallback(
|
||||
async (
|
||||
uri: string,
|
||||
fetchSnapshot = false,
|
||||
setStatus?: (s: string) => void,
|
||||
): Promise<TestResult> => {
|
||||
try {
|
||||
const probeResponse = await axios.get("ffprobe", {
|
||||
params: { paths: uri, detailed: true },
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
let probeData: FfprobeResponse | null = null;
|
||||
if (
|
||||
probeResponse.data &&
|
||||
probeResponse.data.length > 0 &&
|
||||
probeResponse.data[0].return_code === 0
|
||||
) {
|
||||
probeData = probeResponse.data[0];
|
||||
}
|
||||
|
||||
if (!probeData) {
|
||||
const error =
|
||||
Array.isArray(probeResponse.data?.[0]?.stderr) &&
|
||||
probeResponse.data[0].stderr.length > 0
|
||||
? probeResponse.data[0].stderr.join("\n")
|
||||
: "Unable to probe stream";
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
let ffprobeData: FfprobeData;
|
||||
if (typeof probeData.stdout === "string") {
|
||||
try {
|
||||
ffprobeData = JSON.parse(probeData.stdout as string) as FfprobeData;
|
||||
} catch {
|
||||
ffprobeData = { streams: [] };
|
||||
}
|
||||
} else {
|
||||
ffprobeData = probeData.stdout as FfprobeData;
|
||||
}
|
||||
|
||||
const streams = ffprobeData.streams || [];
|
||||
|
||||
const videoStream = streams.find(
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "video" ||
|
||||
s.codec_name?.includes("h264") ||
|
||||
s.codec_name?.includes("hevc"),
|
||||
);
|
||||
|
||||
const audioStream = streams.find(
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "audio" ||
|
||||
s.codec_name?.includes("aac") ||
|
||||
s.codec_name?.includes("mp3") ||
|
||||
s.codec_name?.includes("pcm_mulaw") ||
|
||||
s.codec_name?.includes("pcm_alaw"),
|
||||
);
|
||||
|
||||
let resolution: string | undefined = undefined;
|
||||
if (videoStream) {
|
||||
const width = Number(videoStream.width || 0);
|
||||
const height = Number(videoStream.height || 0);
|
||||
if (width > 0 && height > 0) {
|
||||
resolution = `${width}x${height}`;
|
||||
}
|
||||
}
|
||||
|
||||
const fps = videoStream?.avg_frame_rate
|
||||
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
|
||||
parseFloat(videoStream.avg_frame_rate.split("/")[1])
|
||||
: undefined;
|
||||
|
||||
let snapshotBase64: string | undefined = undefined;
|
||||
if (fetchSnapshot) {
|
||||
if (setStatus) {
|
||||
setStatus(t("cameraWizard.step2.testing.fetchingSnapshot"));
|
||||
}
|
||||
try {
|
||||
const snapshotResponse = await axios.get("ffprobe/snapshot", {
|
||||
params: { url: uri },
|
||||
responseType: "blob",
|
||||
timeout: 10000,
|
||||
});
|
||||
const snapshotBlob = snapshotResponse.data;
|
||||
snapshotBase64 = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(snapshotBlob);
|
||||
});
|
||||
} catch (snapshotError) {
|
||||
snapshotBase64 = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const streamTestResult: TestResult = {
|
||||
success: true,
|
||||
snapshot: snapshotBase64,
|
||||
resolution,
|
||||
videoCodec: videoStream?.codec_name,
|
||||
audioCodec: audioStream?.codec_name,
|
||||
fps: fps && !isNaN(fps) ? fps : undefined,
|
||||
};
|
||||
|
||||
return streamTestResult;
|
||||
} catch (err) {
|
||||
const axiosError = err as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const errorMessage =
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.detail ||
|
||||
axiosError.message ||
|
||||
"Connection failed";
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const probeCamera = useCallback(async () => {
|
||||
if (!wizardData.host) {
|
||||
toast.error(t("cameraWizard.step2.errors.hostRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProbing(true);
|
||||
setProbeError(null);
|
||||
setProbeResult(null);
|
||||
|
||||
try {
|
||||
const response = await axios.get("/onvif/probe", {
|
||||
params: {
|
||||
host: wizardData.host,
|
||||
port: wizardData.onvifPort ?? 80,
|
||||
username: wizardData.username || "",
|
||||
password: wizardData.password || "",
|
||||
test: false,
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
setProbeResult(response.data);
|
||||
// Extract candidate URLs and pass to wizardData
|
||||
const candidateUris = (response.data.rtsp_candidates || [])
|
||||
.filter((c: { source: string }) => c.source === "GetStreamUri")
|
||||
.map((c: { uri: string }) => c.uri);
|
||||
onUpdate({
|
||||
probeMode: true,
|
||||
probeCandidates: candidateUris,
|
||||
candidateTests: {},
|
||||
});
|
||||
} else {
|
||||
setProbeError(response.data?.message || "Probe failed");
|
||||
}
|
||||
} catch (error) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const errorMessage =
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.detail ||
|
||||
axiosError.message ||
|
||||
"Failed to probe camera";
|
||||
setProbeError(errorMessage);
|
||||
toast.error(t("cameraWizard.step2.probeFailed", { error: errorMessage }));
|
||||
} finally {
|
||||
setIsProbing(false);
|
||||
}
|
||||
}, [wizardData, onUpdate, t]);
|
||||
|
||||
const testAllSelectedCandidates = useCallback(async () => {
|
||||
const uris = (probeResult?.rtsp_candidates || [])
|
||||
.filter((c) => c.source === "GetStreamUri")
|
||||
.map((c) => c.uri);
|
||||
|
||||
if (!uris || uris.length === 0) {
|
||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare an initial stream so the wizard can proceed to step 3.
|
||||
// Use the first candidate as the initial stream (no extra probing here).
|
||||
const streamsToCreate: StreamConfig[] = [];
|
||||
if (uris.length > 0) {
|
||||
const first = uris[0];
|
||||
streamsToCreate.push({
|
||||
id: `stream_${Date.now()}`,
|
||||
url: first,
|
||||
roles: ["detect" as const],
|
||||
testResult: candidateTests[first],
|
||||
});
|
||||
}
|
||||
|
||||
// Use existing candidateTests state (may contain entries from individual tests)
|
||||
onNext({
|
||||
probeMode: true,
|
||||
probeCandidates: uris,
|
||||
candidateTests: candidateTests,
|
||||
streams: streamsToCreate,
|
||||
});
|
||||
}, [probeResult, candidateTests, onNext, t]);
|
||||
|
||||
const testCandidate = useCallback(
|
||||
async (uri: string) => {
|
||||
if (!uri) return;
|
||||
setTestingCandidates((s) => ({ ...s, [uri]: true }));
|
||||
try {
|
||||
const result = await probeUri(uri, false);
|
||||
setCandidateTests((s) => ({ ...s, [uri]: result }));
|
||||
} finally {
|
||||
setTestingCandidates((s) => ({ ...s, [uri]: false }));
|
||||
}
|
||||
},
|
||||
[probeUri],
|
||||
);
|
||||
|
||||
const generateDynamicStreamUrl = useCallback(
|
||||
async (data: Partial<WizardFormData>): Promise<string | null> => {
|
||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||
if (!brand || !data.host) return null;
|
||||
|
||||
let protocol = undefined;
|
||||
if (data.brandTemplate === "reolink" && data.username && data.password) {
|
||||
try {
|
||||
protocol = await detectReolinkCamera(
|
||||
data.host,
|
||||
data.username,
|
||||
data.password,
|
||||
);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const protocolKey = protocol || "rtsp";
|
||||
const templates: Record<string, string> = brand.dynamicTemplates || {};
|
||||
|
||||
if (Object.keys(templates).includes(protocolKey)) {
|
||||
const template =
|
||||
templates[protocolKey as keyof typeof brand.dynamicTemplates];
|
||||
return template
|
||||
.replace("{username}", data.username || "")
|
||||
.replace("{password}", data.password || "")
|
||||
.replace("{host}", data.host);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const generateStreamUrl = useCallback(
|
||||
async (data: Partial<WizardFormData>): Promise<string> => {
|
||||
if (data.brandTemplate === "other") {
|
||||
return data.customUrl || "";
|
||||
}
|
||||
|
||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||
if (!brand || !data.host) return "";
|
||||
|
||||
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
|
||||
const dynamicUrl = await generateDynamicStreamUrl(data);
|
||||
|
||||
if (dynamicUrl) {
|
||||
return dynamicUrl;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
return brand.template
|
||||
.replace("{username}", data.username || "")
|
||||
.replace("{password}", data.password || "")
|
||||
.replace("{host}", data.host);
|
||||
},
|
||||
[generateDynamicStreamUrl],
|
||||
);
|
||||
|
||||
const testConnection = useCallback(
|
||||
async (showToast = true) => {
|
||||
const streamUrl = await generateStreamUrl(wizardData);
|
||||
|
||||
if (!streamUrl) {
|
||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTesting(true);
|
||||
setTestStatus("");
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
|
||||
const result = await probeUri(streamUrl, true, setTestStatus);
|
||||
|
||||
if (result && result.success) {
|
||||
setTestResult(result);
|
||||
const streamId = `stream_${Date.now()}`;
|
||||
onUpdate({
|
||||
streams: [
|
||||
{
|
||||
id: streamId,
|
||||
url: streamUrl,
|
||||
roles: ["detect"] as StreamRole[],
|
||||
testResult: result,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (showToast) {
|
||||
toast.success(t("cameraWizard.step2.testSuccess"));
|
||||
}
|
||||
} else {
|
||||
const errMsg = result?.error || "Unable to probe stream";
|
||||
setTestResult({
|
||||
success: false,
|
||||
error: errMsg,
|
||||
});
|
||||
|
||||
if (showToast) {
|
||||
toast.error(
|
||||
t("cameraWizard.commonErrors.testFailed", { error: errMsg }),
|
||||
{
|
||||
duration: 6000,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const errorMessage =
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.detail ||
|
||||
axiosError.message ||
|
||||
"Connection failed";
|
||||
setTestResult({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
if (showToast) {
|
||||
toast.error(
|
||||
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
|
||||
{
|
||||
duration: 10000,
|
||||
},
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
setTestStatus("");
|
||||
}
|
||||
},
|
||||
[wizardData, generateStreamUrl, t, onUpdate, probeUri],
|
||||
);
|
||||
|
||||
const handleContinue = useCallback(() => {
|
||||
onNext();
|
||||
}, [onNext]);
|
||||
|
||||
// Auto-start probe or test when step loads
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasStarted) {
|
||||
setHasStarted(true);
|
||||
if (probeMode) {
|
||||
probeCamera();
|
||||
} else {
|
||||
// Auto-run the connection test but suppress toasts to avoid duplicates
|
||||
testConnection(false);
|
||||
}
|
||||
}
|
||||
}, [hasStarted, probeMode, probeCamera, testConnection]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{probeMode ? (
|
||||
// Probe mode: show probe results directly
|
||||
<>
|
||||
{probeResult && (
|
||||
<div className="space-y-4">
|
||||
<OnvifProbeResults
|
||||
isLoading={isProbing}
|
||||
isError={!!probeError}
|
||||
error={probeError || undefined}
|
||||
probeResult={probeResult}
|
||||
onRetry={probeCamera}
|
||||
testCandidate={testCandidate}
|
||||
candidateTests={candidateTests}
|
||||
testingCandidates={testingCandidates}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProbeFooterButtons
|
||||
isProbing={isProbing}
|
||||
probeError={probeError}
|
||||
onBack={onBack}
|
||||
onTestAll={testAllSelectedCandidates}
|
||||
onRetry={probeCamera}
|
||||
// disable next if either the overall testConnection is running or any candidate test is running
|
||||
isTesting={
|
||||
isTesting || Object.values(testingCandidates).some((v) => v)
|
||||
}
|
||||
candidateCount={
|
||||
(probeResult?.rtsp_candidates || []).filter(
|
||||
(c) => c.source === "GetStreamUri",
|
||||
).length
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Manual mode: show snapshot and stream details
|
||||
<>
|
||||
{testResult?.success && (
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex flex-row items-center gap-2 text-sm font-medium text-success">
|
||||
<FaCircleCheck className="size-4" />
|
||||
{t("cameraWizard.step2.testSuccess")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{testResult.snapshot ? (
|
||||
<div className="relative flex justify-center">
|
||||
<img
|
||||
src={testResult.snapshot}
|
||||
alt="Camera snapshot"
|
||||
className="max-h-[50dvh] max-w-full rounded-lg object-contain"
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 rounded-md bg-black/70 p-3 text-sm backdrop-blur-sm">
|
||||
<div className="space-y-1">
|
||||
<StreamDetails testResult={testResult} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-4">
|
||||
<CardTitle className="mb-2 text-sm">
|
||||
{t("cameraWizard.step2.streamDetails")}
|
||||
</CardTitle>
|
||||
<CardContent className="p-0 text-sm">
|
||||
<StreamDetails testResult={testResult} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTesting && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ActivityIndicator className="size-4" />
|
||||
{testStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResult && !testResult.success && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-destructive">{testResult.error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProbeFooterButtons
|
||||
mode="manual"
|
||||
isProbing={false}
|
||||
probeError={null}
|
||||
onBack={onBack}
|
||||
onTestAll={testAllSelectedCandidates}
|
||||
onRetry={probeCamera}
|
||||
isTesting={
|
||||
isTesting || Object.values(testingCandidates).some((v) => v)
|
||||
}
|
||||
candidateCount={
|
||||
(probeResult?.rtsp_candidates || []).filter(
|
||||
(c) => c.source === "GetStreamUri",
|
||||
).length
|
||||
}
|
||||
manualTestSuccess={!!testResult?.success}
|
||||
onContinue={handleContinue}
|
||||
onManualTest={testConnection}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamDetails({ testResult }: { testResult: TestResult }) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{testResult.resolution && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.resolution")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.resolution}</span>
|
||||
</div>
|
||||
)}
|
||||
{testResult.fps && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.fps")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.fps}</span>
|
||||
</div>
|
||||
)}
|
||||
{testResult.videoCodec && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.video")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.videoCodec}</span>
|
||||
</div>
|
||||
)}
|
||||
{testResult.audioCodec && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.audio")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.audioCodec}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ProbeFooterProps = {
|
||||
isProbing: boolean;
|
||||
probeError: string | null;
|
||||
onBack: () => void;
|
||||
onTestAll: () => void;
|
||||
onRetry: () => void;
|
||||
isTesting: boolean;
|
||||
candidateCount?: number;
|
||||
mode?: "probe" | "manual";
|
||||
manualTestSuccess?: boolean;
|
||||
onContinue?: () => void;
|
||||
onManualTest?: () => void;
|
||||
};
|
||||
|
||||
function ProbeFooterButtons({
|
||||
isProbing,
|
||||
probeError,
|
||||
onBack,
|
||||
onTestAll,
|
||||
onRetry,
|
||||
isTesting,
|
||||
candidateCount = 0,
|
||||
mode = "probe",
|
||||
manualTestSuccess,
|
||||
onContinue,
|
||||
onManualTest,
|
||||
}: ProbeFooterProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
// Loading footer
|
||||
if (isProbing) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ActivityIndicator className="size-4" />
|
||||
{t("cameraWizard.step2.probing")}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack} disabled className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
<ActivityIndicator className="size-4" />
|
||||
{t("cameraWizard.step2.probing")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error footer
|
||||
if (probeError) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-destructive">{probeError}</div>
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
{t("cameraWizard.step2.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default footer: show back + test (test disabled if none selected or testing)
|
||||
// If manual mode, show Continue when test succeeded, otherwise show Test (calls onManualTest)
|
||||
if (mode === "manual") {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
{manualTestSuccess ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
{t("button.continue", { ns: "common" })}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onManualTest}
|
||||
disabled={isTesting}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<ActivityIndicator className="size-4" />{" "}
|
||||
{t("button.continue", { ns: "common" })}
|
||||
</>
|
||||
) : (
|
||||
t("cameraWizard.step2.retry")
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default probe footer
|
||||
return (
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onTestAll}
|
||||
disabled={isTesting || (candidateCount ?? 0) === 0}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
{t("button.next", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,481 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { LuPlus, LuTrash2, LuX } from "react-icons/lu";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
WizardFormData,
|
||||
StreamConfig,
|
||||
StreamRole,
|
||||
TestResult,
|
||||
FfprobeStream,
|
||||
} from "@/types/cameraWizard";
|
||||
import { Label } from "../../ui/label";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { LuInfo, LuExternalLink } from "react-icons/lu";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
|
||||
type Step2StreamConfigProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||
onBack?: () => void;
|
||||
onNext?: () => void;
|
||||
canProceed?: boolean;
|
||||
};
|
||||
|
||||
export default function Step2StreamConfig({
|
||||
wizardData,
|
||||
onUpdate,
|
||||
onBack,
|
||||
onNext,
|
||||
canProceed,
|
||||
}: Step2StreamConfigProps) {
|
||||
const { t } = useTranslation(["views/settings", "components/dialog"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
||||
|
||||
const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
|
||||
|
||||
const addStream = useCallback(() => {
|
||||
const newStream: StreamConfig = {
|
||||
id: `stream_${Date.now()}`,
|
||||
url: "",
|
||||
roles: [],
|
||||
};
|
||||
onUpdate({
|
||||
streams: [...streams, newStream],
|
||||
});
|
||||
}, [streams, onUpdate]);
|
||||
|
||||
const removeStream = useCallback(
|
||||
(streamId: string) => {
|
||||
onUpdate({
|
||||
streams: streams.filter((s) => s.id !== streamId),
|
||||
});
|
||||
},
|
||||
[streams, onUpdate],
|
||||
);
|
||||
|
||||
const updateStream = useCallback(
|
||||
(streamId: string, updates: Partial<StreamConfig>) => {
|
||||
onUpdate({
|
||||
streams: streams.map((s) =>
|
||||
s.id === streamId ? { ...s, ...updates } : s,
|
||||
),
|
||||
});
|
||||
},
|
||||
[streams, onUpdate],
|
||||
);
|
||||
|
||||
const getUsedRolesExcludingStream = useCallback(
|
||||
(excludeStreamId: string) => {
|
||||
const roles = new Set<StreamRole>();
|
||||
streams.forEach((stream) => {
|
||||
if (stream.id !== excludeStreamId) {
|
||||
stream.roles.forEach((role) => roles.add(role));
|
||||
}
|
||||
});
|
||||
return roles;
|
||||
},
|
||||
[streams],
|
||||
);
|
||||
|
||||
const toggleRole = useCallback(
|
||||
(streamId: string, role: StreamRole) => {
|
||||
const stream = streams.find((s) => s.id === streamId);
|
||||
if (!stream) return;
|
||||
|
||||
const hasRole = stream.roles.includes(role);
|
||||
if (hasRole) {
|
||||
// Allow removing the role
|
||||
const newRoles = stream.roles.filter((r) => r !== role);
|
||||
updateStream(streamId, { roles: newRoles });
|
||||
} else {
|
||||
// Check if role is already used in another stream
|
||||
const usedRoles = getUsedRolesExcludingStream(streamId);
|
||||
if (!usedRoles.has(role)) {
|
||||
// Allow adding the role
|
||||
const newRoles = [...stream.roles, role];
|
||||
updateStream(streamId, { roles: newRoles });
|
||||
}
|
||||
}
|
||||
},
|
||||
[streams, updateStream, getUsedRolesExcludingStream],
|
||||
);
|
||||
|
||||
const testStream = useCallback(
|
||||
(stream: StreamConfig) => {
|
||||
if (!stream.url.trim()) {
|
||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
setTestingStreams((prev) => new Set(prev).add(stream.id));
|
||||
|
||||
axios
|
||||
.get("ffprobe", {
|
||||
params: { paths: stream.url, detailed: true },
|
||||
timeout: 10000,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data?.[0]?.return_code === 0) {
|
||||
const probeData = response.data[0];
|
||||
const streams = probeData.stdout.streams || [];
|
||||
|
||||
const videoStream = streams.find(
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "video" ||
|
||||
s.codec_name?.includes("h264") ||
|
||||
s.codec_name?.includes("h265"),
|
||||
);
|
||||
|
||||
const audioStream = streams.find(
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "audio" ||
|
||||
s.codec_name?.includes("aac") ||
|
||||
s.codec_name?.includes("mp3"),
|
||||
);
|
||||
|
||||
const resolution = videoStream
|
||||
? `${videoStream.width}x${videoStream.height}`
|
||||
: undefined;
|
||||
|
||||
const fps = videoStream?.avg_frame_rate
|
||||
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
|
||||
parseFloat(videoStream.avg_frame_rate.split("/")[1])
|
||||
: undefined;
|
||||
|
||||
const testResult: TestResult = {
|
||||
success: true,
|
||||
resolution,
|
||||
videoCodec: videoStream?.codec_name,
|
||||
audioCodec: audioStream?.codec_name,
|
||||
fps: fps && !isNaN(fps) ? fps : undefined,
|
||||
};
|
||||
|
||||
updateStream(stream.id, { testResult, userTested: true });
|
||||
toast.success(t("cameraWizard.step2.testSuccess"));
|
||||
} else {
|
||||
const error = response.data?.[0]?.stderr || "Unknown error";
|
||||
updateStream(stream.id, {
|
||||
testResult: { success: false, error },
|
||||
userTested: true,
|
||||
});
|
||||
toast.error(t("cameraWizard.commonErrors.testFailed", { error }));
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Connection failed";
|
||||
updateStream(stream.id, {
|
||||
testResult: { success: false, error: errorMessage },
|
||||
userTested: true,
|
||||
});
|
||||
toast.error(
|
||||
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setTestingStreams((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(stream.id);
|
||||
return newSet;
|
||||
});
|
||||
});
|
||||
},
|
||||
[updateStream, t],
|
||||
);
|
||||
|
||||
const setRestream = useCallback(
|
||||
(streamId: string) => {
|
||||
const stream = streams.find((s) => s.id === streamId);
|
||||
if (!stream) return;
|
||||
|
||||
updateStream(streamId, { restream: !stream.restream });
|
||||
},
|
||||
[streams, updateStream],
|
||||
);
|
||||
|
||||
const hasDetectRole = streams.some((s) => s.roles.includes("detect"));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-sm text-secondary-foreground">
|
||||
{t("cameraWizard.step2.description")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{streams.map((stream, index) => (
|
||||
<Card key={stream.id} className="bg-secondary text-primary">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
{t("cameraWizard.step2.streamTitle", { number: index + 1 })}
|
||||
</h4>
|
||||
{stream.testResult && stream.testResult.success && (
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{[
|
||||
stream.testResult.resolution,
|
||||
stream.testResult.fps
|
||||
? `${stream.testResult.fps} ${t("cameraWizard.testResultLabels.fps")}`
|
||||
: null,
|
||||
stream.testResult.videoCodec,
|
||||
stream.testResult.audioCodec,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{stream.testResult?.success && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<FaCircleCheck className="size-4 text-success" />
|
||||
<span className="text-success">
|
||||
{t("cameraWizard.step2.connected")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{stream.testResult && !stream.testResult.success && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<LuX className="size-4 text-danger" />
|
||||
<span className="text-danger">
|
||||
{t("cameraWizard.step2.notConnected")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{streams.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeStream(stream.id)}
|
||||
className="text-secondary-foreground hover:text-secondary-foreground"
|
||||
>
|
||||
<LuTrash2 className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step2.url")}
|
||||
</label>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Input
|
||||
value={stream.url}
|
||||
onChange={(e) =>
|
||||
updateStream(stream.id, {
|
||||
url: e.target.value,
|
||||
testResult: undefined,
|
||||
})
|
||||
}
|
||||
className="h-8 flex-1"
|
||||
placeholder={t("cameraWizard.step2.streamUrlPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => testStream(stream)}
|
||||
disabled={
|
||||
testingStreams.has(stream.id) || !stream.url.trim()
|
||||
}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{testingStreams.has(stream.id) && (
|
||||
<ActivityIndicator className="mr-2 size-4" />
|
||||
)}
|
||||
{t("cameraWizard.step2.testStream")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stream.testResult &&
|
||||
!stream.testResult.success &&
|
||||
stream.userTested && (
|
||||
<div className="rounded-md border border-danger/20 bg-danger/10 p-3 text-sm text-danger">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step2.testFailedTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-xs">
|
||||
{stream.testResult.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step2.roles")}
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
||||
<LuInfo className="size-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step2.rolesPopover.title")}
|
||||
</div>
|
||||
<div className="space-y-1 text-muted-foreground">
|
||||
<div>
|
||||
<strong>detect</strong> -{" "}
|
||||
{t("cameraWizard.step2.rolesPopover.detect")}
|
||||
</div>
|
||||
<div>
|
||||
<strong>record</strong> -{" "}
|
||||
{t("cameraWizard.step2.rolesPopover.record")}
|
||||
</div>
|
||||
<div>
|
||||
<strong>audio</strong> -{" "}
|
||||
{t("cameraWizard.step2.rolesPopover.audio")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/cameras")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["detect", "record", "audio"] as const).map((role) => {
|
||||
const isUsedElsewhere = getUsedRolesExcludingStream(
|
||||
stream.id,
|
||||
).has(role);
|
||||
const isChecked = stream.roles.includes(role);
|
||||
return (
|
||||
<div
|
||||
key={role}
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<span className="text-sm capitalize">{role}</span>
|
||||
<Switch
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleRole(stream.id, role)}
|
||||
disabled={!isChecked && isUsedElsewhere}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step2.featuresTitle")}
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
||||
<LuInfo className="size-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step2.featuresPopover.title")}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.featuresPopover.description")}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl(
|
||||
"configuration/restream#reduce-connections-to-camera",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">
|
||||
{t("cameraWizard.step2.go2rtc")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={stream.restream || false}
|
||||
onCheckedChange={() => setRestream(stream.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addStream}
|
||||
variant="outline"
|
||||
className=""
|
||||
>
|
||||
<LuPlus className="mr-2 size-4" />
|
||||
{t("cameraWizard.step2.addAnotherStream")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!hasDetectRole && (
|
||||
<div className="rounded-lg border border-danger/50 p-3 text-sm text-danger">
|
||||
{t("cameraWizard.step2.detectRoleWarning")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
{onBack && (
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
{onNext && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onNext?.()}
|
||||
disabled={!canProceed}
|
||||
variant="select"
|
||||
className="sm:flex-1"
|
||||
>
|
||||
{t("button.next", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
757
web/src/components/settings/wizard/Step3StreamConfig.tsx
Normal file
757
web/src/components/settings/wizard/Step3StreamConfig.tsx
Normal file
@ -0,0 +1,757 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { LuPlus, LuTrash2, LuX } from "react-icons/lu";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
WizardFormData,
|
||||
StreamConfig,
|
||||
StreamRole,
|
||||
TestResult,
|
||||
FfprobeStream,
|
||||
FfprobeData,
|
||||
FfprobeResponse,
|
||||
CandidateTestMap,
|
||||
} from "@/types/cameraWizard";
|
||||
import { Label } from "../../ui/label";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import {
|
||||
LuInfo,
|
||||
LuExternalLink,
|
||||
LuCheck,
|
||||
LuChevronsUpDown,
|
||||
} from "react-icons/lu";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
|
||||
type Step3StreamConfigProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||
onBack?: () => void;
|
||||
onNext?: () => void;
|
||||
canProceed?: boolean;
|
||||
};
|
||||
|
||||
export default function Step3StreamConfig({
|
||||
wizardData,
|
||||
onUpdate,
|
||||
onBack,
|
||||
onNext,
|
||||
canProceed,
|
||||
}: Step3StreamConfigProps) {
|
||||
const { t } = useTranslation(["views/settings", "components/dialog"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
||||
const [openCombobox, setOpenCombobox] = useState<string | null>(null);
|
||||
|
||||
const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
|
||||
|
||||
// Probe mode candidate tracking
|
||||
const probeCandidates = useMemo(
|
||||
() => (wizardData.probeCandidates || []) as string[],
|
||||
[wizardData.probeCandidates],
|
||||
);
|
||||
|
||||
const candidateTests = useMemo(
|
||||
() => (wizardData.candidateTests || {}) as CandidateTestMap,
|
||||
[wizardData.candidateTests],
|
||||
);
|
||||
|
||||
const isProbeMode = !!wizardData.probeMode;
|
||||
|
||||
const addStream = useCallback(() => {
|
||||
const newStreamId = `stream_${Date.now()}`;
|
||||
|
||||
let initialUrl = "";
|
||||
if (isProbeMode && probeCandidates.length > 0) {
|
||||
// pick first candidate not already used
|
||||
const used = new Set(streams.map((s) => s.url).filter(Boolean));
|
||||
const firstAvailable = probeCandidates.find((c) => !used.has(c));
|
||||
if (firstAvailable) {
|
||||
initialUrl = firstAvailable;
|
||||
}
|
||||
}
|
||||
|
||||
const newStream: StreamConfig = {
|
||||
id: newStreamId,
|
||||
url: initialUrl,
|
||||
roles: [],
|
||||
testResult: initialUrl ? candidateTests[initialUrl] : undefined,
|
||||
userTested: initialUrl ? !!candidateTests[initialUrl] : false,
|
||||
};
|
||||
|
||||
onUpdate({
|
||||
streams: [...streams, newStream],
|
||||
});
|
||||
}, [streams, onUpdate, isProbeMode, probeCandidates, candidateTests]);
|
||||
|
||||
const removeStream = useCallback(
|
||||
(streamId: string) => {
|
||||
onUpdate({
|
||||
streams: streams.filter((s) => s.id !== streamId),
|
||||
});
|
||||
},
|
||||
[streams, onUpdate],
|
||||
);
|
||||
|
||||
const updateStream = useCallback(
|
||||
(streamId: string, updates: Partial<StreamConfig>) => {
|
||||
onUpdate({
|
||||
streams: streams.map((s) =>
|
||||
s.id === streamId ? { ...s, ...updates } : s,
|
||||
),
|
||||
});
|
||||
},
|
||||
[streams, onUpdate],
|
||||
);
|
||||
|
||||
const getUsedRolesExcludingStream = useCallback(
|
||||
(excludeStreamId: string) => {
|
||||
const roles = new Set<StreamRole>();
|
||||
streams.forEach((stream) => {
|
||||
if (stream.id !== excludeStreamId) {
|
||||
stream.roles.forEach((role) => roles.add(role));
|
||||
}
|
||||
});
|
||||
return roles;
|
||||
},
|
||||
[streams],
|
||||
);
|
||||
|
||||
const getUsedUrlsExcludingStream = useCallback(
|
||||
(excludeStreamId: string) => {
|
||||
const used = new Set<string>();
|
||||
streams.forEach((s) => {
|
||||
if (s.id !== excludeStreamId && s.url) {
|
||||
used.add(s.url);
|
||||
}
|
||||
});
|
||||
return used;
|
||||
},
|
||||
[streams],
|
||||
);
|
||||
|
||||
const toggleRole = useCallback(
|
||||
(streamId: string, role: StreamRole) => {
|
||||
const stream = streams.find((s) => s.id === streamId);
|
||||
if (!stream) return;
|
||||
|
||||
const hasRole = stream.roles.includes(role);
|
||||
if (hasRole) {
|
||||
// Allow removing the role
|
||||
const newRoles = stream.roles.filter((r) => r !== role);
|
||||
updateStream(streamId, { roles: newRoles });
|
||||
} else {
|
||||
// Check if role is already used in another stream
|
||||
const usedRoles = getUsedRolesExcludingStream(streamId);
|
||||
if (!usedRoles.has(role)) {
|
||||
// Allow adding the role
|
||||
const newRoles = [...stream.roles, role];
|
||||
updateStream(streamId, { roles: newRoles });
|
||||
}
|
||||
}
|
||||
},
|
||||
[streams, updateStream, getUsedRolesExcludingStream],
|
||||
);
|
||||
|
||||
const testStream = useCallback(
|
||||
async (stream: StreamConfig) => {
|
||||
if (!stream.url.trim()) {
|
||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
setTestingStreams((prev) => new Set(prev).add(stream.id));
|
||||
|
||||
try {
|
||||
const response = await axios.get("ffprobe", {
|
||||
params: { paths: stream.url, detailed: true },
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
let probeData: FfprobeResponse | null = null;
|
||||
if (
|
||||
response.data &&
|
||||
response.data.length > 0 &&
|
||||
response.data[0].return_code === 0
|
||||
) {
|
||||
probeData = response.data[0];
|
||||
}
|
||||
|
||||
if (!probeData) {
|
||||
const error =
|
||||
Array.isArray(response.data?.[0]?.stderr) &&
|
||||
response.data[0].stderr.length > 0
|
||||
? response.data[0].stderr.join("\n")
|
||||
: "Unable to probe stream";
|
||||
const failResult: TestResult = { success: false, error };
|
||||
updateStream(stream.id, { testResult: failResult, userTested: true });
|
||||
onUpdate({
|
||||
candidateTests: {
|
||||
...(wizardData.candidateTests || {}),
|
||||
[stream.url]: failResult,
|
||||
} as CandidateTestMap,
|
||||
});
|
||||
toast.error(t("cameraWizard.commonErrors.testFailed", { error }));
|
||||
return;
|
||||
}
|
||||
|
||||
let ffprobeData: FfprobeData;
|
||||
if (typeof probeData.stdout === "string") {
|
||||
try {
|
||||
ffprobeData = JSON.parse(probeData.stdout as string) as FfprobeData;
|
||||
} catch {
|
||||
ffprobeData = { streams: [] } as FfprobeData;
|
||||
}
|
||||
} else {
|
||||
ffprobeData = probeData.stdout as FfprobeData;
|
||||
}
|
||||
|
||||
const streamsArr = ffprobeData.streams || [];
|
||||
|
||||
const videoStream = streamsArr.find(
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "video" ||
|
||||
s.codec_name?.includes("h264") ||
|
||||
s.codec_name?.includes("hevc"),
|
||||
);
|
||||
|
||||
const audioStream = streamsArr.find(
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "audio" ||
|
||||
s.codec_name?.includes("aac") ||
|
||||
s.codec_name?.includes("mp3") ||
|
||||
s.codec_name?.includes("pcm_mulaw") ||
|
||||
s.codec_name?.includes("pcm_alaw"),
|
||||
);
|
||||
|
||||
let resolution: string | undefined = undefined;
|
||||
if (videoStream) {
|
||||
const width = Number(videoStream.width || 0);
|
||||
const height = Number(videoStream.height || 0);
|
||||
if (width > 0 && height > 0) {
|
||||
resolution = `${width}x${height}`;
|
||||
}
|
||||
}
|
||||
|
||||
const fps = videoStream?.avg_frame_rate
|
||||
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
|
||||
parseFloat(videoStream.avg_frame_rate.split("/")[1])
|
||||
: undefined;
|
||||
|
||||
const testResult: TestResult = {
|
||||
success: true,
|
||||
resolution,
|
||||
videoCodec: videoStream?.codec_name,
|
||||
audioCodec: audioStream?.codec_name,
|
||||
fps: fps && !isNaN(fps) ? fps : undefined,
|
||||
};
|
||||
|
||||
updateStream(stream.id, { testResult, userTested: true });
|
||||
onUpdate({
|
||||
candidateTests: {
|
||||
...(wizardData.candidateTests || {}),
|
||||
[stream.url]: testResult,
|
||||
} as CandidateTestMap,
|
||||
});
|
||||
toast.success(t("cameraWizard.step3.testSuccess"));
|
||||
} catch (error) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const errorMessage =
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.detail ||
|
||||
axiosError.message ||
|
||||
"Connection failed";
|
||||
const catchResult: TestResult = {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
updateStream(stream.id, { testResult: catchResult, userTested: true });
|
||||
onUpdate({
|
||||
candidateTests: {
|
||||
...(wizardData.candidateTests || {}),
|
||||
[stream.url]: catchResult,
|
||||
} as CandidateTestMap,
|
||||
});
|
||||
toast.error(
|
||||
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
|
||||
);
|
||||
} finally {
|
||||
setTestingStreams((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(stream.id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
},
|
||||
[updateStream, t, onUpdate, wizardData.candidateTests],
|
||||
);
|
||||
|
||||
const setRestream = useCallback(
|
||||
(streamId: string) => {
|
||||
const stream = streams.find((s) => s.id === streamId);
|
||||
if (!stream) return;
|
||||
|
||||
updateStream(streamId, { restream: !stream.restream });
|
||||
},
|
||||
[streams, updateStream],
|
||||
);
|
||||
|
||||
const hasDetectRole = streams.some((s) => s.roles.includes("detect"));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-sm text-secondary-foreground">
|
||||
{t("cameraWizard.step3.description")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{streams.map((stream, index) => (
|
||||
<Card key={stream.id} className="bg-secondary text-primary">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
{t("cameraWizard.step3.streamTitle", { number: index + 1 })}
|
||||
</h4>
|
||||
{stream.testResult && stream.testResult.success && (
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{[
|
||||
stream.testResult.resolution,
|
||||
stream.testResult.fps
|
||||
? `${stream.testResult.fps} ${t("cameraWizard.testResultLabels.fps")}`
|
||||
: null,
|
||||
stream.testResult.videoCodec,
|
||||
stream.testResult.audioCodec,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{stream.testResult?.success && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<FaCircleCheck className="size-4 text-success" />
|
||||
<span className="text-success">
|
||||
{t("cameraWizard.step3.connected")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{stream.testResult && !stream.testResult.success && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<LuX className="size-4 text-danger" />
|
||||
<span className="text-danger">
|
||||
{t("cameraWizard.step3.notConnected")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{streams.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeStream(stream.id)}
|
||||
className="text-secondary-foreground hover:text-secondary-foreground"
|
||||
>
|
||||
<LuTrash2 className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step3.url")}
|
||||
</label>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{isProbeMode && probeCandidates.length > 0 ? (
|
||||
// Responsive: Popover on desktop, Drawer on mobile
|
||||
!isMobile ? (
|
||||
<Popover
|
||||
open={openCombobox === stream.id}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpenCombobox(isOpen ? stream.id : null);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openCombobox === stream.id}
|
||||
className="h-8 w-full justify-between overflow-hidden text-left"
|
||||
>
|
||||
<span className="truncate">
|
||||
{stream.url
|
||||
? stream.url
|
||||
: t("cameraWizard.step3.selectStream")}
|
||||
</span>
|
||||
<LuChevronsUpDown className="ml-2 size-6 opacity-50" />
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[--radix-popover-trigger-width] p-2"
|
||||
disablePortal
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"cameraWizard.step3.searchCandidates",
|
||||
)}
|
||||
className="h-9"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("cameraWizard.step3.noStreamFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{probeCandidates
|
||||
.filter((c) => {
|
||||
const used = getUsedUrlsExcludingStream(
|
||||
stream.id,
|
||||
);
|
||||
return !used.has(c);
|
||||
})
|
||||
.map((candidate) => (
|
||||
<CommandItem
|
||||
key={candidate}
|
||||
value={candidate}
|
||||
onSelect={() => {
|
||||
updateStream(stream.id, {
|
||||
url: candidate,
|
||||
testResult:
|
||||
candidateTests[candidate] ||
|
||||
undefined,
|
||||
userTested:
|
||||
!!candidateTests[candidate],
|
||||
});
|
||||
setOpenCombobox(null);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-3 size-5",
|
||||
stream.url === candidate
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{candidate}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Drawer
|
||||
open={openCombobox === stream.id}
|
||||
onOpenChange={(isOpen) =>
|
||||
setOpenCombobox(isOpen ? stream.id : null)
|
||||
}
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openCombobox === stream.id}
|
||||
className="h-8 w-full justify-between overflow-hidden text-left"
|
||||
>
|
||||
<span className="truncate">
|
||||
{stream.url
|
||||
? stream.url
|
||||
: t("cameraWizard.step3.selectStream")}
|
||||
</span>
|
||||
<LuChevronsUpDown className="ml-2 size-6 opacity-50" />
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="mx-1 max-h-[75dvh] overflow-hidden rounded-t-2xl px-2">
|
||||
<div className="mt-2">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"cameraWizard.step3.searchCandidates",
|
||||
)}
|
||||
className="h-9"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("cameraWizard.step3.noStreamFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{probeCandidates
|
||||
.filter((c) => {
|
||||
const used = getUsedUrlsExcludingStream(
|
||||
stream.id,
|
||||
);
|
||||
return !used.has(c);
|
||||
})
|
||||
.map((candidate) => (
|
||||
<CommandItem
|
||||
key={candidate}
|
||||
value={candidate}
|
||||
onSelect={() => {
|
||||
updateStream(stream.id, {
|
||||
url: candidate,
|
||||
testResult:
|
||||
candidateTests[candidate] ||
|
||||
undefined,
|
||||
userTested:
|
||||
!!candidateTests[candidate],
|
||||
});
|
||||
setOpenCombobox(null);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-3 size-5",
|
||||
stream.url === candidate
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{candidate}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
) : (
|
||||
<Input
|
||||
value={stream.url}
|
||||
onChange={(e) =>
|
||||
updateStream(stream.id, {
|
||||
url: e.target.value,
|
||||
testResult: undefined,
|
||||
})
|
||||
}
|
||||
className="h-8 flex-1"
|
||||
placeholder={t(
|
||||
"cameraWizard.step3.streamUrlPlaceholder",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => testStream(stream)}
|
||||
disabled={
|
||||
testingStreams.has(stream.id) || !stream.url.trim()
|
||||
}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{testingStreams.has(stream.id) && (
|
||||
<ActivityIndicator className="mr-2 size-4" />
|
||||
)}
|
||||
{t("cameraWizard.step3.testStream")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stream.testResult &&
|
||||
!stream.testResult.success &&
|
||||
stream.userTested && (
|
||||
<div className="rounded-md border border-danger/20 bg-danger/10 p-3 text-sm text-danger">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step3.testFailedTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-xs">
|
||||
{stream.testResult.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step3.roles")}
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
||||
<LuInfo className="size-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step3.rolesPopover.title")}
|
||||
</div>
|
||||
<div className="space-y-1 text-muted-foreground">
|
||||
<div>
|
||||
<strong>detect</strong> -{" "}
|
||||
{t("cameraWizard.step3.rolesPopover.detect")}
|
||||
</div>
|
||||
<div>
|
||||
<strong>record</strong> -{" "}
|
||||
{t("cameraWizard.step3.rolesPopover.record")}
|
||||
</div>
|
||||
<div>
|
||||
<strong>audio</strong> -{" "}
|
||||
{t("cameraWizard.step3.rolesPopover.audio")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/cameras")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["detect", "record", "audio"] as const).map((role) => {
|
||||
const isUsedElsewhere = getUsedRolesExcludingStream(
|
||||
stream.id,
|
||||
).has(role);
|
||||
const isChecked = stream.roles.includes(role);
|
||||
return (
|
||||
<div
|
||||
key={role}
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<span className="text-sm capitalize">{role}</span>
|
||||
<Switch
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleRole(stream.id, role)}
|
||||
disabled={!isChecked && isUsedElsewhere}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step3.featuresTitle")}
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
||||
<LuInfo className="size-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step3.featuresPopover.title")}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t("cameraWizard.step3.featuresPopover.description")}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl(
|
||||
"configuration/restream#reduce-connections-to-camera",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">
|
||||
{t("cameraWizard.step3.go2rtc")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={stream.restream || false}
|
||||
onCheckedChange={() => setRestream(stream.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addStream}
|
||||
variant="outline"
|
||||
className=""
|
||||
>
|
||||
<LuPlus className="mr-2 size-4" />
|
||||
{t("cameraWizard.step3.addAnotherStream")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!hasDetectRole && (
|
||||
<div className="rounded-lg border border-danger/50 p-3 text-sm text-danger">
|
||||
{t("cameraWizard.step3.detectRoleWarning")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
{onBack && (
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
{onNext && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onNext?.()}
|
||||
disabled={!canProceed || testingStreams.size > 0}
|
||||
variant="select"
|
||||
className="sm:flex-1"
|
||||
>
|
||||
{t("button.next", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -19,7 +19,7 @@ import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { Card, CardContent } from "../../ui/card";
|
||||
|
||||
type Step3ValidationProps = {
|
||||
type Step4ValidationProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||
onSave: (config: WizardFormData) => void;
|
||||
@ -27,13 +27,13 @@ type Step3ValidationProps = {
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export default function Step3Validation({
|
||||
export default function Step4Validation({
|
||||
wizardData,
|
||||
onUpdate,
|
||||
onSave,
|
||||
onBack,
|
||||
isLoading = false,
|
||||
}: Step3ValidationProps) {
|
||||
}: Step4ValidationProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
||||
@ -143,13 +143,13 @@ export default function Step3Validation({
|
||||
|
||||
if (testResult.success) {
|
||||
toast.success(
|
||||
t("cameraWizard.step3.streamValidated", {
|
||||
t("cameraWizard.step4.streamValidated", {
|
||||
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t("cameraWizard.step3.streamValidationFailed", {
|
||||
t("cameraWizard.step4.streamValidationFailed", {
|
||||
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
||||
}),
|
||||
);
|
||||
@ -200,16 +200,16 @@ export default function Step3Validation({
|
||||
(r) => r.success,
|
||||
).length;
|
||||
if (successfulTests === results.size) {
|
||||
toast.success(t("cameraWizard.step3.reconnectionSuccess"));
|
||||
toast.success(t("cameraWizard.step4.reconnectionSuccess"));
|
||||
} else {
|
||||
toast.warning(t("cameraWizard.step3.reconnectionPartial"));
|
||||
toast.warning(t("cameraWizard.step4.reconnectionPartial"));
|
||||
}
|
||||
}
|
||||
}, [streams, onUpdate, t, performStreamValidation]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!wizardData.cameraName || !wizardData.streams?.length) {
|
||||
toast.error(t("cameraWizard.step3.saveError"));
|
||||
toast.error(t("cameraWizard.step4.saveError"));
|
||||
return;
|
||||
}
|
||||
|
||||
@ -239,13 +239,13 @@ export default function Step3Validation({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("cameraWizard.step3.description")}
|
||||
{t("cameraWizard.step4.description")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("cameraWizard.step3.validationTitle")}
|
||||
{t("cameraWizard.step4.validationTitle")}
|
||||
</h3>
|
||||
<Button
|
||||
onClick={validateAllStreams}
|
||||
@ -254,8 +254,8 @@ export default function Step3Validation({
|
||||
>
|
||||
{isValidating && <ActivityIndicator className="mr-2 size-4" />}
|
||||
{isValidating
|
||||
? t("cameraWizard.step3.connecting")
|
||||
: t("cameraWizard.step3.connectAllStreams")}
|
||||
? t("cameraWizard.step4.connecting")
|
||||
: t("cameraWizard.step4.connectAllStreams")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -270,7 +270,7 @@ export default function Step3Validation({
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="flex flex-row items-center">
|
||||
<h4 className="mr-2 font-medium">
|
||||
{t("cameraWizard.step3.streamTitle", {
|
||||
{t("cameraWizard.step4.streamTitle", {
|
||||
number: index + 1,
|
||||
})}
|
||||
</h4>
|
||||
@ -331,7 +331,7 @@ export default function Step3Validation({
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">
|
||||
{t("cameraWizard.step3.ffmpegModule")}
|
||||
{t("cameraWizard.step4.ffmpegModule")}
|
||||
</span>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@ -346,11 +346,11 @@ export default function Step3Validation({
|
||||
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step3.ffmpegModule")}
|
||||
{t("cameraWizard.step4.ffmpegModule")}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t(
|
||||
"cameraWizard.step3.ffmpegModuleDescription",
|
||||
"cameraWizard.step4.ffmpegModuleDescription",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -402,17 +402,17 @@ export default function Step3Validation({
|
||||
<ActivityIndicator className="mr-2 size-4" />
|
||||
)}
|
||||
{result?.success
|
||||
? t("cameraWizard.step3.disconnectStream")
|
||||
? t("cameraWizard.step4.disconnectStream")
|
||||
: testingStreams.has(stream.id)
|
||||
? t("cameraWizard.step3.connectingStream")
|
||||
: t("cameraWizard.step3.connectStream")}
|
||||
? t("cameraWizard.step4.connectingStream")
|
||||
: t("cameraWizard.step4.connectStream")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs">
|
||||
{t("cameraWizard.step3.issues.title")}
|
||||
{t("cameraWizard.step4.issues.title")}
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<StreamIssues
|
||||
@ -455,7 +455,7 @@ export default function Step3Validation({
|
||||
{isLoading && <ActivityIndicator className="mr-2 size-4" />}
|
||||
{isLoading
|
||||
? t("button.saving", { ns: "common" })
|
||||
: t("cameraWizard.step3.saveAndApply")}
|
||||
: t("cameraWizard.step4.saveAndApply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -486,7 +486,7 @@ function StreamIssues({
|
||||
if (streamUrl.startsWith("rtsp://")) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step1.errors.brands.reolink-rtsp"),
|
||||
message: t("cameraWizard.step4.issues.brands.reolink-rtsp"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -497,7 +497,7 @@ function StreamIssues({
|
||||
if (["h264", "h265", "hevc"].includes(videoCodec)) {
|
||||
result.push({
|
||||
type: "good",
|
||||
message: t("cameraWizard.step3.issues.videoCodecGood", {
|
||||
message: t("cameraWizard.step4.issues.videoCodecGood", {
|
||||
codec: stream.testResult.videoCodec,
|
||||
}),
|
||||
});
|
||||
@ -511,20 +511,20 @@ function StreamIssues({
|
||||
if (audioCodec === "aac") {
|
||||
result.push({
|
||||
type: "good",
|
||||
message: t("cameraWizard.step3.issues.audioCodecGood", {
|
||||
message: t("cameraWizard.step4.issues.audioCodecGood", {
|
||||
codec: stream.testResult.audioCodec,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step3.issues.audioCodecRecordError"),
|
||||
message: t("cameraWizard.step4.issues.audioCodecRecordError"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.noAudioWarning"),
|
||||
message: t("cameraWizard.step4.issues.noAudioWarning"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -534,7 +534,7 @@ function StreamIssues({
|
||||
if (!stream.testResult?.audioCodec) {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step3.issues.audioCodecRequired"),
|
||||
message: t("cameraWizard.step4.issues.audioCodecRequired"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -544,7 +544,7 @@ function StreamIssues({
|
||||
if (stream.restream) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.restreamingWarning"),
|
||||
message: t("cameraWizard.step4.issues.restreamingWarning"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -557,14 +557,14 @@ function StreamIssues({
|
||||
if (minDimension > 1080) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.resolutionHigh", {
|
||||
message: t("cameraWizard.step4.issues.resolutionHigh", {
|
||||
resolution: stream.resolution,
|
||||
}),
|
||||
});
|
||||
} else if (maxDimension < 640) {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step3.issues.resolutionLow", {
|
||||
message: t("cameraWizard.step4.issues.resolutionLow", {
|
||||
resolution: stream.resolution,
|
||||
}),
|
||||
});
|
||||
@ -580,7 +580,7 @@ function StreamIssues({
|
||||
) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.dahua.substreamWarning"),
|
||||
message: t("cameraWizard.step4.issues.dahua.substreamWarning"),
|
||||
});
|
||||
}
|
||||
if (
|
||||
@ -590,7 +590,7 @@ function StreamIssues({
|
||||
) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.hikvision.substreamWarning"),
|
||||
message: t("cameraWizard.step4.issues.hikvision.substreamWarning"),
|
||||
});
|
||||
}
|
||||
|
||||
@ -662,7 +662,7 @@ function BandwidthDisplay({
|
||||
return (
|
||||
<div className="mb-2 text-sm">
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{t("cameraWizard.step3.estimatedBandwidth")}:
|
||||
{t("cameraWizard.step4.estimatedBandwidth")}:
|
||||
</span>{" "}
|
||||
<span className="text-secondary-foreground">
|
||||
{streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })}
|
||||
@ -748,7 +748,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
||||
style={{ aspectRatio }}
|
||||
>
|
||||
<span className="text-sm text-danger">
|
||||
{t("cameraWizard.step3.streamUnavailable")}
|
||||
{t("cameraWizard.step4.streamUnavailable")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -757,7 +757,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<LuRotateCcw className="size-4" />
|
||||
{t("cameraWizard.step3.reload")}
|
||||
{t("cameraWizard.step4.reload")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@ -771,7 +771,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
||||
>
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span className="ml-2 text-sm">
|
||||
{t("cameraWizard.step3.connecting")}
|
||||
{t("cameraWizard.step4.connecting")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@ -99,6 +99,11 @@ export type TestResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type CandidateTestMap = Record<
|
||||
string,
|
||||
TestResult | { success: false; error: string }
|
||||
>;
|
||||
|
||||
export type WizardFormData = {
|
||||
cameraName?: string;
|
||||
host?: string;
|
||||
@ -107,12 +112,17 @@ export type WizardFormData = {
|
||||
brandTemplate?: CameraBrand;
|
||||
customUrl?: string;
|
||||
streams?: StreamConfig[];
|
||||
probeMode?: boolean; // true for probe, false for manual
|
||||
onvifPort?: number;
|
||||
probeResult?: OnvifProbeResponse;
|
||||
probeCandidates?: string[]; // candidate URLs from probe
|
||||
candidateTests?: CandidateTestMap; // test results for candidates
|
||||
};
|
||||
|
||||
// API Response Types
|
||||
export type FfprobeResponse = {
|
||||
return_code: number;
|
||||
stderr: string;
|
||||
stderr: string | string[];
|
||||
stdout: FfprobeData | string;
|
||||
};
|
||||
|
||||
@ -167,3 +177,26 @@ export type ConfigSetBody = {
|
||||
config_data: CameraConfigData;
|
||||
update_topic?: string;
|
||||
};
|
||||
|
||||
export type OnvifRtspCandidate = {
|
||||
source: "GetStreamUri" | "pattern";
|
||||
profile_token?: string;
|
||||
uri: string;
|
||||
};
|
||||
|
||||
export type OnvifProbeResponse = {
|
||||
success: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
firmware_version?: string;
|
||||
profiles_count?: number;
|
||||
ptz_supported?: boolean;
|
||||
presets_count?: number;
|
||||
autotrack_supported?: boolean;
|
||||
move_status_supported?: boolean;
|
||||
rtsp_candidates?: OnvifRtspCandidate[];
|
||||
message?: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user