mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 13:34:13 +03:00
Add Camera Wizard (#20461)
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
* fetch more from ffprobe * add detailed param to ffprobe endpoint * add dots variant to step indicator * add classname * tweak colors for dark mode to match figma * add step 1 form * add helper function for ffmpeg snapshot * add go2rtc stream add and ffprobe snapshot endpoints * add camera image and stream details on successful test * step 1 tweaks * step 2 and i18n * types * step 1 and 2 tweaks * add wizard to camera settings view * add data unit i18n keys * restream tweak * fix type * implement rough idea for step 3 * add api endpoint to delete stream from go2rtc * add main wizard dialog component * extract logic for friendly_name and use in wizard * add i18n and popover for brand url * add camera name to top * consolidate validation logic * prevent dialog from closing when clicking outside * center camera name on mobile * add help/docs link popovers * keep spaces in friendly name * add stream details to overlay like stats in liveplayer * add validation results pane to step 3 * ensure test is invalidated if stream is changed * only display validation results and enable save button if all streams have been tested * tweaks * normalize camera name to lower case and improve hash generation * move wizard to subfolder * tweaks * match look of camera edit form to wizard * move wizard and edit form to its own component * move enabled/disabled switch to management section * clean up * fixes * fix mobile
This commit is contained in:
parent
423693d14d
commit
9d85136f8f
@ -43,6 +43,7 @@ from frigate.util.builtin import (
|
|||||||
update_yaml_file_bulk,
|
update_yaml_file_bulk,
|
||||||
)
|
)
|
||||||
from frigate.util.config import find_config_file
|
from frigate.util.config import find_config_file
|
||||||
|
from frigate.util.image import run_ffmpeg_snapshot
|
||||||
from frigate.util.services import (
|
from frigate.util.services import (
|
||||||
ffprobe_stream,
|
ffprobe_stream,
|
||||||
get_nvidia_driver_info,
|
get_nvidia_driver_info,
|
||||||
@ -107,6 +108,80 @@ def go2rtc_camera_stream(request: Request, camera_name: str):
|
|||||||
return JSONResponse(content=stream_data)
|
return JSONResponse(content=stream_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/go2rtc/streams/{stream_name}", dependencies=[Depends(require_role(["admin"]))]
|
||||||
|
)
|
||||||
|
def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
|
||||||
|
"""Add or update a go2rtc stream configuration."""
|
||||||
|
try:
|
||||||
|
params = {"name": stream_name}
|
||||||
|
if src:
|
||||||
|
params["src"] = src
|
||||||
|
|
||||||
|
r = requests.put(
|
||||||
|
"http://127.0.0.1:1984/api/streams",
|
||||||
|
params=params,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if not r.ok:
|
||||||
|
logger.error(f"Failed to add go2rtc stream {stream_name}: {r.text}")
|
||||||
|
return JSONResponse(
|
||||||
|
content=(
|
||||||
|
{"success": False, "message": f"Failed to add stream: {r.text}"}
|
||||||
|
),
|
||||||
|
status_code=r.status_code,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": True, "message": "Stream added successfully"}
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(f"Error communicating with go2rtc: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
content=(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"message": "Error communicating with go2rtc",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/go2rtc/streams/{stream_name}", dependencies=[Depends(require_role(["admin"]))]
|
||||||
|
)
|
||||||
|
def go2rtc_delete_stream(stream_name: str):
|
||||||
|
"""Delete a go2rtc stream."""
|
||||||
|
try:
|
||||||
|
r = requests.delete(
|
||||||
|
"http://127.0.0.1:1984/api/streams",
|
||||||
|
params={"src": stream_name},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if not r.ok:
|
||||||
|
logger.error(f"Failed to delete go2rtc stream {stream_name}: {r.text}")
|
||||||
|
return JSONResponse(
|
||||||
|
content=(
|
||||||
|
{"success": False, "message": f"Failed to delete stream: {r.text}"}
|
||||||
|
),
|
||||||
|
status_code=r.status_code,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": True, "message": "Stream deleted successfully"}
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(f"Error communicating with go2rtc: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
content=(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"message": "Error communicating with go2rtc",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/version", response_class=PlainTextResponse)
|
@router.get("/version", response_class=PlainTextResponse)
|
||||||
def version():
|
def version():
|
||||||
return VERSION
|
return VERSION
|
||||||
@ -453,7 +528,7 @@ def config_set(request: Request, body: AppConfigSetBody):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/ffprobe")
|
@router.get("/ffprobe")
|
||||||
def ffprobe(request: Request, paths: str = ""):
|
def ffprobe(request: Request, paths: str = "", detailed: bool = False):
|
||||||
path_param = paths
|
path_param = paths
|
||||||
|
|
||||||
if not path_param:
|
if not path_param:
|
||||||
@ -492,26 +567,132 @@ def ffprobe(request: Request, paths: str = ""):
|
|||||||
output = []
|
output = []
|
||||||
|
|
||||||
for path in paths:
|
for path in paths:
|
||||||
ffprobe = ffprobe_stream(request.app.frigate_config.ffmpeg, path.strip())
|
ffprobe = ffprobe_stream(
|
||||||
output.append(
|
request.app.frigate_config.ffmpeg, path.strip(), detailed=detailed
|
||||||
{
|
|
||||||
"return_code": ffprobe.returncode,
|
|
||||||
"stderr": (
|
|
||||||
ffprobe.stderr.decode("unicode_escape").strip()
|
|
||||||
if ffprobe.returncode != 0
|
|
||||||
else ""
|
|
||||||
),
|
|
||||||
"stdout": (
|
|
||||||
json.loads(ffprobe.stdout.decode("unicode_escape").strip())
|
|
||||||
if ffprobe.returncode == 0
|
|
||||||
else ""
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"return_code": ffprobe.returncode,
|
||||||
|
"stderr": (
|
||||||
|
ffprobe.stderr.decode("unicode_escape").strip()
|
||||||
|
if ffprobe.returncode != 0
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"stdout": (
|
||||||
|
json.loads(ffprobe.stdout.decode("unicode_escape").strip())
|
||||||
|
if ffprobe.returncode == 0
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add detailed metadata if requested and probe was successful
|
||||||
|
if detailed and ffprobe.returncode == 0 and result["stdout"]:
|
||||||
|
try:
|
||||||
|
probe_data = result["stdout"]
|
||||||
|
metadata = {}
|
||||||
|
|
||||||
|
# Extract video stream information
|
||||||
|
video_stream = None
|
||||||
|
audio_stream = None
|
||||||
|
|
||||||
|
for stream in probe_data.get("streams", []):
|
||||||
|
if stream.get("codec_type") == "video":
|
||||||
|
video_stream = stream
|
||||||
|
elif stream.get("codec_type") == "audio":
|
||||||
|
audio_stream = stream
|
||||||
|
|
||||||
|
# Video metadata
|
||||||
|
if video_stream:
|
||||||
|
metadata["video"] = {
|
||||||
|
"codec": video_stream.get("codec_name"),
|
||||||
|
"width": video_stream.get("width"),
|
||||||
|
"height": video_stream.get("height"),
|
||||||
|
"fps": _extract_fps(video_stream.get("r_frame_rate")),
|
||||||
|
"pixel_format": video_stream.get("pix_fmt"),
|
||||||
|
"profile": video_stream.get("profile"),
|
||||||
|
"level": video_stream.get("level"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate resolution string
|
||||||
|
if video_stream.get("width") and video_stream.get("height"):
|
||||||
|
metadata["video"]["resolution"] = (
|
||||||
|
f"{video_stream['width']}x{video_stream['height']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Audio metadata
|
||||||
|
if audio_stream:
|
||||||
|
metadata["audio"] = {
|
||||||
|
"codec": audio_stream.get("codec_name"),
|
||||||
|
"channels": audio_stream.get("channels"),
|
||||||
|
"sample_rate": audio_stream.get("sample_rate"),
|
||||||
|
"channel_layout": audio_stream.get("channel_layout"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Container/format metadata
|
||||||
|
if probe_data.get("format"):
|
||||||
|
format_info = probe_data["format"]
|
||||||
|
metadata["container"] = {
|
||||||
|
"format": format_info.get("format_name"),
|
||||||
|
"duration": format_info.get("duration"),
|
||||||
|
"size": format_info.get("size"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result["metadata"] = metadata
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to extract detailed metadata: {e}")
|
||||||
|
# Continue without metadata if parsing fails
|
||||||
|
|
||||||
|
output.append(result)
|
||||||
|
|
||||||
return JSONResponse(content=output)
|
return JSONResponse(content=output)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ffprobe/snapshot", dependencies=[Depends(require_role(["admin"]))])
|
||||||
|
def ffprobe_snapshot(request: Request, url: str = "", timeout: int = 10):
|
||||||
|
"""Get a snapshot from a stream URL using ffmpeg."""
|
||||||
|
if not url:
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": False, "message": "URL parameter is required"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
config: FrigateConfig = request.app.frigate_config
|
||||||
|
|
||||||
|
image_data, error = run_ffmpeg_snapshot(
|
||||||
|
config.ffmpeg, url, "mjpeg", timeout=timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
if image_data:
|
||||||
|
return Response(
|
||||||
|
image_data,
|
||||||
|
media_type="image/jpeg",
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
elif error == "timeout":
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": False, "message": "Timeout capturing snapshot"},
|
||||||
|
status_code=408,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(f"ffmpeg failed: {error}")
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": False, "message": "Failed to capture snapshot"},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_fps(r_frame_rate: str) -> float | None:
|
||||||
|
"""Extract FPS from ffprobe r_frame_rate string (e.g., '30/1' -> 30.0)"""
|
||||||
|
if not r_frame_rate:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
num, den = r_frame_rate.split("/")
|
||||||
|
return round(float(num) / float(den), 2)
|
||||||
|
except (ValueError, ZeroDivisionError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/vainfo")
|
@router.get("/vainfo")
|
||||||
def vainfo():
|
def vainfo():
|
||||||
vainfo = vainfo_hwaccel()
|
vainfo = vainfo_hwaccel()
|
||||||
|
|||||||
@ -943,6 +943,58 @@ def add_mask(mask: str, mask_img: np.ndarray):
|
|||||||
cv2.fillPoly(mask_img, pts=[contour], color=(0))
|
cv2.fillPoly(mask_img, pts=[contour], color=(0))
|
||||||
|
|
||||||
|
|
||||||
|
def run_ffmpeg_snapshot(
|
||||||
|
ffmpeg,
|
||||||
|
input_path: str,
|
||||||
|
codec: str,
|
||||||
|
seek_time: Optional[float] = None,
|
||||||
|
height: Optional[int] = None,
|
||||||
|
timeout: Optional[int] = None,
|
||||||
|
) -> tuple[Optional[bytes], str]:
|
||||||
|
"""Run ffmpeg to extract a snapshot/image from a video source."""
|
||||||
|
ffmpeg_cmd = [
|
||||||
|
ffmpeg.ffmpeg_path,
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"warning",
|
||||||
|
]
|
||||||
|
|
||||||
|
if seek_time is not None:
|
||||||
|
ffmpeg_cmd.extend(["-ss", f"00:00:{seek_time}"])
|
||||||
|
|
||||||
|
ffmpeg_cmd.extend(
|
||||||
|
[
|
||||||
|
"-i",
|
||||||
|
input_path,
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-c:v",
|
||||||
|
codec,
|
||||||
|
"-f",
|
||||||
|
"image2pipe",
|
||||||
|
"-",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if height is not None:
|
||||||
|
ffmpeg_cmd.insert(-3, "-vf")
|
||||||
|
ffmpeg_cmd.insert(-3, f"scale=-1:{height}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = sp.run(
|
||||||
|
ffmpeg_cmd,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
if process.returncode == 0 and process.stdout:
|
||||||
|
return process.stdout, ""
|
||||||
|
else:
|
||||||
|
return None, process.stderr.decode() if process.stderr else "ffmpeg failed"
|
||||||
|
except sp.TimeoutExpired:
|
||||||
|
return None, "timeout"
|
||||||
|
|
||||||
|
|
||||||
def get_image_from_recording(
|
def get_image_from_recording(
|
||||||
ffmpeg, # Ffmpeg Config
|
ffmpeg, # Ffmpeg Config
|
||||||
file_path: str,
|
file_path: str,
|
||||||
@ -952,37 +1004,11 @@ def get_image_from_recording(
|
|||||||
) -> Optional[Any]:
|
) -> Optional[Any]:
|
||||||
"""retrieve a frame from given time in recording file."""
|
"""retrieve a frame from given time in recording file."""
|
||||||
|
|
||||||
ffmpeg_cmd = [
|
image_data, _ = run_ffmpeg_snapshot(
|
||||||
ffmpeg.ffmpeg_path,
|
ffmpeg, file_path, codec, seek_time=relative_frame_time, height=height
|
||||||
"-hide_banner",
|
|
||||||
"-loglevel",
|
|
||||||
"warning",
|
|
||||||
"-ss",
|
|
||||||
f"00:00:{relative_frame_time}",
|
|
||||||
"-i",
|
|
||||||
file_path,
|
|
||||||
"-frames:v",
|
|
||||||
"1",
|
|
||||||
"-c:v",
|
|
||||||
codec,
|
|
||||||
"-f",
|
|
||||||
"image2pipe",
|
|
||||||
"-",
|
|
||||||
]
|
|
||||||
|
|
||||||
if height is not None:
|
|
||||||
ffmpeg_cmd.insert(-3, "-vf")
|
|
||||||
ffmpeg_cmd.insert(-3, f"scale=-1:{height}")
|
|
||||||
|
|
||||||
process = sp.run(
|
|
||||||
ffmpeg_cmd,
|
|
||||||
capture_output=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if process.returncode == 0:
|
return image_data
|
||||||
return process.stdout
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_histogram(image, x_min, y_min, x_max, y_max):
|
def get_histogram(image, x_min, y_min, x_max, y_max):
|
||||||
|
|||||||
@ -515,9 +515,20 @@ def get_jetson_stats() -> Optional[dict[int, dict]]:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def ffprobe_stream(ffmpeg, path: str) -> sp.CompletedProcess:
|
def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
|
||||||
"""Run ffprobe on stream."""
|
"""Run ffprobe on stream."""
|
||||||
clean_path = escape_special_characters(path)
|
clean_path = escape_special_characters(path)
|
||||||
|
|
||||||
|
# Base entries that are always included
|
||||||
|
stream_entries = "codec_long_name,width,height,bit_rate,duration,display_aspect_ratio,avg_frame_rate"
|
||||||
|
|
||||||
|
# Additional detailed entries
|
||||||
|
if detailed:
|
||||||
|
stream_entries += ",codec_name,profile,level,pix_fmt,channels,sample_rate,channel_layout,r_frame_rate"
|
||||||
|
format_entries = "format_name,size,bit_rate,duration"
|
||||||
|
else:
|
||||||
|
format_entries = None
|
||||||
|
|
||||||
ffprobe_cmd = [
|
ffprobe_cmd = [
|
||||||
ffmpeg.ffprobe_path,
|
ffmpeg.ffprobe_path,
|
||||||
"-timeout",
|
"-timeout",
|
||||||
@ -525,11 +536,15 @@ def ffprobe_stream(ffmpeg, path: str) -> sp.CompletedProcess:
|
|||||||
"-print_format",
|
"-print_format",
|
||||||
"json",
|
"json",
|
||||||
"-show_entries",
|
"-show_entries",
|
||||||
"stream=codec_long_name,width,height,bit_rate,duration,display_aspect_ratio,avg_frame_rate",
|
f"stream={stream_entries}",
|
||||||
"-loglevel",
|
|
||||||
"quiet",
|
|
||||||
clean_path,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Add format entries for detailed mode
|
||||||
|
if detailed and format_entries:
|
||||||
|
ffprobe_cmd.extend(["-show_entries", f"format={format_entries}"])
|
||||||
|
|
||||||
|
ffprobe_cmd.extend(["-loglevel", "quiet", clean_path])
|
||||||
|
|
||||||
return sp.run(ffprobe_cmd, capture_output=True)
|
return sp.run(ffprobe_cmd, capture_output=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -82,6 +82,14 @@
|
|||||||
"length": {
|
"length": {
|
||||||
"feet": "feet",
|
"feet": "feet",
|
||||||
"meters": "meters"
|
"meters": "meters"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"kbps": "kB/s",
|
||||||
|
"mbps": "MB/s",
|
||||||
|
"gbps": "GB/s",
|
||||||
|
"kbph": "kB/hour",
|
||||||
|
"mbph": "MB/hour",
|
||||||
|
"gbph": "GB/hour"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
"documentTitle": {
|
"documentTitle": {
|
||||||
"default": "Settings - Frigate",
|
"default": "Settings - Frigate",
|
||||||
"authentication": "Authentication Settings - Frigate",
|
"authentication": "Authentication Settings - Frigate",
|
||||||
"camera": "Camera Settings - Frigate",
|
"cameraManagement": "Manage Cameras - Frigate",
|
||||||
|
"cameraReview": "Camera Review Settings - Frigate",
|
||||||
"enrichments": "Enrichments Settings - Frigate",
|
"enrichments": "Enrichments Settings - Frigate",
|
||||||
"masksAndZones": "Mask and Zone Editor - Frigate",
|
"masksAndZones": "Mask and Zone Editor - Frigate",
|
||||||
"motionTuner": "Motion Tuner - Frigate",
|
"motionTuner": "Motion Tuner - Frigate",
|
||||||
@ -14,7 +15,8 @@
|
|||||||
"menu": {
|
"menu": {
|
||||||
"ui": "UI",
|
"ui": "UI",
|
||||||
"enrichments": "Enrichments",
|
"enrichments": "Enrichments",
|
||||||
"cameras": "Camera Settings",
|
"cameraManagement": "Management",
|
||||||
|
"cameraReview": "Review",
|
||||||
"masksAndZones": "Masks / Zones",
|
"masksAndZones": "Masks / Zones",
|
||||||
"motionTuner": "Motion Tuner",
|
"motionTuner": "Motion Tuner",
|
||||||
"triggers": "Triggers",
|
"triggers": "Triggers",
|
||||||
@ -143,12 +145,176 @@
|
|||||||
"error": "Failed to save config changes: {{errorMessage}}"
|
"error": "Failed to save config changes: {{errorMessage}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"camera": {
|
"cameraWizard": {
|
||||||
"title": "Camera Settings",
|
"title": "Add Camera",
|
||||||
|
"description": "Follow the steps below to add a new camera to your Frigate installation.",
|
||||||
|
"steps": {
|
||||||
|
"nameAndConnection": "Name & Connection",
|
||||||
|
"streamConfiguration": "Stream Configuration",
|
||||||
|
"validationAndTesting": "Validation & Testing"
|
||||||
|
},
|
||||||
|
"save": {
|
||||||
|
"success": "Successfully saved new camera {{cameraName}}.",
|
||||||
|
"failure": "Error saving {{cameraName}}."
|
||||||
|
},
|
||||||
|
"testResultLabels": {
|
||||||
|
"resolution": "Resolution",
|
||||||
|
"video": "Video",
|
||||||
|
"audio": "Audio",
|
||||||
|
"fps": "FPS"
|
||||||
|
},
|
||||||
|
"commonErrors": {
|
||||||
|
"noUrl": "Please provide a valid stream URL",
|
||||||
|
"testFailed": "Stream test failed: {{error}}"
|
||||||
|
},
|
||||||
|
"step1": {
|
||||||
|
"description": "Enter your camera details and test the connection.",
|
||||||
|
"cameraName": "Camera Name",
|
||||||
|
"cameraNamePlaceholder": "e.g., front_door or Back Yard Overview",
|
||||||
|
"host": "Host/IP Address",
|
||||||
|
"port": "Port",
|
||||||
|
"username": "Username",
|
||||||
|
"usernamePlaceholder": "Optional",
|
||||||
|
"password": "Password",
|
||||||
|
"passwordPlaceholder": "Optional",
|
||||||
|
"selectTransport": "Select transport protocol",
|
||||||
|
"cameraBrand": "Camera Brand",
|
||||||
|
"selectBrand": "Select camera brand for URL template",
|
||||||
|
"customUrl": "Custom Stream URL",
|
||||||
|
"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",
|
||||||
|
"warnings": {
|
||||||
|
"noSnapshot": "Unable to fetch a snapshot from the configured stream."
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"step2": {
|
||||||
|
"description": "Configure stream roles and add additional streams for your camera.",
|
||||||
|
"streamsTitle": "Camera Streams",
|
||||||
|
"addStream": "Add Stream",
|
||||||
|
"addAnotherStream": "Add Another Stream",
|
||||||
|
"streamTitle": "Stream {{number}}",
|
||||||
|
"streamUrl": "Stream URL",
|
||||||
|
"streamUrlPlaceholder": "rtsp://username:password@host:port/path",
|
||||||
|
"url": "URL",
|
||||||
|
"resolution": "Resolution",
|
||||||
|
"selectResolution": "Select resolution",
|
||||||
|
"quality": "Quality",
|
||||||
|
"selectQuality": "Select quality",
|
||||||
|
"roles": "Roles",
|
||||||
|
"roleLabels": {
|
||||||
|
"detect": "Object Detection",
|
||||||
|
"record": "Recording",
|
||||||
|
"audio": "Audio"
|
||||||
|
},
|
||||||
|
"testStream": "Test Connection",
|
||||||
|
"testSuccess": "Stream test successful!",
|
||||||
|
"testFailed": "Stream test failed",
|
||||||
|
"testFailedTitle": "Test Failed",
|
||||||
|
"connected": "Connected",
|
||||||
|
"notConnected": "Not Connected",
|
||||||
|
"featuresTitle": "Features",
|
||||||
|
"go2rtc": "Reduce connections to camera",
|
||||||
|
"detectRoleWarning": "At least one stream must have the \"detect\" role to proceed.",
|
||||||
|
"rolesPopover": {
|
||||||
|
"title": "Stream Roles",
|
||||||
|
"detect": "Main feed for object detection.",
|
||||||
|
"record": "Saves segments of the video feed based on configuration settings.",
|
||||||
|
"audio": "Feed for audio based detection."
|
||||||
|
},
|
||||||
|
"featuresPopover": {
|
||||||
|
"title": "Stream Features",
|
||||||
|
"description": "Use go2rtc restreaming to reduce connections to your camera."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"step3": {
|
||||||
|
"description": "Final validation and analysis before saving your new camera. Connect each stream before saving.",
|
||||||
|
"validationTitle": "Stream Validation",
|
||||||
|
"connectAllStreams": "Connect All Streams",
|
||||||
|
"reconnectionSuccess": "Reconnection successful.",
|
||||||
|
"reconnectionPartial": "Some streams failed to reconnect.",
|
||||||
|
"streamUnavailable": "Stream preview unavailable",
|
||||||
|
"reload": "Reload",
|
||||||
|
"connecting": "Connecting...",
|
||||||
|
"streamTitle": "Stream {{number}}",
|
||||||
|
"valid": "Valid",
|
||||||
|
"failed": "Failed",
|
||||||
|
"notTested": "Not tested",
|
||||||
|
"connectStream": "Connect",
|
||||||
|
"connectingStream": "Connecting",
|
||||||
|
"disconnectStream": "Disconnect",
|
||||||
|
"estimatedBandwidth": "Estimated Bandwidth",
|
||||||
|
"roles": "Roles",
|
||||||
|
"none": "None",
|
||||||
|
"error": "Error",
|
||||||
|
"streamValidated": "Stream {{number}} validated successfully",
|
||||||
|
"streamValidationFailed": "Stream {{number}} validation failed",
|
||||||
|
"saveAndApply": "Save New Camera",
|
||||||
|
"saveError": "Invalid configuration. Please check your settings.",
|
||||||
|
"issues": {
|
||||||
|
"title": "Stream Validation",
|
||||||
|
"videoCodecGood": "Video codec is {{codec}}.",
|
||||||
|
"audioCodecGood": "Audio codec is {{codec}}.",
|
||||||
|
"noAudioWarning": "No audio detected for this stream, recordings will not have audio.",
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cameraManagement": {
|
||||||
|
"title": "Manage Cameras",
|
||||||
|
"addCamera": "Add New Camera",
|
||||||
|
"editCamera": "Edit Camera:",
|
||||||
|
"selectCamera": "Select a Camera",
|
||||||
|
"backToSettings": "Back to Camera Settings",
|
||||||
"streams": {
|
"streams": {
|
||||||
"title": "Streams",
|
"title": "Enable / Disable Cameras",
|
||||||
"desc": "Temporarily disable a camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em>"
|
"desc": "Temporarily disable a camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em>"
|
||||||
},
|
},
|
||||||
|
"cameraConfig": {
|
||||||
|
"add": "Add Camera",
|
||||||
|
"edit": "Edit Camera",
|
||||||
|
"description": "Configure camera settings including stream inputs and roles.",
|
||||||
|
"name": "Camera Name",
|
||||||
|
"nameRequired": "Camera name is required",
|
||||||
|
"nameLength": "Camera name must be less than 64 characters.",
|
||||||
|
"namePlaceholder": "e.g., front_door or Back Yard Overview",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": "Input Streams",
|
||||||
|
"path": "Stream Path",
|
||||||
|
"pathRequired": "Stream path is required",
|
||||||
|
"pathPlaceholder": "rtsp://...",
|
||||||
|
"roles": "Roles",
|
||||||
|
"rolesRequired": "At least one role is required",
|
||||||
|
"rolesUnique": "Each role (audio, detect, record) can only be assigned to one stream",
|
||||||
|
"addInput": "Add Input Stream",
|
||||||
|
"removeInput": "Remove Input Stream",
|
||||||
|
"inputsRequired": "At least one input stream is required"
|
||||||
|
},
|
||||||
|
"go2rtcStreams": "go2rtc Streams",
|
||||||
|
"streamUrls": "Stream URLs",
|
||||||
|
"addUrl": "Add URL",
|
||||||
|
"addGo2rtcStream": "Add go2rtc Stream",
|
||||||
|
"toast": {
|
||||||
|
"success": "Camera {{cameraName}} saved successfully"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cameraReview": {
|
||||||
|
"title": "Camera Review Settings",
|
||||||
"object_descriptions": {
|
"object_descriptions": {
|
||||||
"title": "Generative AI Object Descriptions",
|
"title": "Generative AI Object Descriptions",
|
||||||
"desc": "Temporarily enable/disable Generative AI object descriptions for this camera. When disabled, AI generated descriptions will not be requested for tracked objects on this camera."
|
"desc": "Temporarily enable/disable Generative AI object descriptions for this camera. When disabled, AI generated descriptions will not be requested for tracked objects on this camera."
|
||||||
@ -183,35 +349,6 @@
|
|||||||
"toast": {
|
"toast": {
|
||||||
"success": "Review Classification configuration has been saved. Restart Frigate to apply changes."
|
"success": "Review Classification configuration has been saved. Restart Frigate to apply changes."
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"addCamera": "Add New Camera",
|
|
||||||
"editCamera": "Edit Camera:",
|
|
||||||
"selectCamera": "Select a Camera",
|
|
||||||
"backToSettings": "Back to Camera Settings",
|
|
||||||
"cameraConfig": {
|
|
||||||
"add": "Add Camera",
|
|
||||||
"edit": "Edit Camera",
|
|
||||||
"description": "Configure camera settings including stream inputs and roles.",
|
|
||||||
"name": "Camera Name",
|
|
||||||
"nameRequired": "Camera name is required",
|
|
||||||
"nameLength": "Camera name must be less than 24 characters.",
|
|
||||||
"namePlaceholder": "e.g., front_door",
|
|
||||||
"enabled": "Enabled",
|
|
||||||
"ffmpeg": {
|
|
||||||
"inputs": "Input Streams",
|
|
||||||
"path": "Stream Path",
|
|
||||||
"pathRequired": "Stream path is required",
|
|
||||||
"pathPlaceholder": "rtsp://...",
|
|
||||||
"roles": "Roles",
|
|
||||||
"rolesRequired": "At least one role is required",
|
|
||||||
"rolesUnique": "Each role (audio, detect, record) can only be assigned to one stream",
|
|
||||||
"addInput": "Add Input Stream",
|
|
||||||
"removeInput": "Remove Input Stream",
|
|
||||||
"inputsRequired": "At least one input stream is required"
|
|
||||||
},
|
|
||||||
"toast": {
|
|
||||||
"success": "Camera {{cameraName}} saved successfully"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"masksAndZones": {
|
"masksAndZones": {
|
||||||
|
|||||||
@ -4,17 +4,43 @@ import { useTranslation } from "react-i18next";
|
|||||||
type StepIndicatorProps = {
|
type StepIndicatorProps = {
|
||||||
steps: string[];
|
steps: string[];
|
||||||
currentStep: number;
|
currentStep: number;
|
||||||
translationNameSpace: string;
|
variant?: "default" | "dots";
|
||||||
|
translationNameSpace?: string;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function StepIndicator({
|
export default function StepIndicator({
|
||||||
steps,
|
steps,
|
||||||
currentStep,
|
currentStep,
|
||||||
|
variant = "default",
|
||||||
translationNameSpace,
|
translationNameSpace,
|
||||||
|
className,
|
||||||
}: StepIndicatorProps) {
|
}: StepIndicatorProps) {
|
||||||
const { t } = useTranslation(translationNameSpace);
|
const { t } = useTranslation(translationNameSpace);
|
||||||
|
|
||||||
|
if (variant == "dots") {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-row justify-center gap-2", className)}>
|
||||||
|
{steps.map((_, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={cn(
|
||||||
|
"size-3 rounded-full border border-primary/10 transition-colors",
|
||||||
|
currentStep === idx
|
||||||
|
? "bg-selected"
|
||||||
|
: currentStep > idx
|
||||||
|
? "bg-muted-foreground"
|
||||||
|
: "bg-muted",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default variant (original behavior)
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row justify-evenly">
|
<div className={cn("flex flex-row justify-evenly", className)}>
|
||||||
{steps.map((name, idx) => (
|
{steps.map((name, idx) => (
|
||||||
<div key={idx} className="flex flex-col items-center gap-2">
|
<div key={idx} className="flex flex-col items-center gap-2">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@ -22,20 +23,9 @@ import { LuTrash2, LuPlus } from "react-icons/lu";
|
|||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import { processCameraName } from "@/utils/cameraUtil";
|
||||||
type ConfigSetBody = {
|
import { Label } from "@/components/ui/label";
|
||||||
requires_restart: number;
|
import { ConfigSetBody } from "@/types/cameraWizard";
|
||||||
// TODO: type this better
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
config_data: any;
|
|
||||||
update_topic?: string;
|
|
||||||
};
|
|
||||||
const generateFixedHash = (name: string): string => {
|
|
||||||
const encoded = encodeURIComponent(name);
|
|
||||||
const base64 = btoa(encoded);
|
|
||||||
const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
|
|
||||||
return `cam_${cleanHash.toLowerCase()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const RoleEnum = z.enum(["audio", "detect", "record"]);
|
const RoleEnum = z.enum(["audio", "detect", "record"]);
|
||||||
type Role = z.infer<typeof RoleEnum>;
|
type Role = z.infer<typeof RoleEnum>;
|
||||||
@ -60,22 +50,26 @@ export default function CameraEditForm({
|
|||||||
z.object({
|
z.object({
|
||||||
cameraName: z
|
cameraName: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, { message: t("camera.cameraConfig.nameRequired") }),
|
.min(1, { message: t("cameraManagement.cameraConfig.nameRequired") }),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
ffmpeg: z.object({
|
ffmpeg: z.object({
|
||||||
inputs: z
|
inputs: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
path: z.string().min(1, {
|
path: z.string().min(1, {
|
||||||
message: t("camera.cameraConfig.ffmpeg.pathRequired"),
|
message: t(
|
||||||
|
"cameraManagement.cameraConfig.ffmpeg.pathRequired",
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
roles: z.array(RoleEnum).min(1, {
|
roles: z.array(RoleEnum).min(1, {
|
||||||
message: t("camera.cameraConfig.ffmpeg.rolesRequired"),
|
message: t(
|
||||||
|
"cameraManagement.cameraConfig.ffmpeg.rolesRequired",
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.min(1, {
|
.min(1, {
|
||||||
message: t("camera.cameraConfig.ffmpeg.inputsRequired"),
|
message: t("cameraManagement.cameraConfig.ffmpeg.inputsRequired"),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(inputs) => {
|
(inputs) => {
|
||||||
@ -93,11 +87,12 @@ export default function CameraEditForm({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: t("camera.cameraConfig.ffmpeg.rolesUnique"),
|
message: t("cameraManagement.cameraConfig.ffmpeg.rolesUnique"),
|
||||||
path: ["inputs"],
|
path: ["inputs"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
go2rtcStreams: z.record(z.string(), z.array(z.string())).optional(),
|
||||||
}),
|
}),
|
||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
@ -110,6 +105,7 @@ export default function CameraEditForm({
|
|||||||
friendly_name: undefined,
|
friendly_name: undefined,
|
||||||
name: cameraName || "",
|
name: cameraName || "",
|
||||||
roles: new Set<Role>(),
|
roles: new Set<Role>(),
|
||||||
|
go2rtcStreams: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,10 +116,14 @@ export default function CameraEditForm({
|
|||||||
input.roles.forEach((role) => roles.add(role as Role));
|
input.roles.forEach((role) => roles.add(role as Role));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load existing go2rtc streams
|
||||||
|
const go2rtcStreams = config.go2rtc?.streams || {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
friendly_name: camera?.friendly_name || cameraName,
|
friendly_name: camera?.friendly_name || cameraName,
|
||||||
name: cameraName,
|
name: cameraName,
|
||||||
roles,
|
roles,
|
||||||
|
go2rtcStreams,
|
||||||
};
|
};
|
||||||
}, [cameraName, config]);
|
}, [cameraName, config]);
|
||||||
|
|
||||||
@ -138,6 +138,7 @@ export default function CameraEditForm({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
go2rtcStreams: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load existing camera config if editing
|
// Load existing camera config if editing
|
||||||
@ -150,6 +151,41 @@ export default function CameraEditForm({
|
|||||||
roles: input.roles as Role[],
|
roles: input.roles as Role[],
|
||||||
}))
|
}))
|
||||||
: defaultValues.ffmpeg.inputs;
|
: defaultValues.ffmpeg.inputs;
|
||||||
|
|
||||||
|
// Load go2rtc streams for this camera
|
||||||
|
const go2rtcStreams = config.go2rtc?.streams || {};
|
||||||
|
const cameraStreams: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
// Find streams that match this camera's name pattern
|
||||||
|
Object.entries(go2rtcStreams).forEach(([streamName, urls]) => {
|
||||||
|
if (streamName.startsWith(cameraName) || streamName === cameraName) {
|
||||||
|
cameraStreams[streamName] = Array.isArray(urls) ? urls : [urls];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also deduce go2rtc streams from restream URLs in camera inputs
|
||||||
|
camera.ffmpeg?.inputs?.forEach((input, index) => {
|
||||||
|
const restreamMatch = input.path.match(
|
||||||
|
/^rtsp:\/\/127\.0\.0\.1:8554\/(.+)$/,
|
||||||
|
);
|
||||||
|
if (restreamMatch) {
|
||||||
|
const streamName = restreamMatch[1];
|
||||||
|
// Find the corresponding go2rtc stream
|
||||||
|
const go2rtcStream = Object.entries(go2rtcStreams).find(
|
||||||
|
([name]) =>
|
||||||
|
name === streamName ||
|
||||||
|
name === `${cameraName}_${index + 1}` ||
|
||||||
|
name === cameraName,
|
||||||
|
);
|
||||||
|
if (go2rtcStream) {
|
||||||
|
cameraStreams[go2rtcStream[0]] = Array.isArray(go2rtcStream[1])
|
||||||
|
? go2rtcStream[1]
|
||||||
|
: [go2rtcStream[1]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
defaultValues.go2rtcStreams = cameraStreams;
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
@ -166,21 +202,20 @@ export default function CameraEditForm({
|
|||||||
// Watch ffmpeg.inputs to track used roles
|
// Watch ffmpeg.inputs to track used roles
|
||||||
const watchedInputs = form.watch("ffmpeg.inputs");
|
const watchedInputs = form.watch("ffmpeg.inputs");
|
||||||
|
|
||||||
|
// Watch go2rtc streams
|
||||||
|
const watchedGo2rtcStreams = form.watch("go2rtcStreams") || {};
|
||||||
|
|
||||||
const saveCameraConfig = (values: FormValues) => {
|
const saveCameraConfig = (values: FormValues) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
let finalCameraName = values.cameraName;
|
const { finalCameraName, friendlyName } = processCameraName(
|
||||||
let friendly_name: string | undefined = undefined;
|
values.cameraName,
|
||||||
const isValidName = /^[a-zA-Z0-9_-]+$/.test(values.cameraName);
|
);
|
||||||
if (!isValidName) {
|
|
||||||
finalCameraName = generateFixedHash(finalCameraName);
|
|
||||||
friendly_name = values.cameraName;
|
|
||||||
}
|
|
||||||
|
|
||||||
const configData: ConfigSetBody["config_data"] = {
|
const configData: ConfigSetBody["config_data"] = {
|
||||||
cameras: {
|
cameras: {
|
||||||
[finalCameraName]: {
|
[finalCameraName]: {
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
...(friendly_name && { friendly_name }),
|
...(friendlyName && { friendly_name: friendlyName }),
|
||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
inputs: values.ffmpeg.inputs.map((input) => ({
|
inputs: values.ffmpeg.inputs.map((input) => ({
|
||||||
path: input.path,
|
path: input.path,
|
||||||
@ -191,6 +226,13 @@ export default function CameraEditForm({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add go2rtc streams if provided
|
||||||
|
if (values.go2rtcStreams && Object.keys(values.go2rtcStreams).length > 0) {
|
||||||
|
configData.go2rtc = {
|
||||||
|
streams: values.go2rtcStreams,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const requestBody: ConfigSetBody = {
|
const requestBody: ConfigSetBody = {
|
||||||
requires_restart: 1,
|
requires_restart: 1,
|
||||||
config_data: configData,
|
config_data: configData,
|
||||||
@ -205,13 +247,36 @@ export default function CameraEditForm({
|
|||||||
.put("config/set", requestBody)
|
.put("config/set", requestBody)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
toast.success(
|
// Update running go2rtc instance if streams were configured
|
||||||
t("camera.cameraConfig.toast.success", {
|
if (
|
||||||
cameraName: values.cameraName,
|
values.go2rtcStreams &&
|
||||||
}),
|
Object.keys(values.go2rtcStreams).length > 0
|
||||||
{ position: "top-center" },
|
) {
|
||||||
);
|
const updatePromises = Object.entries(values.go2rtcStreams).map(
|
||||||
if (onSave) onSave();
|
([streamName, urls]) =>
|
||||||
|
axios.put(
|
||||||
|
`go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Promise.allSettled(updatePromises).then(() => {
|
||||||
|
toast.success(
|
||||||
|
t("cameraManagement.cameraConfig.toast.success", {
|
||||||
|
cameraName: values.cameraName,
|
||||||
|
}),
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
if (onSave) onSave();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
t("cameraManagement.cameraConfig.toast.success", {
|
||||||
|
cameraName: values.cameraName,
|
||||||
|
}),
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
if (onSave) onSave();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(res.statusText);
|
throw new Error(res.statusText);
|
||||||
}
|
}
|
||||||
@ -238,11 +303,11 @@ export default function CameraEditForm({
|
|||||||
values.cameraName !== cameraInfo?.friendly_name
|
values.cameraName !== cameraInfo?.friendly_name
|
||||||
) {
|
) {
|
||||||
// If camera name changed, delete old camera config
|
// If camera name changed, delete old camera config
|
||||||
const deleteRequestBody: ConfigSetBody = {
|
const deleteRequestBody = {
|
||||||
requires_restart: 1,
|
requires_restart: 1,
|
||||||
config_data: {
|
config_data: {
|
||||||
cameras: {
|
cameras: {
|
||||||
[cameraName]: "",
|
[cameraName]: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
update_topic: `config/cameras/${cameraName}/remove`,
|
update_topic: `config/cameras/${cameraName}/remove`,
|
||||||
@ -289,15 +354,15 @@ export default function CameraEditForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="scrollbar-container max-w-4xl overflow-y-auto md:mb-24">
|
||||||
<Toaster position="top-center" closeButton />
|
<Toaster position="top-center" closeButton />
|
||||||
<Heading as="h3" className="my-2">
|
<Heading as="h3" className="my-2">
|
||||||
{cameraName
|
{cameraName
|
||||||
? t("camera.cameraConfig.edit")
|
? t("cameraManagement.cameraConfig.edit")
|
||||||
: t("camera.cameraConfig.add")}
|
: t("cameraManagement.cameraConfig.add")}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="my-3 text-sm text-muted-foreground">
|
<div className="my-3 text-sm text-muted-foreground">
|
||||||
{t("camera.cameraConfig.description")}
|
{t("cameraManagement.cameraConfig.description")}
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-3 bg-secondary" />
|
<Separator className="my-3 bg-secondary" />
|
||||||
|
|
||||||
@ -308,10 +373,12 @@ export default function CameraEditForm({
|
|||||||
name="cameraName"
|
name="cameraName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("camera.cameraConfig.name")}</FormLabel>
|
<FormLabel>{t("cameraManagement.cameraConfig.name")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("camera.cameraConfig.namePlaceholder")}
|
placeholder={t(
|
||||||
|
"cameraManagement.cameraConfig.namePlaceholder",
|
||||||
|
)}
|
||||||
{...field}
|
{...field}
|
||||||
disabled={!!cameraName} // Prevent editing name for existing cameras
|
disabled={!!cameraName} // Prevent editing name for existing cameras
|
||||||
/>
|
/>
|
||||||
@ -332,107 +399,251 @@ export default function CameraEditForm({
|
|||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormLabel>{t("camera.cameraConfig.enabled")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("cameraManagement.cameraConfig.enabled")}
|
||||||
|
</FormLabel>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<FormLabel>{t("camera.cameraConfig.ffmpeg.inputs")}</FormLabel>
|
<Label className="text-sm font-medium">
|
||||||
|
{t("cameraManagement.cameraConfig.ffmpeg.inputs")}
|
||||||
|
</Label>
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => (
|
||||||
<div
|
<Card key={field.id} className="bg-secondary text-primary">
|
||||||
key={field.id}
|
<CardContent className="space-y-4 p-4">
|
||||||
className="mt-2 space-y-4 rounded-md border p-4"
|
<div className="flex items-center justify-between">
|
||||||
>
|
<h4 className="font-medium">
|
||||||
<FormField
|
{t("cameraWizard.step2.streamTitle", {
|
||||||
control={form.control}
|
number: index + 1,
|
||||||
name={`ffmpeg.inputs.${index}.path`}
|
})}
|
||||||
render={({ field }) => (
|
</h4>
|
||||||
<FormItem>
|
<Button
|
||||||
<FormLabel>
|
variant="ghost"
|
||||||
{t("camera.cameraConfig.ffmpeg.path")}
|
size="sm"
|
||||||
</FormLabel>
|
onClick={() => remove(index)}
|
||||||
<FormControl>
|
disabled={fields.length === 1}
|
||||||
<Input
|
className="text-secondary-foreground hover:text-secondary-foreground"
|
||||||
placeholder={t(
|
>
|
||||||
"camera.cameraConfig.ffmpeg.pathPlaceholder",
|
<LuTrash2 className="size-5" />
|
||||||
)}
|
</Button>
|
||||||
{...field}
|
</div>
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`ffmpeg.inputs.${index}.roles`}
|
name={`ffmpeg.inputs.${index}.path`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel className="text-sm font-medium">
|
||||||
{t("camera.cameraConfig.ffmpeg.roles")}
|
{t("cameraManagement.cameraConfig.ffmpeg.path")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex flex-wrap gap-2">
|
<Input
|
||||||
{(["audio", "detect", "record"] as const).map(
|
className="h-8"
|
||||||
(role) => (
|
placeholder={t(
|
||||||
<label
|
"cameraManagement.cameraConfig.ffmpeg.pathPlaceholder",
|
||||||
|
)}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
{t("cameraManagement.cameraConfig.ffmpeg.roles")}
|
||||||
|
</Label>
|
||||||
|
<div className="rounded-lg bg-background p-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(["detect", "record", "audio"] as const).map(
|
||||||
|
(role) => {
|
||||||
|
const isUsedElsewhere =
|
||||||
|
getUsedRolesExcludingIndex(index).has(role);
|
||||||
|
const isChecked =
|
||||||
|
watchedInputs[index]?.roles?.includes(role) ||
|
||||||
|
false;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
key={role}
|
key={role}
|
||||||
className="flex items-center space-x-2"
|
className="flex w-full items-center justify-between"
|
||||||
>
|
>
|
||||||
<input
|
<span className="text-sm capitalize">
|
||||||
type="checkbox"
|
{role}
|
||||||
checked={field.value.includes(role)}
|
</span>
|
||||||
onChange={(e) => {
|
<Switch
|
||||||
const updatedRoles = e.target.checked
|
checked={isChecked}
|
||||||
? [...field.value, role]
|
onCheckedChange={(checked) => {
|
||||||
: field.value.filter((r) => r !== role);
|
const currentRoles =
|
||||||
field.onChange(updatedRoles);
|
watchedInputs[index]?.roles || [];
|
||||||
|
const updatedRoles = checked
|
||||||
|
? [...currentRoles, role]
|
||||||
|
: currentRoles.filter((r) => r !== role);
|
||||||
|
form.setValue(
|
||||||
|
`ffmpeg.inputs.${index}.roles`,
|
||||||
|
updatedRoles,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
disabled={
|
disabled={!isChecked && isUsedElsewhere}
|
||||||
!field.value.includes(role) &&
|
|
||||||
getUsedRolesExcludingIndex(index).has(role)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<span>{role}</span>
|
</div>
|
||||||
</label>
|
);
|
||||||
),
|
},
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</div>
|
||||||
<FormMessage />
|
</div>
|
||||||
</FormItem>
|
</CardContent>
|
||||||
)}
|
</Card>
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => remove(index)}
|
|
||||||
disabled={fields.length === 1}
|
|
||||||
>
|
|
||||||
<LuTrash2 className="mr-2 h-4 w-4" />
|
|
||||||
{t("camera.cameraConfig.ffmpeg.removeInput")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
<FormMessage>
|
<FormMessage>
|
||||||
{form.formState.errors.ffmpeg?.inputs?.root &&
|
{form.formState.errors.ffmpeg?.inputs?.root &&
|
||||||
form.formState.errors.ffmpeg.inputs.root.message}
|
form.formState.errors.ffmpeg.inputs.root.message}
|
||||||
</FormMessage>
|
</FormMessage>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
size="sm"
|
|
||||||
className="mt-2"
|
|
||||||
onClick={() => append({ path: "", roles: getAvailableRoles() })}
|
onClick={() => append({ path: "", roles: getAvailableRoles() })}
|
||||||
|
variant="outline"
|
||||||
|
className=""
|
||||||
>
|
>
|
||||||
<LuPlus className="mr-2 h-4 w-4" />
|
<LuPlus className="mr-2 size-4" />
|
||||||
{t("camera.cameraConfig.ffmpeg.addInput")}
|
{t("cameraManagement.cameraConfig.ffmpeg.addInput")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* go2rtc Streams Section */}
|
||||||
|
{Object.keys(watchedGo2rtcStreams).length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
{t("cameraManagement.cameraConfig.go2rtcStreams")}
|
||||||
|
</Label>
|
||||||
|
{Object.entries(watchedGo2rtcStreams).map(
|
||||||
|
([streamName, urls]) => (
|
||||||
|
<Card key={streamName} className="bg-secondary text-primary">
|
||||||
|
<CardContent className="space-y-4 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-medium">{streamName}</h4>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const updatedStreams = { ...watchedGo2rtcStreams };
|
||||||
|
delete updatedStreams[streamName];
|
||||||
|
form.setValue("go2rtcStreams", updatedStreams);
|
||||||
|
}}
|
||||||
|
className="text-secondary-foreground hover:text-secondary-foreground"
|
||||||
|
>
|
||||||
|
<LuTrash2 className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
{t("cameraManagement.cameraConfig.streamUrls")}
|
||||||
|
</Label>
|
||||||
|
{(Array.isArray(urls) ? urls : [urls]).map(
|
||||||
|
(url, urlIndex) => (
|
||||||
|
<div
|
||||||
|
key={urlIndex}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
className="h-8 flex-1"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updatedStreams = {
|
||||||
|
...watchedGo2rtcStreams,
|
||||||
|
};
|
||||||
|
const currentUrls = Array.isArray(
|
||||||
|
updatedStreams[streamName],
|
||||||
|
)
|
||||||
|
? updatedStreams[streamName]
|
||||||
|
: [updatedStreams[streamName]];
|
||||||
|
currentUrls[urlIndex] = e.target.value;
|
||||||
|
updatedStreams[streamName] = currentUrls;
|
||||||
|
form.setValue(
|
||||||
|
"go2rtcStreams",
|
||||||
|
updatedStreams,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
placeholder="rtsp://username:password@host:port/path"
|
||||||
|
/>
|
||||||
|
{(Array.isArray(urls) ? urls : [urls]).length >
|
||||||
|
1 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const updatedStreams = {
|
||||||
|
...watchedGo2rtcStreams,
|
||||||
|
};
|
||||||
|
const currentUrls = Array.isArray(
|
||||||
|
updatedStreams[streamName],
|
||||||
|
)
|
||||||
|
? updatedStreams[streamName]
|
||||||
|
: [updatedStreams[streamName]];
|
||||||
|
currentUrls.splice(urlIndex, 1);
|
||||||
|
updatedStreams[streamName] = currentUrls;
|
||||||
|
form.setValue(
|
||||||
|
"go2rtcStreams",
|
||||||
|
updatedStreams,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="text-secondary-foreground hover:text-secondary-foreground"
|
||||||
|
>
|
||||||
|
<LuTrash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const updatedStreams = { ...watchedGo2rtcStreams };
|
||||||
|
const currentUrls = Array.isArray(
|
||||||
|
updatedStreams[streamName],
|
||||||
|
)
|
||||||
|
? updatedStreams[streamName]
|
||||||
|
: [updatedStreams[streamName]];
|
||||||
|
currentUrls.push("");
|
||||||
|
updatedStreams[streamName] = currentUrls;
|
||||||
|
form.setValue("go2rtcStreams", updatedStreams);
|
||||||
|
}}
|
||||||
|
className="w-fit"
|
||||||
|
>
|
||||||
|
<LuPlus className="mr-2 size-4" />
|
||||||
|
{t("cameraManagement.cameraConfig.addUrl")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const streamName = `${cameraName}_stream_${Object.keys(watchedGo2rtcStreams).length + 1}`;
|
||||||
|
const updatedStreams = {
|
||||||
|
...watchedGo2rtcStreams,
|
||||||
|
[streamName]: [""],
|
||||||
|
};
|
||||||
|
form.setValue("go2rtcStreams", updatedStreams);
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
className=""
|
||||||
|
>
|
||||||
|
<LuPlus className="mr-2 size-4" />
|
||||||
|
{t("cameraManagement.cameraConfig.addGo2rtcStream")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[50%]">
|
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[50%]">
|
||||||
<Button
|
<Button
|
||||||
className="flex flex-1"
|
className="flex flex-1"
|
||||||
@ -461,6 +672,6 @@ export default function CameraEditForm({
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
398
web/src/components/settings/CameraWizardDialog.tsx
Normal file
398
web/src/components/settings/CameraWizardDialog.tsx
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
import StepIndicator from "@/components/indicators/StepIndicator";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useCallback, useState, useEffect, useReducer } from "react";
|
||||||
|
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 type {
|
||||||
|
WizardFormData,
|
||||||
|
CameraConfigData,
|
||||||
|
ConfigSetBody,
|
||||||
|
} from "@/types/cameraWizard";
|
||||||
|
import { processCameraName } from "@/utils/cameraUtil";
|
||||||
|
|
||||||
|
type WizardState = {
|
||||||
|
wizardData: Partial<WizardFormData>;
|
||||||
|
shouldNavigateNext: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WizardAction =
|
||||||
|
| { type: "UPDATE_DATA"; payload: Partial<WizardFormData> }
|
||||||
|
| { type: "UPDATE_AND_NEXT"; payload: Partial<WizardFormData> }
|
||||||
|
| { type: "RESET_NAVIGATE" };
|
||||||
|
|
||||||
|
const wizardReducer = (
|
||||||
|
state: WizardState,
|
||||||
|
action: WizardAction,
|
||||||
|
): WizardState => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "UPDATE_DATA":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
wizardData: { ...state.wizardData, ...action.payload },
|
||||||
|
};
|
||||||
|
case "UPDATE_AND_NEXT":
|
||||||
|
return {
|
||||||
|
wizardData: { ...state.wizardData, ...action.payload },
|
||||||
|
shouldNavigateNext: true,
|
||||||
|
};
|
||||||
|
case "RESET_NAVIGATE":
|
||||||
|
return { ...state, shouldNavigateNext: false };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
"cameraWizard.steps.nameAndConnection",
|
||||||
|
"cameraWizard.steps.streamConfiguration",
|
||||||
|
"cameraWizard.steps.validationAndTesting",
|
||||||
|
];
|
||||||
|
|
||||||
|
type CameraWizardDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CameraWizardDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: CameraWizardDialogProps) {
|
||||||
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
const { mutate: updateConfig } = useSWR("config");
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [state, dispatch] = useReducer(wizardReducer, {
|
||||||
|
wizardData: { streams: [] },
|
||||||
|
shouldNavigateNext: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset wizard when opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setCurrentStep(0);
|
||||||
|
dispatch({ type: "UPDATE_DATA", payload: { streams: [] } });
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setCurrentStep(0);
|
||||||
|
dispatch({ type: "UPDATE_DATA", payload: { streams: [] } });
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const onUpdate = useCallback((data: Partial<WizardFormData>) => {
|
||||||
|
dispatch({ type: "UPDATE_DATA", payload: data });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
case 1:
|
||||||
|
// 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)
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [currentStep, state.wizardData]);
|
||||||
|
|
||||||
|
const handleNext = useCallback(
|
||||||
|
(data?: Partial<WizardFormData>) => {
|
||||||
|
if (data) {
|
||||||
|
// Atomic update and navigate
|
||||||
|
dispatch({ type: "UPDATE_AND_NEXT", payload: data });
|
||||||
|
} else {
|
||||||
|
// Just navigate
|
||||||
|
if (currentStep < STEPS.length - 1 && canProceedToNext()) {
|
||||||
|
setCurrentStep((s) => s + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentStep, canProceedToNext],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (currentStep > 0) {
|
||||||
|
setCurrentStep(currentStep - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle navigation after atomic update
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.shouldNavigateNext) {
|
||||||
|
if (currentStep < STEPS.length - 1 && canProceedToNext()) {
|
||||||
|
setCurrentStep((s) => s + 1);
|
||||||
|
}
|
||||||
|
dispatch({ type: "RESET_NAVIGATE" });
|
||||||
|
}
|
||||||
|
}, [state.shouldNavigateNext, currentStep, canProceedToNext]);
|
||||||
|
|
||||||
|
// Handle wizard save
|
||||||
|
const handleSave = useCallback(
|
||||||
|
(wizardData: WizardFormData) => {
|
||||||
|
if (!wizardData.cameraName || !wizardData.streams) {
|
||||||
|
toast.error("Invalid wizard data");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Process camera name and friendly name
|
||||||
|
const { finalCameraName, friendlyName } = processCameraName(
|
||||||
|
wizardData.cameraName,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert wizard data to Frigate config format
|
||||||
|
const configData: CameraConfigData = {
|
||||||
|
cameras: {
|
||||||
|
[finalCameraName]: {
|
||||||
|
enabled: true,
|
||||||
|
...(friendlyName && { friendly_name: friendlyName }),
|
||||||
|
ffmpeg: {
|
||||||
|
inputs: wizardData.streams.map((stream, index) => {
|
||||||
|
const isRestreamed =
|
||||||
|
wizardData.restreamIds?.includes(stream.id) ?? false;
|
||||||
|
if (isRestreamed) {
|
||||||
|
const go2rtcStreamName =
|
||||||
|
wizardData.streams!.length === 1
|
||||||
|
? finalCameraName
|
||||||
|
: `${finalCameraName}_${index + 1}`;
|
||||||
|
return {
|
||||||
|
path: `rtsp://127.0.0.1:8554/${go2rtcStreamName}`,
|
||||||
|
input_args: "preset-rtsp-restream",
|
||||||
|
roles: stream.roles,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
path: stream.url,
|
||||||
|
roles: stream.roles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add live.streams configuration for go2rtc streams
|
||||||
|
if (wizardData.streams && wizardData.streams.length > 0) {
|
||||||
|
configData.cameras[finalCameraName].live = {
|
||||||
|
streams: {},
|
||||||
|
};
|
||||||
|
wizardData.streams.forEach((_, index) => {
|
||||||
|
const go2rtcStreamName =
|
||||||
|
wizardData.streams!.length === 1
|
||||||
|
? finalCameraName
|
||||||
|
: `${finalCameraName}_${index + 1}`;
|
||||||
|
configData.cameras[finalCameraName].live!.streams[
|
||||||
|
`Stream ${index + 1}`
|
||||||
|
] = go2rtcStreamName;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody: ConfigSetBody = {
|
||||||
|
requires_restart: 1,
|
||||||
|
config_data: configData,
|
||||||
|
update_topic: `config/cameras/${finalCameraName}/add`,
|
||||||
|
};
|
||||||
|
|
||||||
|
axios
|
||||||
|
.put("config/set", requestBody)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.status === 200) {
|
||||||
|
// Configure go2rtc streams for all streams
|
||||||
|
if (wizardData.streams && wizardData.streams.length > 0) {
|
||||||
|
const go2rtcStreams: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
wizardData.streams.forEach((stream, index) => {
|
||||||
|
// Use camera name with index suffix for multiple streams
|
||||||
|
const streamName =
|
||||||
|
wizardData.streams!.length === 1
|
||||||
|
? finalCameraName
|
||||||
|
: `${finalCameraName}_${index + 1}`;
|
||||||
|
go2rtcStreams[streamName] = [stream.url];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(go2rtcStreams).length > 0) {
|
||||||
|
// Update frigate go2rtc config for persistence
|
||||||
|
const go2rtcConfigData = {
|
||||||
|
go2rtc: {
|
||||||
|
streams: go2rtcStreams,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const go2rtcRequestBody = {
|
||||||
|
requires_restart: 0,
|
||||||
|
config_data: go2rtcConfigData,
|
||||||
|
};
|
||||||
|
|
||||||
|
axios
|
||||||
|
.put("config/set", go2rtcRequestBody)
|
||||||
|
.then(() => {
|
||||||
|
// also update the running go2rtc instance for immediate effect
|
||||||
|
const updatePromises = Object.entries(go2rtcStreams).map(
|
||||||
|
([streamName, urls]) =>
|
||||||
|
axios.put(
|
||||||
|
`go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Promise.allSettled(updatePromises).then(() => {
|
||||||
|
toast.success(
|
||||||
|
t("cameraWizard.save.success", {
|
||||||
|
cameraName: friendlyName || finalCameraName,
|
||||||
|
}),
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
updateConfig();
|
||||||
|
onClose();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// log the error but don't fail the entire save
|
||||||
|
toast.warning(
|
||||||
|
t("cameraWizard.save.failure", {
|
||||||
|
cameraName: friendlyName || finalCameraName,
|
||||||
|
}),
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
updateConfig();
|
||||||
|
onClose();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No valid streams found
|
||||||
|
toast.success(
|
||||||
|
t("cameraWizard.save.failure", {
|
||||||
|
cameraName: friendlyName || finalCameraName,
|
||||||
|
}),
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
updateConfig();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
t("camera.cameraConfig.toast.success", {
|
||||||
|
cameraName: wizardData.cameraName,
|
||||||
|
}),
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
updateConfig();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(response.statusText);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const apiError = error as {
|
||||||
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
const errorMessage =
|
||||||
|
apiError.response?.data?.message ||
|
||||||
|
apiError.response?.data?.detail ||
|
||||||
|
apiError.message ||
|
||||||
|
"Unknown error";
|
||||||
|
|
||||||
|
toast.error(
|
||||||
|
t("toast.save.error.title", {
|
||||||
|
errorMessage,
|
||||||
|
ns: "common",
|
||||||
|
}),
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateConfig, t, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent
|
||||||
|
className="max-h-[90dvh] max-w-4xl overflow-y-auto"
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StepIndicator
|
||||||
|
steps={STEPS}
|
||||||
|
currentStep={currentStep}
|
||||||
|
variant="dots"
|
||||||
|
className="mb-4 justify-start"
|
||||||
|
/>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("cameraWizard.title")}</DialogTitle>
|
||||||
|
{currentStep === 0 && (
|
||||||
|
<DialogDescription>
|
||||||
|
{t("cameraWizard.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{currentStep > 0 && state.wizardData.cameraName && (
|
||||||
|
<div className="text-center text-primary-variant md:text-start">
|
||||||
|
{state.wizardData.cameraName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pb-4">
|
||||||
|
<div className="size-full">
|
||||||
|
{currentStep === 0 && (
|
||||||
|
<Step1NameCamera
|
||||||
|
wizardData={state.wizardData}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onNext={handleNext}
|
||||||
|
onCancel={handleClose}
|
||||||
|
canProceed={canProceedToNext()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentStep === 1 && (
|
||||||
|
<Step2StreamConfig
|
||||||
|
wizardData={state.wizardData}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onBack={handleBack}
|
||||||
|
onNext={handleNext}
|
||||||
|
canProceed={canProceedToNext()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<Step3Validation
|
||||||
|
wizardData={state.wizardData}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onSave={handleSave}
|
||||||
|
onBack={handleBack}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
615
web/src/components/settings/wizard/Step1NameCamera.tsx
Normal file
615
web/src/components/settings/wizard/Step1NameCamera.tsx
Normal file
@ -0,0 +1,615 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
FormDescription,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
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 {
|
||||||
|
WizardFormData,
|
||||||
|
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";
|
||||||
|
|
||||||
|
type Step1NameCameraProps = {
|
||||||
|
wizardData: Partial<WizardFormData>;
|
||||||
|
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||||
|
onNext: (data?: Partial<WizardFormData>) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
canProceed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Step1NameCamera({
|
||||||
|
wizardData,
|
||||||
|
onUpdate,
|
||||||
|
onNext,
|
||||||
|
onCancel,
|
||||||
|
}: Step1NameCameraProps) {
|
||||||
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||||
|
|
||||||
|
const existingCameraNames = useMemo(() => {
|
||||||
|
if (!config?.cameras) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.keys(config.cameras);
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const step1FormData = z
|
||||||
|
.object({
|
||||||
|
cameraName: z
|
||||||
|
.string()
|
||||||
|
.min(1, t("cameraWizard.step1.errors.nameRequired"))
|
||||||
|
.max(64, t("cameraWizard.step1.errors.nameLength"))
|
||||||
|
.regex(
|
||||||
|
/^[a-zA-Z0-9\s_-]+$/,
|
||||||
|
t("cameraWizard.step1.errors.invalidCharacters"),
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(value) => !existingCameraNames.includes(value),
|
||||||
|
t("cameraWizard.step1.errors.nameExists"),
|
||||||
|
),
|
||||||
|
host: z.string().optional(),
|
||||||
|
username: z.string().optional(),
|
||||||
|
password: z.string().optional(),
|
||||||
|
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
|
||||||
|
customUrl: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
// If brand is "other", customUrl is required
|
||||||
|
if (data.brandTemplate === "other") {
|
||||||
|
return data.customUrl && data.customUrl.trim().length > 0;
|
||||||
|
}
|
||||||
|
// If brand is not "other", host is required
|
||||||
|
return data.host && data.host.trim().length > 0;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: t("cameraWizard.step1.errors.brandOrCustomUrlRequired"),
|
||||||
|
path: ["customUrl"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof step1FormData>>({
|
||||||
|
resolver: zodResolver(step1FormData),
|
||||||
|
defaultValues: {
|
||||||
|
cameraName: wizardData.cameraName || "",
|
||||||
|
host: wizardData.host || "",
|
||||||
|
username: wizardData.username || "",
|
||||||
|
password: wizardData.password || "",
|
||||||
|
brandTemplate:
|
||||||
|
wizardData.brandTemplate &&
|
||||||
|
CAMERA_BRAND_VALUES.includes(wizardData.brandTemplate as CameraBrand)
|
||||||
|
? (wizardData.brandTemplate as CameraBrand)
|
||||||
|
: "dahua",
|
||||||
|
customUrl: wizardData.customUrl || "",
|
||||||
|
},
|
||||||
|
mode: "onChange",
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchedBrand = form.watch("brandTemplate");
|
||||||
|
const watchedHost = form.watch("host");
|
||||||
|
const watchedCustomUrl = form.watch("customUrl");
|
||||||
|
|
||||||
|
const isTestButtonEnabled =
|
||||||
|
watchedBrand === "other"
|
||||||
|
? !!(watchedCustomUrl && watchedCustomUrl.trim())
|
||||||
|
: !!(watchedHost && watchedHost.trim());
|
||||||
|
|
||||||
|
const generateStreamUrl = useCallback(
|
||||||
|
(data: z.infer<typeof step1FormData>): string => {
|
||||||
|
if (data.brandTemplate === "other") {
|
||||||
|
return data.customUrl || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||||
|
if (!brand || !data.host) return "";
|
||||||
|
|
||||||
|
return brand.template
|
||||||
|
.replace("{username}", data.username || "")
|
||||||
|
.replace("{password}", data.password || "")
|
||||||
|
.replace("{host}", data.host);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const testConnection = useCallback(async () => {
|
||||||
|
const data = form.getValues();
|
||||||
|
const streamUrl = generateStreamUrl(data);
|
||||||
|
|
||||||
|
if (!streamUrl) {
|
||||||
|
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsTesting(true);
|
||||||
|
setTestResult(null);
|
||||||
|
|
||||||
|
// First get probe data for metadata
|
||||||
|
const probePromise = axios.get("ffprobe", {
|
||||||
|
params: { paths: streamUrl, detailed: true },
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then get snapshot for preview
|
||||||
|
const snapshotPromise = axios.get("ffprobe/snapshot", {
|
||||||
|
params: { url: streamUrl },
|
||||||
|
responseType: "blob",
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First get probe data for metadata
|
||||||
|
const probeResponse = await probePromise;
|
||||||
|
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) {
|
||||||
|
try {
|
||||||
|
const snapshotResponse = await snapshotPromise;
|
||||||
|
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("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;
|
||||||
|
|
||||||
|
// Extract FPS from rational (e.g., "15/1" -> 15)
|
||||||
|
const fps = videoStream?.r_frame_rate
|
||||||
|
? parseFloat(videoStream.r_frame_rate.split("/")[0]) /
|
||||||
|
parseFloat(videoStream.r_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);
|
||||||
|
toast.success(t("cameraWizard.step1.testSuccess"));
|
||||||
|
} else {
|
||||||
|
const error = probeData?.stderr || "Unknown error";
|
||||||
|
setTestResult({
|
||||||
|
success: false,
|
||||||
|
error: error,
|
||||||
|
});
|
||||||
|
toast.error(t("cameraWizard.commonErrors.testFailed", { error }));
|
||||||
|
}
|
||||||
|
} 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 }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsTesting(false);
|
||||||
|
}
|
||||||
|
}, [form, generateStreamUrl, t]);
|
||||||
|
|
||||||
|
const onSubmit = (data: z.infer<typeof step1FormData>) => {
|
||||||
|
onUpdate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContinue = useCallback(() => {
|
||||||
|
const data = form.getValues();
|
||||||
|
const streamUrl = 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]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{!testResult?.success && (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("cameraWizard.step1.description")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="cameraName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("cameraWizard.step1.cameraName")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="h-8"
|
||||||
|
placeholder={t(
|
||||||
|
"cameraWizard.step1.cameraNamePlaceholder",
|
||||||
|
)}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="brandTemplate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("cameraWizard.step1.cameraBrand")}</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t("cameraWizard.step1.selectBrand")}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{CAMERA_BRANDS.map((brand) => (
|
||||||
|
<SelectItem key={brand.value} value={brand.value}>
|
||||||
|
{brand.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
{field.value &&
|
||||||
|
(() => {
|
||||||
|
const selectedBrand = CAMERA_BRANDS.find(
|
||||||
|
(brand) => brand.value === field.value,
|
||||||
|
);
|
||||||
|
return selectedBrand &&
|
||||||
|
selectedBrand.value != "other" ? (
|
||||||
|
<FormDescription className="mt-1 pt-0.5 text-xs text-muted-foreground">
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<div className="flex flex-row items-center gap-0.5 text-xs text-muted-foreground hover:text-primary">
|
||||||
|
<LuInfo className="mr-1 size-3" />
|
||||||
|
{t("cameraWizard.step1.brandInformation")}
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium">
|
||||||
|
{selectedBrand.label}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("cameraWizard.step1.brandUrlFormat", {
|
||||||
|
exampleUrl: selectedBrand.exampleUrl,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</FormDescription>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{watchedBrand !== "other" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="host"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("cameraWizard.step1.host")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="h-8"
|
||||||
|
placeholder="192.168.1.100"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("cameraWizard.step1.username")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="h-8"
|
||||||
|
placeholder={t(
|
||||||
|
"cameraWizard.step1.usernamePlaceholder",
|
||||||
|
)}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("cameraWizard.step1.password")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
className="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}
|
||||||
|
name="customUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("cameraWizard.step1.customUrl")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="h-8"
|
||||||
|
placeholder="rtsp://username:password@host:port/path"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
{testResult?.success ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleContinue}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{isTesting && <ActivityIndicator className="size-4" />}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
487
web/src/components/settings/wizard/Step2StreamConfig.tsx
Normal file
487
web/src/components/settings/wizard/Step2StreamConfig.tsx
Normal file
@ -0,0 +1,487 @@
|
|||||||
|
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?.r_frame_rate
|
||||||
|
? parseFloat(videoStream.r_frame_rate.split("/")[0]) /
|
||||||
|
parseFloat(videoStream.r_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 currentIds = wizardData.restreamIds || [];
|
||||||
|
const isSelected = currentIds.includes(streamId);
|
||||||
|
const newIds = isSelected
|
||||||
|
? currentIds.filter((id) => id !== streamId)
|
||||||
|
: [...currentIds, streamId];
|
||||||
|
onUpdate({
|
||||||
|
restreamIds: newIds,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[wizardData.restreamIds, onUpdate],
|
||||||
|
);
|
||||||
|
|
||||||
|
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">
|
||||||
|
{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">
|
||||||
|
{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="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">
|
||||||
|
{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="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={(wizardData.restreamIds || []).includes(
|
||||||
|
stream.id,
|
||||||
|
)}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
690
web/src/components/settings/wizard/Step3Validation.tsx
Normal file
690
web/src/components/settings/wizard/Step3Validation.tsx
Normal file
@ -0,0 +1,690 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { LuRotateCcw } from "react-icons/lu";
|
||||||
|
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import axios from "axios";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import MSEPlayer from "@/components/player/MsePlayer";
|
||||||
|
import { WizardFormData, StreamConfig, TestResult } from "@/types/cameraWizard";
|
||||||
|
import { PlayerStatsType } from "@/types/live";
|
||||||
|
import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
|
||||||
|
import { LuX } from "react-icons/lu";
|
||||||
|
import { Card, CardContent } from "../../ui/card";
|
||||||
|
|
||||||
|
type Step3ValidationProps = {
|
||||||
|
wizardData: Partial<WizardFormData>;
|
||||||
|
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||||
|
onSave: (config: WizardFormData) => void;
|
||||||
|
onBack?: () => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Step3Validation({
|
||||||
|
wizardData,
|
||||||
|
onUpdate,
|
||||||
|
onSave,
|
||||||
|
onBack,
|
||||||
|
isLoading = false,
|
||||||
|
}: Step3ValidationProps) {
|
||||||
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
|
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
||||||
|
const [measuredBandwidth, setMeasuredBandwidth] = useState<
|
||||||
|
Map<string, number>
|
||||||
|
>(new Map());
|
||||||
|
|
||||||
|
const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
|
||||||
|
|
||||||
|
const handleBandwidthUpdate = useCallback(
|
||||||
|
(streamId: string, bandwidth: number) => {
|
||||||
|
setMeasuredBandwidth((prev) => new Map(prev).set(streamId, bandwidth));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use test results from Step 2, but allow re-validation in Step 3
|
||||||
|
const validationResults = useMemo(() => {
|
||||||
|
const results = new Map<string, TestResult>();
|
||||||
|
streams.forEach((stream) => {
|
||||||
|
if (stream.testResult) {
|
||||||
|
results.set(stream.id, stream.testResult);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return results;
|
||||||
|
}, [streams]);
|
||||||
|
|
||||||
|
const performStreamValidation = useCallback(
|
||||||
|
async (stream: StreamConfig): Promise<TestResult> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get("ffprobe", {
|
||||||
|
params: { paths: stream.url, detailed: true },
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.[0]?.return_code === 0) {
|
||||||
|
const probeData = response.data[0];
|
||||||
|
const streamData = probeData.stdout.streams || [];
|
||||||
|
|
||||||
|
const videoStream = streamData.find(
|
||||||
|
(s: { codec_type?: string; codec_name?: string }) =>
|
||||||
|
s.codec_type === "video" ||
|
||||||
|
s.codec_name?.includes("h264") ||
|
||||||
|
s.codec_name?.includes("h265"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const audioStream = streamData.find(
|
||||||
|
(s: { codec_type?: string; codec_name?: string }) =>
|
||||||
|
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?.r_frame_rate
|
||||||
|
? parseFloat(videoStream.r_frame_rate.split("/")[0]) /
|
||||||
|
parseFloat(videoStream.r_frame_rate.split("/")[1])
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
resolution,
|
||||||
|
videoCodec: videoStream?.codec_name,
|
||||||
|
audioCodec: audioStream?.codec_name,
|
||||||
|
fps: fps && !isNaN(fps) ? fps : undefined,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const error = response.data?.[0]?.stderr || "Unknown error";
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
} 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";
|
||||||
|
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const validateStream = useCallback(
|
||||||
|
async (stream: StreamConfig) => {
|
||||||
|
if (!stream.url.trim()) {
|
||||||
|
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTestingStreams((prev) => new Set(prev).add(stream.id));
|
||||||
|
|
||||||
|
const testResult = await performStreamValidation(stream);
|
||||||
|
|
||||||
|
onUpdate({
|
||||||
|
streams: streams.map((s) =>
|
||||||
|
s.id === stream.id ? { ...s, testResult } : s,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (testResult.success) {
|
||||||
|
toast.success(
|
||||||
|
t("cameraWizard.step3.streamValidated", {
|
||||||
|
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
t("cameraWizard.step3.streamValidationFailed", {
|
||||||
|
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTestingStreams((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(stream.id);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[streams, onUpdate, t, performStreamValidation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const validateAllStreams = useCallback(async () => {
|
||||||
|
setIsValidating(true);
|
||||||
|
const results = new Map<string, TestResult>();
|
||||||
|
|
||||||
|
// Only test streams that haven't been tested or failed
|
||||||
|
const streamsToTest = streams.filter(
|
||||||
|
(stream) => !stream.testResult || !stream.testResult.success,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const stream of streamsToTest) {
|
||||||
|
if (!stream.url.trim()) continue;
|
||||||
|
|
||||||
|
const testResult = await performStreamValidation(stream);
|
||||||
|
results.set(stream.id, testResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update wizard data with new test results
|
||||||
|
if (results.size > 0) {
|
||||||
|
const updatedStreams = streams.map((stream) => {
|
||||||
|
const newResult = results.get(stream.id);
|
||||||
|
if (newResult) {
|
||||||
|
return { ...stream, testResult: newResult };
|
||||||
|
}
|
||||||
|
return stream;
|
||||||
|
});
|
||||||
|
|
||||||
|
onUpdate({ streams: updatedStreams });
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsValidating(false);
|
||||||
|
|
||||||
|
if (results.size > 0) {
|
||||||
|
const successfulTests = Array.from(results.values()).filter(
|
||||||
|
(r) => r.success,
|
||||||
|
).length;
|
||||||
|
if (successfulTests === results.size) {
|
||||||
|
toast.success(t("cameraWizard.step3.reconnectionSuccess"));
|
||||||
|
} else {
|
||||||
|
toast.warning(t("cameraWizard.step3.reconnectionPartial"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [streams, onUpdate, t, performStreamValidation]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
if (!wizardData.cameraName || !wizardData.streams?.length) {
|
||||||
|
toast.error(t("cameraWizard.step3.saveError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert wizard data to final config format
|
||||||
|
const configData = {
|
||||||
|
cameraName: wizardData.cameraName,
|
||||||
|
host: wizardData.host,
|
||||||
|
username: wizardData.username,
|
||||||
|
password: wizardData.password,
|
||||||
|
brandTemplate: wizardData.brandTemplate,
|
||||||
|
customUrl: wizardData.customUrl,
|
||||||
|
streams: wizardData.streams,
|
||||||
|
restreamIds: wizardData.restreamIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
onSave(configData);
|
||||||
|
}, [wizardData, onSave, t]);
|
||||||
|
|
||||||
|
const canSave = useMemo(() => {
|
||||||
|
return (
|
||||||
|
wizardData.cameraName &&
|
||||||
|
wizardData.streams?.length &&
|
||||||
|
wizardData.streams.some((s) => s.roles.includes("detect")) &&
|
||||||
|
wizardData.streams.every((s) => s.testResult) // All streams must be tested
|
||||||
|
);
|
||||||
|
}, [wizardData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("cameraWizard.step3.description")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-medium">
|
||||||
|
{t("cameraWizard.step3.validationTitle")}
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
onClick={validateAllStreams}
|
||||||
|
disabled={isValidating || streams.length === 0}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{isValidating && <ActivityIndicator className="mr-2 size-4" />}
|
||||||
|
{isValidating
|
||||||
|
? t("cameraWizard.step3.connecting")
|
||||||
|
: t("cameraWizard.step3.connectAllStreams")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{streams.map((stream, index) => {
|
||||||
|
const result = validationResults.get(stream.id);
|
||||||
|
return (
|
||||||
|
<Card key={stream.id} className="bg-secondary text-primary">
|
||||||
|
<CardContent className="space-y-4 p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<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", {
|
||||||
|
number: index + 1,
|
||||||
|
})}
|
||||||
|
</h4>
|
||||||
|
{stream.roles.map((role) => (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
key={role}
|
||||||
|
className="mx-1 text-xs"
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{result && result.success && (
|
||||||
|
<div className="mb-2 text-sm text-muted-foreground">
|
||||||
|
{[
|
||||||
|
result.resolution,
|
||||||
|
result.fps
|
||||||
|
? `${result.fps} ${t("cameraWizard.testResultLabels.fps")}`
|
||||||
|
: null,
|
||||||
|
result.videoCodec,
|
||||||
|
result.audioCodec,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{result?.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>
|
||||||
|
)}
|
||||||
|
{result && !result.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>
|
||||||
|
|
||||||
|
{result?.success && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<StreamPreview
|
||||||
|
stream={stream}
|
||||||
|
onBandwidthUpdate={handleBandwidthUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-2 flex flex-col justify-between gap-1 md:flex-row md:items-center">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{stream.url}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (result?.success) {
|
||||||
|
// Disconnect: clear the test result
|
||||||
|
onUpdate({
|
||||||
|
streams: streams.map((s) =>
|
||||||
|
s.id === stream.id
|
||||||
|
? { ...s, testResult: undefined }
|
||||||
|
: s,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Test/Connect: perform validation
|
||||||
|
validateStream(stream);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
testingStreams.has(stream.id) || !stream.url.trim()
|
||||||
|
}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{testingStreams.has(stream.id) && (
|
||||||
|
<ActivityIndicator className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
{result?.success
|
||||||
|
? t("cameraWizard.step3.disconnectStream")
|
||||||
|
: testingStreams.has(stream.id)
|
||||||
|
? t("cameraWizard.step3.connectingStream")
|
||||||
|
: t("cameraWizard.step3.connectStream")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs">
|
||||||
|
{t("cameraWizard.step3.issues.title")}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-background p-3">
|
||||||
|
<StreamIssues
|
||||||
|
stream={stream}
|
||||||
|
measuredBandwidth={measuredBandwidth}
|
||||||
|
wizardData={wizardData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && !result.success && (
|
||||||
|
<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">{result.error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!canSave || isLoading}
|
||||||
|
className="sm:flex-1"
|
||||||
|
variant="select"
|
||||||
|
>
|
||||||
|
{isLoading && <ActivityIndicator className="mr-2 size-4" />}
|
||||||
|
{isLoading
|
||||||
|
? t("button.saving", { ns: "common" })
|
||||||
|
: t("cameraWizard.step3.saveAndApply")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreamIssuesProps = {
|
||||||
|
stream: StreamConfig;
|
||||||
|
measuredBandwidth: Map<string, number>;
|
||||||
|
wizardData: Partial<WizardFormData>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function StreamIssues({
|
||||||
|
stream,
|
||||||
|
measuredBandwidth,
|
||||||
|
wizardData,
|
||||||
|
}: StreamIssuesProps) {
|
||||||
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
|
||||||
|
const issues = useMemo(() => {
|
||||||
|
const result: Array<{
|
||||||
|
type: "good" | "warning" | "error";
|
||||||
|
message: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Video codec check
|
||||||
|
if (stream.testResult?.videoCodec) {
|
||||||
|
const videoCodec = stream.testResult.videoCodec.toLowerCase();
|
||||||
|
if (["h264", "h265", "hevc"].includes(videoCodec)) {
|
||||||
|
result.push({
|
||||||
|
type: "good",
|
||||||
|
message: t("cameraWizard.step3.issues.videoCodecGood", {
|
||||||
|
codec: stream.testResult.videoCodec,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio codec check
|
||||||
|
if (stream.roles.includes("record")) {
|
||||||
|
if (stream.testResult?.audioCodec) {
|
||||||
|
const audioCodec = stream.testResult.audioCodec.toLowerCase();
|
||||||
|
if (audioCodec === "aac") {
|
||||||
|
result.push({
|
||||||
|
type: "good",
|
||||||
|
message: t("cameraWizard.step3.issues.audioCodecGood", {
|
||||||
|
codec: stream.testResult.audioCodec,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result.push({
|
||||||
|
type: "error",
|
||||||
|
message: t("cameraWizard.step3.issues.audioCodecRecordError"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push({
|
||||||
|
type: "warning",
|
||||||
|
message: t("cameraWizard.step3.issues.noAudioWarning"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio detection check
|
||||||
|
if (stream.roles.includes("audio")) {
|
||||||
|
if (!stream.testResult?.audioCodec) {
|
||||||
|
result.push({
|
||||||
|
type: "error",
|
||||||
|
message: t("cameraWizard.step3.issues.audioCodecRequired"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restreaming check
|
||||||
|
if (stream.roles.includes("record")) {
|
||||||
|
const restreamIds = wizardData.restreamIds || [];
|
||||||
|
if (restreamIds.includes(stream.id)) {
|
||||||
|
result.push({
|
||||||
|
type: "warning",
|
||||||
|
message: t("cameraWizard.step3.issues.restreamingWarning"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [stream, wizardData, t]);
|
||||||
|
|
||||||
|
if (issues.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<BandwidthDisplay
|
||||||
|
streamId={stream.id}
|
||||||
|
measuredBandwidth={measuredBandwidth}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{issues.map((issue, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2 text-sm">
|
||||||
|
{issue.type === "good" && (
|
||||||
|
<FaCircleCheck className="size-4 flex-shrink-0 text-success" />
|
||||||
|
)}
|
||||||
|
{issue.type === "warning" && (
|
||||||
|
<FaTriangleExclamation className="size-4 flex-shrink-0 text-yellow-500" />
|
||||||
|
)}
|
||||||
|
{issue.type === "error" && (
|
||||||
|
<LuX className="size-4 flex-shrink-0 text-danger" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
issue.type === "good"
|
||||||
|
? "text-success"
|
||||||
|
: issue.type === "warning"
|
||||||
|
? "text-yellow-500"
|
||||||
|
: "text-danger"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{issue.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type BandwidthDisplayProps = {
|
||||||
|
streamId: string;
|
||||||
|
measuredBandwidth: Map<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function BandwidthDisplay({
|
||||||
|
streamId,
|
||||||
|
measuredBandwidth,
|
||||||
|
}: BandwidthDisplayProps) {
|
||||||
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
const streamBandwidth = measuredBandwidth.get(streamId);
|
||||||
|
|
||||||
|
if (!streamBandwidth) return null;
|
||||||
|
|
||||||
|
const perHour = streamBandwidth * 3600; // kB/hour
|
||||||
|
const perHourDisplay =
|
||||||
|
perHour >= 1000000
|
||||||
|
? `${(perHour / 1000000).toFixed(1)} ${t("unit.data.gbph", { ns: "common" })}`
|
||||||
|
: perHour >= 1000
|
||||||
|
? `${(perHour / 1000).toFixed(1)} ${t("unit.data.mbph", { ns: "common" })}`
|
||||||
|
: `${perHour.toFixed(0)} ${t("unit.data.kbph", { ns: "common" })}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-2 text-sm">
|
||||||
|
<span className="font-medium text-muted-foreground">
|
||||||
|
{t("cameraWizard.step3.estimatedBandwidth")}:
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-secondary-foreground">
|
||||||
|
{streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-muted-foreground">({perHourDisplay})</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreamPreviewProps = {
|
||||||
|
stream: StreamConfig;
|
||||||
|
onBandwidthUpdate?: (streamId: string, bandwidth: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// live stream preview using MSEPlayer with temp go2rtc streams
|
||||||
|
function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
||||||
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
const [streamId, setStreamId] = useState(`wizard_${stream.id}_${Date.now()}`);
|
||||||
|
const [registered, setRegistered] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
const handleStats = useCallback(
|
||||||
|
(stats: PlayerStatsType) => {
|
||||||
|
if (stats.bandwidth > 0) {
|
||||||
|
onBandwidthUpdate?.(stream.id, stats.bandwidth);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[stream.id, onBandwidthUpdate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReload = useCallback(async () => {
|
||||||
|
// Clean up old stream first
|
||||||
|
if (streamId) {
|
||||||
|
axios.delete(`go2rtc/streams/${streamId}`).catch(() => {
|
||||||
|
// do nothing on cleanup errors - go2rtc won't consume the streams
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset state and create new stream ID
|
||||||
|
setError(false);
|
||||||
|
setRegistered(false);
|
||||||
|
setStreamId(`wizard_${stream.id}_${Date.now()}`);
|
||||||
|
}, [stream.id, streamId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Register stream with go2rtc
|
||||||
|
axios
|
||||||
|
.put(`go2rtc/streams/${streamId}`, null, {
|
||||||
|
params: { src: stream.url },
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// Add small delay to allow go2rtc api to run and initialize the stream
|
||||||
|
setTimeout(() => {
|
||||||
|
setRegistered(true);
|
||||||
|
}, 500);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
axios.delete(`go2rtc/streams/${streamId}`).catch(() => {
|
||||||
|
// do nothing on cleanup errors - go2rtc won't consume the streams
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [stream.url, streamId]);
|
||||||
|
|
||||||
|
const resolution = stream.testResult?.resolution;
|
||||||
|
let aspectRatio = "16/9";
|
||||||
|
if (resolution) {
|
||||||
|
const [width, height] = resolution.split("x").map(Number);
|
||||||
|
if (width && height) {
|
||||||
|
aspectRatio = `${width}/${height}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex max-h-[30dvh] flex-col items-center justify-center gap-2 rounded-lg bg-secondary p-4 md:max-h-[20dvh]"
|
||||||
|
style={{ aspectRatio }}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-danger">
|
||||||
|
{t("cameraWizard.step3.streamUnavailable")}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReload}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<LuRotateCcw className="size-4" />
|
||||||
|
{t("cameraWizard.step3.reload")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!registered) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex max-h-[30dvh] items-center justify-center rounded-lg bg-secondary md:max-h-[20dvh]"
|
||||||
|
style={{ aspectRatio }}
|
||||||
|
>
|
||||||
|
<ActivityIndicator className="size-4" />
|
||||||
|
<span className="ml-2 text-sm">
|
||||||
|
{t("cameraWizard.step3.connecting")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative max-h-[30dvh] md:max-h-[20dvh]"
|
||||||
|
style={{ aspectRatio }}
|
||||||
|
>
|
||||||
|
<MSEPlayer
|
||||||
|
camera={streamId}
|
||||||
|
playbackEnabled={true}
|
||||||
|
className="max-h-[30dvh] rounded-lg md:max-h-[20dvh]"
|
||||||
|
getStats={true}
|
||||||
|
setStats={handleStats}
|
||||||
|
onError={() => setError(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
ControllerProps,
|
ControllerProps,
|
||||||
@ -8,27 +8,27 @@ import {
|
|||||||
FieldValues,
|
FieldValues,
|
||||||
FormProvider,
|
FormProvider,
|
||||||
useFormContext,
|
useFormContext,
|
||||||
} from "react-hook-form"
|
} from "react-hook-form";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
const Form = FormProvider
|
const Form = FormProvider;
|
||||||
|
|
||||||
type FormFieldContextValue<
|
type FormFieldContextValue<
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
> = {
|
> = {
|
||||||
name: TName
|
name: TName;
|
||||||
}
|
};
|
||||||
|
|
||||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
{} as FormFieldContextValue
|
{} as FormFieldContextValue,
|
||||||
)
|
);
|
||||||
|
|
||||||
const FormField = <
|
const FormField = <
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
>({
|
>({
|
||||||
...props
|
...props
|
||||||
}: ControllerProps<TFieldValues, TName>) => {
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
@ -36,21 +36,21 @@ const FormField = <
|
|||||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
<Controller {...props} />
|
<Controller {...props} />
|
||||||
</FormFieldContext.Provider>
|
</FormFieldContext.Provider>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const useFormField = () => {
|
const useFormField = () => {
|
||||||
const fieldContext = React.useContext(FormFieldContext)
|
const fieldContext = React.useContext(FormFieldContext);
|
||||||
const itemContext = React.useContext(FormItemContext)
|
const itemContext = React.useContext(FormItemContext);
|
||||||
const { getFieldState, formState } = useFormContext()
|
const { getFieldState, formState } = useFormContext();
|
||||||
|
|
||||||
const fieldState = getFieldState(fieldContext.name, formState)
|
const fieldState = getFieldState(fieldContext.name, formState);
|
||||||
|
|
||||||
if (!fieldContext) {
|
if (!fieldContext) {
|
||||||
throw new Error("useFormField should be used within <FormField>")
|
throw new Error("useFormField should be used within <FormField>");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = itemContext
|
const { id } = itemContext;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@ -59,36 +59,36 @@ const useFormField = () => {
|
|||||||
formDescriptionId: `${id}-form-item-description`,
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
formMessageId: `${id}-form-item-message`,
|
formMessageId: `${id}-form-item-message`,
|
||||||
...fieldState,
|
...fieldState,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
type FormItemContextValue = {
|
type FormItemContextValue = {
|
||||||
id: string
|
id: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
{} as FormItemContextValue
|
{} as FormItemContextValue,
|
||||||
)
|
);
|
||||||
|
|
||||||
const FormItem = React.forwardRef<
|
const FormItem = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => {
|
>(({ className, ...props }, ref) => {
|
||||||
const id = React.useId()
|
const id = React.useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItemContext.Provider value={{ id }}>
|
<FormItemContext.Provider value={{ id }}>
|
||||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
<div ref={ref} className={cn("space-y-1", className)} {...props} />
|
||||||
</FormItemContext.Provider>
|
</FormItemContext.Provider>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
FormItem.displayName = "FormItem"
|
FormItem.displayName = "FormItem";
|
||||||
|
|
||||||
const FormLabel = React.forwardRef<
|
const FormLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
>(({ className, ...props }, ref) => {
|
>(({ className, ...props }, ref) => {
|
||||||
const { error, formItemId } = useFormField()
|
const { error, formItemId } = useFormField();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Label
|
<Label
|
||||||
@ -97,15 +97,16 @@ const FormLabel = React.forwardRef<
|
|||||||
htmlFor={formItemId}
|
htmlFor={formItemId}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
FormLabel.displayName = "FormLabel"
|
FormLabel.displayName = "FormLabel";
|
||||||
|
|
||||||
const FormControl = React.forwardRef<
|
const FormControl = React.forwardRef<
|
||||||
React.ElementRef<typeof Slot>,
|
React.ElementRef<typeof Slot>,
|
||||||
React.ComponentPropsWithoutRef<typeof Slot>
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
>(({ ...props }, ref) => {
|
>(({ ...props }, ref) => {
|
||||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||||
|
useFormField();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Slot
|
<Slot
|
||||||
@ -119,15 +120,15 @@ const FormControl = React.forwardRef<
|
|||||||
aria-invalid={!!error}
|
aria-invalid={!!error}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
FormControl.displayName = "FormControl"
|
FormControl.displayName = "FormControl";
|
||||||
|
|
||||||
const FormDescription = React.forwardRef<
|
const FormDescription = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
>(({ className, ...props }, ref) => {
|
>(({ className, ...props }, ref) => {
|
||||||
const { formDescriptionId } = useFormField()
|
const { formDescriptionId } = useFormField();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
@ -136,19 +137,19 @@ const FormDescription = React.forwardRef<
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
FormDescription.displayName = "FormDescription"
|
FormDescription.displayName = "FormDescription";
|
||||||
|
|
||||||
const FormMessage = React.forwardRef<
|
const FormMessage = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
>(({ className, children, ...props }, ref) => {
|
>(({ className, children, ...props }, ref) => {
|
||||||
const { error, formMessageId } = useFormField()
|
const { error, formMessageId } = useFormField();
|
||||||
const body = error ? String(error?.message) : children
|
const body = error ? String(error?.message) : children;
|
||||||
|
|
||||||
if (!body) {
|
if (!body) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -160,9 +161,9 @@ const FormMessage = React.forwardRef<
|
|||||||
>
|
>
|
||||||
{body}
|
{body}
|
||||||
</p>
|
</p>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
FormMessage.displayName = "FormMessage"
|
FormMessage.displayName = "FormMessage";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useFormField,
|
useFormField,
|
||||||
@ -173,4 +174,4 @@ export {
|
|||||||
FormDescription,
|
FormDescription,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
FormField,
|
FormField,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
@ -11,15 +11,15 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-background_alt",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
Input.displayName = "Input"
|
Input.displayName = "Input";
|
||||||
|
|
||||||
export { Input }
|
export { Input };
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Select = SelectPrimitive.Root
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
const SelectGroup = SelectPrimitive.Group
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
const SelectValue = SelectPrimitive.Value
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
const SelectTrigger = React.forwardRef<
|
const SelectTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
@ -17,8 +17,8 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-background_alt [&>span]:line-clamp-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@ -27,8 +27,8 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
))
|
));
|
||||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
const SelectScrollUpButton = React.forwardRef<
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
@ -38,14 +38,14 @@ const SelectScrollUpButton = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-center py-1",
|
"flex cursor-default items-center justify-center py-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUp className="h-4 w-4" />
|
<ChevronUp className="h-4 w-4" />
|
||||||
</SelectPrimitive.ScrollUpButton>
|
</SelectPrimitive.ScrollUpButton>
|
||||||
))
|
));
|
||||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
const SelectScrollDownButton = React.forwardRef<
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
@ -55,15 +55,15 @@ const SelectScrollDownButton = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-center py-1",
|
"flex cursor-default items-center justify-center py-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
</SelectPrimitive.ScrollDownButton>
|
</SelectPrimitive.ScrollDownButton>
|
||||||
))
|
));
|
||||||
SelectScrollDownButton.displayName =
|
SelectScrollDownButton.displayName =
|
||||||
SelectPrimitive.ScrollDownButton.displayName
|
SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
const SelectContent = React.forwardRef<
|
const SelectContent = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
@ -76,7 +76,7 @@ const SelectContent = React.forwardRef<
|
|||||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
{...props}
|
{...props}
|
||||||
@ -86,7 +86,7 @@ const SelectContent = React.forwardRef<
|
|||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"p-1",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@ -94,8 +94,8 @@ const SelectContent = React.forwardRef<
|
|||||||
<SelectScrollDownButton />
|
<SelectScrollDownButton />
|
||||||
</SelectPrimitive.Content>
|
</SelectPrimitive.Content>
|
||||||
</SelectPrimitive.Portal>
|
</SelectPrimitive.Portal>
|
||||||
))
|
));
|
||||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
const SelectLabel = React.forwardRef<
|
const SelectLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
@ -106,8 +106,8 @@ const SelectLabel = React.forwardRef<
|
|||||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
const SelectItem = React.forwardRef<
|
const SelectItem = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
@ -116,8 +116,8 @@ const SelectItem = React.forwardRef<
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@ -129,8 +129,8 @@ const SelectItem = React.forwardRef<
|
|||||||
|
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
))
|
));
|
||||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
const SelectSeparator = React.forwardRef<
|
const SelectSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
@ -141,8 +141,8 @@ const SelectSeparator = React.forwardRef<
|
|||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Select,
|
Select,
|
||||||
@ -155,4 +155,4 @@ export {
|
|||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
SelectScrollUpButton,
|
SelectScrollUpButton,
|
||||||
SelectScrollDownButton,
|
SelectScrollDownButton,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import FilterSwitch from "@/components/filter/FilterSwitch";
|
|||||||
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
||||||
import { PolygonType } from "@/types/canvas";
|
import { PolygonType } from "@/types/canvas";
|
||||||
import CameraSettingsView from "@/views/settings/CameraSettingsView";
|
import CameraSettingsView from "@/views/settings/CameraSettingsView";
|
||||||
|
import CameraManagementView from "@/views/settings/CameraManagementView";
|
||||||
import MotionTunerView from "@/views/settings/MotionTunerView";
|
import MotionTunerView from "@/views/settings/MotionTunerView";
|
||||||
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
|
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
|
||||||
import UsersView from "@/views/settings/UsersView";
|
import UsersView from "@/views/settings/UsersView";
|
||||||
@ -70,7 +71,8 @@ import {
|
|||||||
const allSettingsViews = [
|
const allSettingsViews = [
|
||||||
"ui",
|
"ui",
|
||||||
"enrichments",
|
"enrichments",
|
||||||
"cameras",
|
"cameraManagement",
|
||||||
|
"cameraReview",
|
||||||
"masksAndZones",
|
"masksAndZones",
|
||||||
"motionTuner",
|
"motionTuner",
|
||||||
"triggers",
|
"triggers",
|
||||||
@ -90,7 +92,8 @@ const settingsGroups = [
|
|||||||
{
|
{
|
||||||
label: "cameras",
|
label: "cameras",
|
||||||
items: [
|
items: [
|
||||||
{ key: "cameras", component: CameraSettingsView },
|
{ key: "cameraManagement", component: CameraManagementView },
|
||||||
|
{ key: "cameraReview", component: CameraSettingsView },
|
||||||
{ key: "masksAndZones", component: MasksAndZonesView },
|
{ key: "masksAndZones", component: MasksAndZonesView },
|
||||||
{ key: "motionTuner", component: MotionTunerView },
|
{ key: "motionTuner", component: MotionTunerView },
|
||||||
],
|
],
|
||||||
@ -119,6 +122,16 @@ const settingsGroups = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const CAMERA_SELECT_BUTTON_PAGES = [
|
||||||
|
"debug",
|
||||||
|
"cameraReview",
|
||||||
|
"masksAndZones",
|
||||||
|
"motionTuner",
|
||||||
|
"triggers",
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALLOWED_VIEWS_FOR_VIEWER = ["ui", "debug", "notifications"];
|
||||||
|
|
||||||
const getCurrentComponent = (page: SettingsType) => {
|
const getCurrentComponent = (page: SettingsType) => {
|
||||||
for (const group of settingsGroups) {
|
for (const group of settingsGroups) {
|
||||||
for (const item of group.items) {
|
for (const item of group.items) {
|
||||||
@ -172,13 +185,8 @@ export default function Settings() {
|
|||||||
|
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
const allowedViewsForViewer: SettingsType[] = [
|
|
||||||
"ui",
|
|
||||||
"debug",
|
|
||||||
"notifications",
|
|
||||||
];
|
|
||||||
const visibleSettingsViews = !isAdmin
|
const visibleSettingsViews = !isAdmin
|
||||||
? allowedViewsForViewer
|
? ALLOWED_VIEWS_FOR_VIEWER
|
||||||
: allSettingsViews;
|
: allSettingsViews;
|
||||||
|
|
||||||
// TODO: confirm leave page
|
// TODO: confirm leave page
|
||||||
@ -242,7 +250,7 @@ export default function Settings() {
|
|||||||
setSelectedCamera(firstEnabledCamera.name);
|
setSelectedCamera(firstEnabledCamera.name);
|
||||||
} else if (
|
} else if (
|
||||||
!cameraEnabledStates[selectedCamera] &&
|
!cameraEnabledStates[selectedCamera] &&
|
||||||
pageToggle !== "cameras"
|
pageToggle !== "cameraReview"
|
||||||
) {
|
) {
|
||||||
// Switch to first enabled camera if current one is disabled, unless on "camera settings" page
|
// Switch to first enabled camera if current one is disabled, unless on "camera settings" page
|
||||||
const firstEnabledCamera =
|
const firstEnabledCamera =
|
||||||
@ -257,7 +265,10 @@ export default function Settings() {
|
|||||||
useSearchEffect("page", (page: string) => {
|
useSearchEffect("page", (page: string) => {
|
||||||
if (allSettingsViews.includes(page as SettingsType)) {
|
if (allSettingsViews.includes(page as SettingsType)) {
|
||||||
// Restrict viewer to UI settings
|
// Restrict viewer to UI settings
|
||||||
if (!isAdmin && !allowedViewsForViewer.includes(page as SettingsType)) {
|
if (
|
||||||
|
!isAdmin &&
|
||||||
|
!ALLOWED_VIEWS_FOR_VIEWER.includes(page as SettingsType)
|
||||||
|
) {
|
||||||
setPageToggle("ui");
|
setPageToggle("ui");
|
||||||
} else {
|
} else {
|
||||||
setPageToggle(page as SettingsType);
|
setPageToggle(page as SettingsType);
|
||||||
@ -321,7 +332,9 @@ export default function Settings() {
|
|||||||
onSelect={(key) => {
|
onSelect={(key) => {
|
||||||
if (
|
if (
|
||||||
!isAdmin &&
|
!isAdmin &&
|
||||||
!allowedViewsForViewer.includes(key as SettingsType)
|
!ALLOWED_VIEWS_FOR_VIEWER.includes(
|
||||||
|
key as SettingsType,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
setPageToggle("ui");
|
setPageToggle("ui");
|
||||||
} else {
|
} else {
|
||||||
@ -348,13 +361,7 @@ export default function Settings() {
|
|||||||
className="top-0 mb-0"
|
className="top-0 mb-0"
|
||||||
onClose={() => navigate(-1)}
|
onClose={() => navigate(-1)}
|
||||||
actions={
|
actions={
|
||||||
[
|
CAMERA_SELECT_BUTTON_PAGES.includes(pageToggle) ? (
|
||||||
"debug",
|
|
||||||
"cameras",
|
|
||||||
"masksAndZones",
|
|
||||||
"motionTuner",
|
|
||||||
"triggers",
|
|
||||||
].includes(pageToggle) ? (
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{pageToggle == "masksAndZones" && (
|
{pageToggle == "masksAndZones" && (
|
||||||
<ZoneMaskFilterButton
|
<ZoneMaskFilterButton
|
||||||
@ -426,13 +433,7 @@ export default function Settings() {
|
|||||||
<Heading as="h3" className="mb-0">
|
<Heading as="h3" className="mb-0">
|
||||||
{t("menu.settings", { ns: "common" })}
|
{t("menu.settings", { ns: "common" })}
|
||||||
</Heading>
|
</Heading>
|
||||||
{[
|
{CAMERA_SELECT_BUTTON_PAGES.includes(page) && (
|
||||||
"debug",
|
|
||||||
"cameras",
|
|
||||||
"masksAndZones",
|
|
||||||
"motionTuner",
|
|
||||||
"triggers",
|
|
||||||
].includes(page) && (
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{pageToggle == "masksAndZones" && (
|
{pageToggle == "masksAndZones" && (
|
||||||
<ZoneMaskFilterButton
|
<ZoneMaskFilterButton
|
||||||
@ -470,7 +471,7 @@ export default function Settings() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (
|
if (
|
||||||
!isAdmin &&
|
!isAdmin &&
|
||||||
!allowedViewsForViewer.includes(
|
!ALLOWED_VIEWS_FOR_VIEWER.includes(
|
||||||
filteredItems[0].key as SettingsType,
|
filteredItems[0].key as SettingsType,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
@ -512,7 +513,7 @@ export default function Settings() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (
|
if (
|
||||||
!isAdmin &&
|
!isAdmin &&
|
||||||
!allowedViewsForViewer.includes(
|
!ALLOWED_VIEWS_FOR_VIEWER.includes(
|
||||||
item.key as SettingsType,
|
item.key as SettingsType,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
@ -635,7 +636,7 @@ function CameraSelectButton({
|
|||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-2.5">
|
||||||
{allCameras.map((item) => {
|
{allCameras.map((item) => {
|
||||||
const isEnabled = cameraEnabledStates[item.name];
|
const isEnabled = cameraEnabledStates[item.name];
|
||||||
const isCameraSettingsPage = currentPage === "cameras";
|
const isCameraSettingsPage = currentPage === "cameraReview";
|
||||||
return (
|
return (
|
||||||
<FilterSwitch
|
<FilterSwitch
|
||||||
key={item.name}
|
key={item.name}
|
||||||
|
|||||||
154
web/src/types/cameraWizard.ts
Normal file
154
web/src/types/cameraWizard.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
// Camera Wizard Types
|
||||||
|
export const CAMERA_BRANDS = [
|
||||||
|
{
|
||||||
|
value: "dahua" as const,
|
||||||
|
label: "Dahua / Amcrest / EmpireTech",
|
||||||
|
template:
|
||||||
|
"rtsp://{username}:{password}@{host}:554/cam/realmonitor?channel=1&subtype=0",
|
||||||
|
exampleUrl:
|
||||||
|
"rtsp://admin:password@192.168.1.100:554/cam/realmonitor?channel=1&subtype=0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "hikvision" as const,
|
||||||
|
label: "Hikvision / Uniview / Annke",
|
||||||
|
template: "rtsp://{username}:{password}@{host}:554/Streaming/Channels/101",
|
||||||
|
exampleUrl:
|
||||||
|
"rtsp://admin:password@192.168.1.100:554/Streaming/Channels/101",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "ubiquiti" as const,
|
||||||
|
label: "Ubiquiti",
|
||||||
|
template: "rtsp://{username}:{password}@{host}:554/live/ch0",
|
||||||
|
exampleUrl: "rtsp://ubnt:password@192.168.1.100:554/live/ch0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "reolink" as const,
|
||||||
|
label: "Reolink",
|
||||||
|
template: "rtsp://{username}:{password}@{host}:554/h264Preview_01_main",
|
||||||
|
exampleUrl: "rtsp://admin:password@192.168.1.100:554/h264Preview_01_main",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "axis" as const,
|
||||||
|
label: "Axis",
|
||||||
|
template: "rtsp://{username}:{password}@{host}:554/axis-media/media.amp",
|
||||||
|
exampleUrl: "rtsp://root:password@192.168.1.100:554/axis-media/media.amp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "tplink" as const,
|
||||||
|
label: "TP-Link",
|
||||||
|
template: "rtsp://{username}:{password}@{host}:554/stream1",
|
||||||
|
exampleUrl: "rtsp://admin:password@192.168.1.100:554/stream1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "foscam" as const,
|
||||||
|
label: "Foscam",
|
||||||
|
template: "rtsp://{username}:{password}@{host}:88/videoMain",
|
||||||
|
exampleUrl: "rtsp://admin:password@192.168.1.100:88/videoMain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "other" as const,
|
||||||
|
label: "Other",
|
||||||
|
template: "",
|
||||||
|
exampleUrl: "rtsp://username:password@host:port/path",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const CAMERA_BRAND_VALUES = CAMERA_BRANDS.map(
|
||||||
|
(brand) => brand.value,
|
||||||
|
) as unknown as [
|
||||||
|
(typeof CAMERA_BRANDS)[number]["value"],
|
||||||
|
...(typeof CAMERA_BRANDS)[number]["value"][],
|
||||||
|
];
|
||||||
|
|
||||||
|
export type CameraBrand = (typeof CAMERA_BRANDS)[number]["value"];
|
||||||
|
|
||||||
|
export type StreamRole = "detect" | "record" | "audio";
|
||||||
|
|
||||||
|
export type StreamConfig = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
roles: StreamRole[];
|
||||||
|
resolution?: string;
|
||||||
|
quality?: string;
|
||||||
|
testResult?: TestResult;
|
||||||
|
userTested?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestResult = {
|
||||||
|
success: boolean;
|
||||||
|
snapshot?: string; // base64 image
|
||||||
|
resolution?: string;
|
||||||
|
videoCodec?: string;
|
||||||
|
audioCodec?: string;
|
||||||
|
fps?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WizardFormData = {
|
||||||
|
cameraName?: string;
|
||||||
|
host?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
brandTemplate?: CameraBrand;
|
||||||
|
customUrl?: string;
|
||||||
|
streams?: StreamConfig[];
|
||||||
|
restreamIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// API Response Types
|
||||||
|
export type FfprobeResponse = {
|
||||||
|
return_code: number;
|
||||||
|
stderr: string;
|
||||||
|
stdout: FfprobeData | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FfprobeData = {
|
||||||
|
streams: FfprobeStream[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FfprobeStream = {
|
||||||
|
index?: number;
|
||||||
|
codec_name?: string;
|
||||||
|
codec_long_name?: string;
|
||||||
|
codec_type?: "video" | "audio";
|
||||||
|
profile?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
pix_fmt?: string;
|
||||||
|
level?: number;
|
||||||
|
r_frame_rate?: string;
|
||||||
|
avg_frame_rate?: string;
|
||||||
|
sample_rate?: string;
|
||||||
|
channels?: number;
|
||||||
|
channel_layout?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Config API Types
|
||||||
|
export type CameraConfigData = {
|
||||||
|
cameras: {
|
||||||
|
[cameraName: string]: {
|
||||||
|
enabled: boolean;
|
||||||
|
friendly_name?: string;
|
||||||
|
ffmpeg: {
|
||||||
|
inputs: {
|
||||||
|
path: string;
|
||||||
|
roles: string[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
live?: {
|
||||||
|
streams: Record<string, string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
go2rtc?: {
|
||||||
|
streams: {
|
||||||
|
[streamName: string]: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfigSetBody = {
|
||||||
|
requires_restart: number;
|
||||||
|
config_data: CameraConfigData;
|
||||||
|
update_topic?: string;
|
||||||
|
};
|
||||||
60
web/src/utils/cameraUtil.ts
Normal file
60
web/src/utils/cameraUtil.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Generates a fixed-length hash from a camera name for use as a valid camera identifier.
|
||||||
|
* Works safely with Unicode input while outputting Latin-only identifiers.
|
||||||
|
*
|
||||||
|
* @param name - The original camera name/display name
|
||||||
|
* @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars)
|
||||||
|
*/
|
||||||
|
export function generateFixedHash(name: string): string {
|
||||||
|
// Safely encode Unicode as UTF-8 bytes
|
||||||
|
const utf8Bytes = new TextEncoder().encode(name);
|
||||||
|
|
||||||
|
// Convert to base64 manually
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of utf8Bytes) {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
const base64 = btoa(binary);
|
||||||
|
|
||||||
|
// Strip out non-alphanumeric characters and truncate
|
||||||
|
const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
|
||||||
|
|
||||||
|
return `cam_${cleanHash.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a string is a valid camera name identifier.
|
||||||
|
* Valid camera names contain only ASCII letters, numbers, underscores, and hyphens.
|
||||||
|
*
|
||||||
|
* @param name - The camera name to validate
|
||||||
|
* @returns True if the name is valid, false otherwise
|
||||||
|
*/
|
||||||
|
export function isValidCameraName(name: string): boolean {
|
||||||
|
return /^[a-zA-Z0-9_-]+$/.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a user-entered camera name and returns both the final camera name
|
||||||
|
* and friendly name for Frigate configuration.
|
||||||
|
*
|
||||||
|
* @param userInput - The name entered by the user (could be display name)
|
||||||
|
* @returns Object with finalCameraName and friendlyName
|
||||||
|
*/
|
||||||
|
export function processCameraName(userInput: string): {
|
||||||
|
finalCameraName: string;
|
||||||
|
friendlyName?: string;
|
||||||
|
} {
|
||||||
|
const normalizedInput = userInput.replace(/\s+/g, "_").toLowerCase();
|
||||||
|
|
||||||
|
if (isValidCameraName(normalizedInput)) {
|
||||||
|
return {
|
||||||
|
finalCameraName: normalizedInput,
|
||||||
|
friendlyName: userInput.includes(" ") ? userInput : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
finalCameraName: generateFixedHash(userInput),
|
||||||
|
friendlyName: userInput,
|
||||||
|
};
|
||||||
|
}
|
||||||
199
web/src/views/settings/CameraManagementView.tsx
Normal file
199
web/src/views/settings/CameraManagementView.tsx
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import Heading from "@/components/ui/heading";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import CameraEditForm from "@/components/settings/CameraEditForm";
|
||||||
|
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||||
|
import { LuPlus } from "react-icons/lu";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
|
import { isDesktop } from "react-device-detect";
|
||||||
|
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Trans } from "react-i18next";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { useEnabledState } from "@/api/ws";
|
||||||
|
|
||||||
|
type CameraManagementViewProps = {
|
||||||
|
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CameraManagementView({
|
||||||
|
setUnsavedChanges,
|
||||||
|
}: CameraManagementViewProps) {
|
||||||
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
|
||||||
|
const { data: config, mutate: updateConfig } =
|
||||||
|
useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<"settings" | "add" | "edit">(
|
||||||
|
"settings",
|
||||||
|
); // Control view state
|
||||||
|
const [editCameraName, setEditCameraName] = useState<string | undefined>(
|
||||||
|
undefined,
|
||||||
|
); // Track camera being edited
|
||||||
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
|
|
||||||
|
// List of cameras for dropdown
|
||||||
|
const cameras = useMemo(() => {
|
||||||
|
if (config) {
|
||||||
|
return Object.keys(config.cameras).sort();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = t("documentTitle.cameraManagement");
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
// Handle back navigation from add/edit form
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
setViewMode("settings");
|
||||||
|
setEditCameraName(undefined);
|
||||||
|
setUnsavedChanges(false);
|
||||||
|
updateConfig();
|
||||||
|
}, [updateConfig, setUnsavedChanges]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex size-full flex-col md:flex-row">
|
||||||
|
<Toaster position="top-center" closeButton={true} />
|
||||||
|
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||||
|
{viewMode === "settings" ? (
|
||||||
|
<>
|
||||||
|
<Heading as="h4" className="mb-2">
|
||||||
|
{t("cameraManagement.title")}
|
||||||
|
</Heading>
|
||||||
|
<div className="my-4 flex flex-col gap-4">
|
||||||
|
<Button
|
||||||
|
variant="select"
|
||||||
|
onClick={() => setShowWizard(true)}
|
||||||
|
className="flex max-w-48 items-center gap-2"
|
||||||
|
>
|
||||||
|
<LuPlus className="h-4 w-4" />
|
||||||
|
{t("cameraManagement.addCamera")}
|
||||||
|
</Button>
|
||||||
|
{cameras.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="my-4 flex flex-col gap-2">
|
||||||
|
<Label>{t("cameraManagement.editCamera")}</Label>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setEditCameraName(value);
|
||||||
|
setViewMode("edit");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t("cameraManagement.selectCamera")}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{cameras.map((camera) => {
|
||||||
|
return (
|
||||||
|
<SelectItem key={camera} value={camera}>
|
||||||
|
<CameraNameLabel camera={camera} />
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-2 flex bg-secondary" />
|
||||||
|
<div className="max-w-7xl space-y-4">
|
||||||
|
<Heading as="h4" className="my-2">
|
||||||
|
<Trans ns="views/settings">
|
||||||
|
cameraManagement.streams.title
|
||||||
|
</Trans>
|
||||||
|
</Heading>
|
||||||
|
<div className="mt-3 text-sm text-muted-foreground">
|
||||||
|
<Trans ns="views/settings">
|
||||||
|
cameraManagement.streams.desc
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
|
||||||
|
{cameras.map((camera) => (
|
||||||
|
<div
|
||||||
|
key={camera}
|
||||||
|
className="flex items-center justify-between smart-capitalize"
|
||||||
|
>
|
||||||
|
<CameraNameLabel camera={camera} />
|
||||||
|
<CameraEnableSwitch cameraName={camera} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="mb-2 mt-4 flex bg-secondary" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
className={`flex items-center gap-2.5 rounded-lg`}
|
||||||
|
aria-label={t("label.back", { ns: "common" })}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBack}
|
||||||
|
>
|
||||||
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||||
|
{isDesktop && (
|
||||||
|
<div className="text-primary">
|
||||||
|
{t("button.back", { ns: "common" })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="md:max-w-5xl">
|
||||||
|
<CameraEditForm
|
||||||
|
cameraName={viewMode === "edit" ? editCameraName : undefined}
|
||||||
|
onSave={handleBack}
|
||||||
|
onCancel={handleBack}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CameraWizardDialog
|
||||||
|
open={showWizard}
|
||||||
|
onClose={() => setShowWizard(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CameraEnableSwitchProps = {
|
||||||
|
cameraName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) {
|
||||||
|
const { payload: enabledState, send: sendEnabled } =
|
||||||
|
useEnabledState(cameraName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<Switch
|
||||||
|
id={`camera-enabled-${cameraName}`}
|
||||||
|
checked={enabledState === "ON"}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
sendEnabled(isChecked ? "ON" : "OFF");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -34,23 +34,14 @@ import { getTranslatedLabel } from "@/utils/i18n";
|
|||||||
import {
|
import {
|
||||||
useAlertsState,
|
useAlertsState,
|
||||||
useDetectionsState,
|
useDetectionsState,
|
||||||
useEnabledState,
|
|
||||||
useObjectDescriptionState,
|
useObjectDescriptionState,
|
||||||
useReviewDescriptionState,
|
useReviewDescriptionState,
|
||||||
} from "@/api/ws";
|
} from "@/api/ws";
|
||||||
import CameraEditForm from "@/components/settings/CameraEditForm";
|
import CameraEditForm from "@/components/settings/CameraEditForm";
|
||||||
import { LuPlus } from "react-icons/lu";
|
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
|
||||||
|
|
||||||
type CameraSettingsViewProps = {
|
type CameraSettingsViewProps = {
|
||||||
selectedCamera: string;
|
selectedCamera: string;
|
||||||
@ -87,17 +78,10 @@ export default function CameraSettingsView({
|
|||||||
const [editCameraName, setEditCameraName] = useState<string | undefined>(
|
const [editCameraName, setEditCameraName] = useState<string | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
); // Track camera being edited
|
); // Track camera being edited
|
||||||
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
|
|
||||||
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
|
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
|
||||||
|
|
||||||
// List of cameras for dropdown
|
|
||||||
const cameras = useMemo(() => {
|
|
||||||
if (config) {
|
|
||||||
return Object.keys(config.cameras).sort();
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
const selectCameraName = useCameraFriendlyName(selectedCamera);
|
const selectCameraName = useCameraFriendlyName(selectedCamera);
|
||||||
|
|
||||||
// zones and labels
|
// zones and labels
|
||||||
@ -148,8 +132,6 @@ export default function CameraSettingsView({
|
|||||||
const watchedAlertsZones = form.watch("alerts_zones");
|
const watchedAlertsZones = form.watch("alerts_zones");
|
||||||
const watchedDetectionsZones = form.watch("detections_zones");
|
const watchedDetectionsZones = form.watch("detections_zones");
|
||||||
|
|
||||||
const { payload: enabledState, send: sendEnabled } =
|
|
||||||
useEnabledState(selectedCamera);
|
|
||||||
const { payload: alertsState, send: sendAlerts } =
|
const { payload: alertsState, send: sendAlerts } =
|
||||||
useAlertsState(selectedCamera);
|
useAlertsState(selectedCamera);
|
||||||
const { payload: detectionsState, send: sendDetections } =
|
const { payload: detectionsState, send: sendDetections } =
|
||||||
@ -202,9 +184,12 @@ export default function CameraSettingsView({
|
|||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
toast.success(t("camera.reviewClassification.toast.success"), {
|
toast.success(
|
||||||
position: "top-center",
|
t("cameraReview.reviewClassification.toast.success"),
|
||||||
});
|
{
|
||||||
|
position: "top-center",
|
||||||
|
},
|
||||||
|
);
|
||||||
updateConfig();
|
updateConfig();
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
@ -272,7 +257,7 @@ export default function CameraSettingsView({
|
|||||||
if (changedValue) {
|
if (changedValue) {
|
||||||
addMessage(
|
addMessage(
|
||||||
"camera_settings",
|
"camera_settings",
|
||||||
t("camera.reviewClassification.unsavedChanges", {
|
t("cameraReview.reviewClassification.unsavedChanges", {
|
||||||
camera: selectedCamera,
|
camera: selectedCamera,
|
||||||
}),
|
}),
|
||||||
undefined,
|
undefined,
|
||||||
@ -295,7 +280,7 @@ export default function CameraSettingsView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t("documentTitle.camera");
|
document.title = t("documentTitle.cameraReview");
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
// Handle back navigation from add/edit form
|
// Handle back navigation from add/edit form
|
||||||
@ -317,70 +302,11 @@ export default function CameraSettingsView({
|
|||||||
{viewMode === "settings" ? (
|
{viewMode === "settings" ? (
|
||||||
<>
|
<>
|
||||||
<Heading as="h4" className="mb-2">
|
<Heading as="h4" className="mb-2">
|
||||||
{t("camera.title")}
|
{t("cameraReview.title")}
|
||||||
</Heading>
|
|
||||||
<div className="mb-4 flex flex-col gap-4">
|
|
||||||
<Button
|
|
||||||
variant="select"
|
|
||||||
onClick={() => setViewMode("add")}
|
|
||||||
className="flex max-w-48 items-center gap-2"
|
|
||||||
>
|
|
||||||
<LuPlus className="h-4 w-4" />
|
|
||||||
{t("camera.addCamera")}
|
|
||||||
</Button>
|
|
||||||
{cameras.length > 0 && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Label>{t("camera.editCamera")}</Label>
|
|
||||||
<Select
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setEditCameraName(value);
|
|
||||||
setViewMode("edit");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[180px]">
|
|
||||||
<SelectValue placeholder={t("camera.selectCamera")} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{cameras.map((camera) => {
|
|
||||||
return (
|
|
||||||
<SelectItem key={camera} value={camera}>
|
|
||||||
<CameraNameLabel camera={camera} />
|
|
||||||
</SelectItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Separator className="my-2 flex bg-secondary" />
|
|
||||||
|
|
||||||
<Heading as="h4" className="my-2">
|
|
||||||
<Trans ns="views/settings">camera.streams.title</Trans>
|
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<div className="flex flex-row items-center">
|
|
||||||
<Switch
|
|
||||||
id="camera-enabled"
|
|
||||||
className="mr-3"
|
|
||||||
checked={enabledState === "ON"}
|
|
||||||
onCheckedChange={(isChecked) => {
|
|
||||||
sendEnabled(isChecked ? "ON" : "OFF");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label htmlFor="camera-enabled">
|
|
||||||
<Trans>button.enabled</Trans>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-sm text-muted-foreground">
|
|
||||||
<Trans ns="views/settings">camera.streams.desc</Trans>
|
|
||||||
</div>
|
|
||||||
<Separator className="mb-2 mt-4 flex bg-secondary" />
|
|
||||||
|
|
||||||
<Heading as="h4" className="my-2">
|
<Heading as="h4" className="my-2">
|
||||||
<Trans ns="views/settings">camera.review.title</Trans>
|
<Trans ns="views/settings">cameraReview.review.title</Trans>
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
||||||
@ -395,7 +321,9 @@ export default function CameraSettingsView({
|
|||||||
/>
|
/>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="alerts-enabled">
|
<Label htmlFor="alerts-enabled">
|
||||||
<Trans ns="views/settings">camera.review.alerts</Trans>
|
<Trans ns="views/settings">
|
||||||
|
cameraReview.review.alerts
|
||||||
|
</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -418,7 +346,7 @@ export default function CameraSettingsView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-sm text-muted-foreground">
|
<div className="mt-3 text-sm text-muted-foreground">
|
||||||
<Trans ns="views/settings">camera.review.desc</Trans>
|
<Trans ns="views/settings">cameraReview.review.desc</Trans>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -428,7 +356,7 @@ export default function CameraSettingsView({
|
|||||||
|
|
||||||
<Heading as="h4" className="my-2">
|
<Heading as="h4" className="my-2">
|
||||||
<Trans ns="views/settings">
|
<Trans ns="views/settings">
|
||||||
camera.object_descriptions.title
|
cameraReview.object_descriptions.title
|
||||||
</Trans>
|
</Trans>
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
@ -450,7 +378,7 @@ export default function CameraSettingsView({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-sm text-muted-foreground">
|
<div className="mt-3 text-sm text-muted-foreground">
|
||||||
<Trans ns="views/settings">
|
<Trans ns="views/settings">
|
||||||
camera.object_descriptions.desc
|
cameraReview.object_descriptions.desc
|
||||||
</Trans>
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -463,7 +391,7 @@ export default function CameraSettingsView({
|
|||||||
|
|
||||||
<Heading as="h4" className="my-2">
|
<Heading as="h4" className="my-2">
|
||||||
<Trans ns="views/settings">
|
<Trans ns="views/settings">
|
||||||
camera.review_descriptions.title
|
cameraReview.review_descriptions.title
|
||||||
</Trans>
|
</Trans>
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
@ -485,7 +413,7 @@ export default function CameraSettingsView({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-sm text-muted-foreground">
|
<div className="mt-3 text-sm text-muted-foreground">
|
||||||
<Trans ns="views/settings">
|
<Trans ns="views/settings">
|
||||||
camera.review_descriptions.desc
|
cameraReview.review_descriptions.desc
|
||||||
</Trans>
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -496,7 +424,7 @@ export default function CameraSettingsView({
|
|||||||
|
|
||||||
<Heading as="h4" className="my-2">
|
<Heading as="h4" className="my-2">
|
||||||
<Trans ns="views/settings">
|
<Trans ns="views/settings">
|
||||||
camera.reviewClassification.title
|
cameraReview.reviewClassification.title
|
||||||
</Trans>
|
</Trans>
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
@ -504,7 +432,7 @@ export default function CameraSettingsView({
|
|||||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||||
<p>
|
<p>
|
||||||
<Trans ns="views/settings">
|
<Trans ns="views/settings">
|
||||||
camera.reviewClassification.desc
|
cameraReview.reviewClassification.desc
|
||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center text-primary">
|
<div className="flex items-center text-primary">
|
||||||
@ -550,7 +478,7 @@ export default function CameraSettingsView({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans ns="views/settings">
|
<Trans ns="views/settings">
|
||||||
camera.reviewClassification.selectAlertsZones
|
cameraReview.reviewClassification.selectAlertsZones
|
||||||
</Trans>
|
</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
@ -599,7 +527,7 @@ export default function CameraSettingsView({
|
|||||||
) : (
|
) : (
|
||||||
<div className="font-normal text-destructive">
|
<div className="font-normal text-destructive">
|
||||||
<Trans ns="views/settings">
|
<Trans ns="views/settings">
|
||||||
camera.reviewClassification.noDefinedZones
|
cameraReview.reviewClassification.noDefinedZones
|
||||||
</Trans>
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -607,7 +535,7 @@ export default function CameraSettingsView({
|
|||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{watchedAlertsZones && watchedAlertsZones.length > 0
|
{watchedAlertsZones && watchedAlertsZones.length > 0
|
||||||
? t(
|
? t(
|
||||||
"camera.reviewClassification.zoneObjectAlertsTips",
|
"cameraReview.reviewClassification.zoneObjectAlertsTips",
|
||||||
{
|
{
|
||||||
alertsLabels,
|
alertsLabels,
|
||||||
zone: watchedAlertsZones
|
zone: watchedAlertsZones
|
||||||
@ -622,7 +550,7 @@ export default function CameraSettingsView({
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
: t(
|
: t(
|
||||||
"camera.reviewClassification.objectAlertsTips",
|
"cameraReview.reviewClassification.objectAlertsTips",
|
||||||
{
|
{
|
||||||
alertsLabels,
|
alertsLabels,
|
||||||
cameraName: selectCameraName,
|
cameraName: selectCameraName,
|
||||||
@ -650,7 +578,7 @@ export default function CameraSettingsView({
|
|||||||
{selectDetections && (
|
{selectDetections && (
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans ns="views/settings">
|
<Trans ns="views/settings">
|
||||||
camera.reviewClassification.selectDetectionsZones
|
cameraReview.reviewClassification.selectDetectionsZones
|
||||||
</Trans>
|
</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
)}
|
)}
|
||||||
@ -713,7 +641,7 @@ export default function CameraSettingsView({
|
|||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
>
|
>
|
||||||
<Trans ns="views/settings">
|
<Trans ns="views/settings">
|
||||||
camera.reviewClassification.limitDetections
|
cameraReview.reviewClassification.limitDetections
|
||||||
</Trans>
|
</Trans>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -726,7 +654,7 @@ export default function CameraSettingsView({
|
|||||||
watchedDetectionsZones.length > 0 ? (
|
watchedDetectionsZones.length > 0 ? (
|
||||||
!selectDetections ? (
|
!selectDetections ? (
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="camera.reviewClassification.zoneObjectDetectionsTips.text"
|
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
|
||||||
values={{
|
values={{
|
||||||
detectionsLabels,
|
detectionsLabels,
|
||||||
zone: watchedDetectionsZones
|
zone: watchedDetectionsZones
|
||||||
@ -743,7 +671,7 @@ export default function CameraSettingsView({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="camera.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
|
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
|
||||||
values={{
|
values={{
|
||||||
detectionsLabels,
|
detectionsLabels,
|
||||||
zone: watchedDetectionsZones
|
zone: watchedDetectionsZones
|
||||||
@ -761,7 +689,7 @@ export default function CameraSettingsView({
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="camera.reviewClassification.objectDetectionsTips"
|
i18nKey="cameraReview.reviewClassification.objectDetectionsTips"
|
||||||
values={{
|
values={{
|
||||||
detectionsLabels,
|
detectionsLabels,
|
||||||
cameraName: selectCameraName,
|
cameraName: selectCameraName,
|
||||||
@ -835,6 +763,11 @@ export default function CameraSettingsView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CameraWizardDialog
|
||||||
|
open={showWizard}
|
||||||
|
onClose={() => setShowWizard(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user