From 9d85136f8f8c86e6eff2a9d3a3312fc749918ffd Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 13 Oct 2025 11:52:08 -0500 Subject: [PATCH] Add Camera Wizard (#20461) * 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 --- frigate/api/app.py | 213 +++++- frigate/util/image.py | 84 ++- frigate/util/services.py | 25 +- web/public/locales/en/common.json | 8 + web/public/locales/en/views/settings.json | 205 +++++- .../components/indicators/StepIndicator.tsx | 30 +- .../components/settings/CameraEditForm.tsx | 455 ++++++++---- .../settings/CameraWizardDialog.tsx | 398 ++++++++++ .../settings/wizard/Step1NameCamera.tsx | 615 ++++++++++++++++ .../settings/wizard/Step2StreamConfig.tsx | 487 ++++++++++++ .../settings/wizard/Step3Validation.tsx | 690 ++++++++++++++++++ web/src/components/ui/form.tsx | 103 +-- web/src/components/ui/input.tsx | 18 +- web/src/components/ui/select.tsx | 60 +- web/src/pages/Settings.tsx | 57 +- web/src/types/cameraWizard.ts | 154 ++++ web/src/utils/cameraUtil.ts | 60 ++ .../views/settings/CameraManagementView.tsx | 199 +++++ web/src/views/settings/CameraSettingsView.tsx | 139 +--- 19 files changed, 3571 insertions(+), 429 deletions(-) create mode 100644 web/src/components/settings/CameraWizardDialog.tsx create mode 100644 web/src/components/settings/wizard/Step1NameCamera.tsx create mode 100644 web/src/components/settings/wizard/Step2StreamConfig.tsx create mode 100644 web/src/components/settings/wizard/Step3Validation.tsx create mode 100644 web/src/types/cameraWizard.ts create mode 100644 web/src/utils/cameraUtil.ts create mode 100644 web/src/views/settings/CameraManagementView.tsx diff --git a/frigate/api/app.py b/frigate/api/app.py index 5c2c132fd..3d5d27e8b 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -43,6 +43,7 @@ from frigate.util.builtin import ( update_yaml_file_bulk, ) from frigate.util.config import find_config_file +from frigate.util.image import run_ffmpeg_snapshot from frigate.util.services import ( ffprobe_stream, get_nvidia_driver_info, @@ -107,6 +108,80 @@ def go2rtc_camera_stream(request: Request, camera_name: str): 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) def version(): return VERSION @@ -453,7 +528,7 @@ def config_set(request: Request, body: AppConfigSetBody): @router.get("/ffprobe") -def ffprobe(request: Request, paths: str = ""): +def ffprobe(request: Request, paths: str = "", detailed: bool = False): path_param = paths if not path_param: @@ -492,26 +567,132 @@ def ffprobe(request: Request, paths: str = ""): output = [] for path in paths: - ffprobe = ffprobe_stream(request.app.frigate_config.ffmpeg, path.strip()) - output.append( - { - "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 "" - ), - } + ffprobe = ffprobe_stream( + request.app.frigate_config.ffmpeg, path.strip(), detailed=detailed ) + 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) +@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") def vainfo(): vainfo = vainfo_hwaccel() diff --git a/frigate/util/image.py b/frigate/util/image.py index 0ebd2f1a1..ea9fb0a0a 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -943,6 +943,58 @@ def add_mask(mask: str, mask_img: np.ndarray): 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( ffmpeg, # Ffmpeg Config file_path: str, @@ -952,37 +1004,11 @@ def get_image_from_recording( ) -> Optional[Any]: """retrieve a frame from given time in recording file.""" - ffmpeg_cmd = [ - ffmpeg.ffmpeg_path, - "-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, + image_data, _ = run_ffmpeg_snapshot( + ffmpeg, file_path, codec, seek_time=relative_frame_time, height=height ) - if process.returncode == 0: - return process.stdout - else: - return None + return image_data def get_histogram(image, x_min, y_min, x_max, y_max): diff --git a/frigate/util/services.py b/frigate/util/services.py index 28497e803..ed21e7b00 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -515,9 +515,20 @@ def get_jetson_stats() -> Optional[dict[int, dict]]: 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.""" 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 = [ ffmpeg.ffprobe_path, "-timeout", @@ -525,11 +536,15 @@ def ffprobe_stream(ffmpeg, path: str) -> sp.CompletedProcess: "-print_format", "json", "-show_entries", - "stream=codec_long_name,width,height,bit_rate,duration,display_aspect_ratio,avg_frame_rate", - "-loglevel", - "quiet", - clean_path, + f"stream={stream_entries}", ] + + # 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) diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index 501f2d4bc..924dc0d0e 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -82,6 +82,14 @@ "length": { "feet": "feet", "meters": "meters" + }, + "data": { + "kbps": "kB/s", + "mbps": "MB/s", + "gbps": "GB/s", + "kbph": "kB/hour", + "mbph": "MB/hour", + "gbph": "GB/hour" } }, "label": { diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 912d4f693..dead8e775 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -2,7 +2,8 @@ "documentTitle": { "default": "Settings - Frigate", "authentication": "Authentication Settings - Frigate", - "camera": "Camera Settings - Frigate", + "cameraManagement": "Manage Cameras - Frigate", + "cameraReview": "Camera Review Settings - Frigate", "enrichments": "Enrichments Settings - Frigate", "masksAndZones": "Mask and Zone Editor - Frigate", "motionTuner": "Motion Tuner - Frigate", @@ -14,7 +15,8 @@ "menu": { "ui": "UI", "enrichments": "Enrichments", - "cameras": "Camera Settings", + "cameraManagement": "Management", + "cameraReview": "Review", "masksAndZones": "Masks / Zones", "motionTuner": "Motion Tuner", "triggers": "Triggers", @@ -143,12 +145,176 @@ "error": "Failed to save config changes: {{errorMessage}}" } }, - "camera": { - "title": "Camera Settings", + "cameraWizard": { + "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": { - "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.
Note: This does not disable go2rtc restreams." }, + "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": { "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." @@ -183,35 +349,6 @@ "toast": { "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": { diff --git a/web/src/components/indicators/StepIndicator.tsx b/web/src/components/indicators/StepIndicator.tsx index a6255fd0f..282527f42 100644 --- a/web/src/components/indicators/StepIndicator.tsx +++ b/web/src/components/indicators/StepIndicator.tsx @@ -4,17 +4,43 @@ import { useTranslation } from "react-i18next"; type StepIndicatorProps = { steps: string[]; currentStep: number; - translationNameSpace: string; + variant?: "default" | "dots"; + translationNameSpace?: string; + className?: string; }; + export default function StepIndicator({ steps, currentStep, + variant = "default", translationNameSpace, + className, }: StepIndicatorProps) { const { t } = useTranslation(translationNameSpace); + if (variant == "dots") { + return ( +
+ {steps.map((_, idx) => ( +
idx + ? "bg-muted-foreground" + : "bg-muted", + )} + /> + ))} +
+ ); + } + + // Default variant (original behavior) return ( -
+
{steps.map((name, idx) => (
{ - const encoded = encodeURIComponent(name); - const base64 = btoa(encoded); - const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8); - return `cam_${cleanHash.toLowerCase()}`; -}; +import { processCameraName } from "@/utils/cameraUtil"; +import { Label } from "@/components/ui/label"; +import { ConfigSetBody } from "@/types/cameraWizard"; const RoleEnum = z.enum(["audio", "detect", "record"]); type Role = z.infer; @@ -60,22 +50,26 @@ export default function CameraEditForm({ z.object({ cameraName: z .string() - .min(1, { message: t("camera.cameraConfig.nameRequired") }), + .min(1, { message: t("cameraManagement.cameraConfig.nameRequired") }), enabled: z.boolean(), ffmpeg: z.object({ inputs: z .array( z.object({ path: z.string().min(1, { - message: t("camera.cameraConfig.ffmpeg.pathRequired"), + message: t( + "cameraManagement.cameraConfig.ffmpeg.pathRequired", + ), }), roles: z.array(RoleEnum).min(1, { - message: t("camera.cameraConfig.ffmpeg.rolesRequired"), + message: t( + "cameraManagement.cameraConfig.ffmpeg.rolesRequired", + ), }), }), ) .min(1, { - message: t("camera.cameraConfig.ffmpeg.inputsRequired"), + message: t("cameraManagement.cameraConfig.ffmpeg.inputsRequired"), }) .refine( (inputs) => { @@ -93,11 +87,12 @@ export default function CameraEditForm({ ); }, { - message: t("camera.cameraConfig.ffmpeg.rolesUnique"), + message: t("cameraManagement.cameraConfig.ffmpeg.rolesUnique"), path: ["inputs"], }, ), }), + go2rtcStreams: z.record(z.string(), z.array(z.string())).optional(), }), [t], ); @@ -110,6 +105,7 @@ export default function CameraEditForm({ friendly_name: undefined, name: cameraName || "", roles: new Set(), + go2rtcStreams: {}, }; } @@ -120,10 +116,14 @@ export default function CameraEditForm({ input.roles.forEach((role) => roles.add(role as Role)); }); + // Load existing go2rtc streams + const go2rtcStreams = config.go2rtc?.streams || {}; + return { friendly_name: camera?.friendly_name || cameraName, name: cameraName, roles, + go2rtcStreams, }; }, [cameraName, config]); @@ -138,6 +138,7 @@ export default function CameraEditForm({ }, ], }, + go2rtcStreams: {}, }; // Load existing camera config if editing @@ -150,6 +151,41 @@ export default function CameraEditForm({ roles: input.roles as Role[], })) : defaultValues.ffmpeg.inputs; + + // Load go2rtc streams for this camera + const go2rtcStreams = config.go2rtc?.streams || {}; + const cameraStreams: Record = {}; + + // 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({ @@ -166,21 +202,20 @@ export default function CameraEditForm({ // Watch ffmpeg.inputs to track used roles const watchedInputs = form.watch("ffmpeg.inputs"); + // Watch go2rtc streams + const watchedGo2rtcStreams = form.watch("go2rtcStreams") || {}; + const saveCameraConfig = (values: FormValues) => { setIsLoading(true); - let finalCameraName = values.cameraName; - let friendly_name: string | undefined = undefined; - const isValidName = /^[a-zA-Z0-9_-]+$/.test(values.cameraName); - if (!isValidName) { - finalCameraName = generateFixedHash(finalCameraName); - friendly_name = values.cameraName; - } + const { finalCameraName, friendlyName } = processCameraName( + values.cameraName, + ); const configData: ConfigSetBody["config_data"] = { cameras: { [finalCameraName]: { enabled: values.enabled, - ...(friendly_name && { friendly_name }), + ...(friendlyName && { friendly_name: friendlyName }), ffmpeg: { inputs: values.ffmpeg.inputs.map((input) => ({ 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 = { requires_restart: 1, config_data: configData, @@ -205,13 +247,36 @@ export default function CameraEditForm({ .put("config/set", requestBody) .then((res) => { if (res.status === 200) { - toast.success( - t("camera.cameraConfig.toast.success", { - cameraName: values.cameraName, - }), - { position: "top-center" }, - ); - if (onSave) onSave(); + // Update running go2rtc instance if streams were configured + if ( + values.go2rtcStreams && + Object.keys(values.go2rtcStreams).length > 0 + ) { + const updatePromises = Object.entries(values.go2rtcStreams).map( + ([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 { throw new Error(res.statusText); } @@ -238,11 +303,11 @@ export default function CameraEditForm({ values.cameraName !== cameraInfo?.friendly_name ) { // If camera name changed, delete old camera config - const deleteRequestBody: ConfigSetBody = { + const deleteRequestBody = { requires_restart: 1, config_data: { cameras: { - [cameraName]: "", + [cameraName]: null, }, }, update_topic: `config/cameras/${cameraName}/remove`, @@ -289,15 +354,15 @@ export default function CameraEditForm({ }; return ( - <> +
{cameraName - ? t("camera.cameraConfig.edit") - : t("camera.cameraConfig.add")} + ? t("cameraManagement.cameraConfig.edit") + : t("cameraManagement.cameraConfig.add")}
- {t("camera.cameraConfig.description")} + {t("cameraManagement.cameraConfig.description")}
@@ -308,10 +373,12 @@ export default function CameraEditForm({ name="cameraName" render={({ field }) => ( - {t("camera.cameraConfig.name")} + {t("cameraManagement.cameraConfig.name")} @@ -332,107 +399,251 @@ export default function CameraEditForm({ onCheckedChange={field.onChange} /> - {t("camera.cameraConfig.enabled")} + + {t("cameraManagement.cameraConfig.enabled")} + )} /> -
- {t("camera.cameraConfig.ffmpeg.inputs")} +
+ {fields.map((field, index) => ( -
- ( - - - {t("camera.cameraConfig.ffmpeg.path")} - - - - - - - )} - /> + + +
+

+ {t("cameraWizard.step2.streamTitle", { + number: index + 1, + })} +

+ +
- ( - - - {t("camera.cameraConfig.ffmpeg.roles")} - - -
- {(["audio", "detect", "record"] as const).map( - (role) => ( -
+
+ + ))} {form.formState.errors.ffmpeg?.inputs?.root && form.formState.errors.ffmpeg.inputs.root.message}
+ {/* go2rtc Streams Section */} + {Object.keys(watchedGo2rtcStreams).length > 0 && ( +
+ + {Object.entries(watchedGo2rtcStreams).map( + ([streamName, urls]) => ( + + +
+

{streamName}

+ +
+ +
+ + {(Array.isArray(urls) ? urls : [urls]).map( + (url, urlIndex) => ( +
+ { + 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 && ( + + )} +
+ ), + )} + +
+
+
+ ), + )} + +
+ )} +
); } diff --git a/web/src/components/settings/CameraWizardDialog.tsx b/web/src/components/settings/CameraWizardDialog.tsx new file mode 100644 index 000000000..12b150e37 --- /dev/null +++ b/web/src/components/settings/CameraWizardDialog.tsx @@ -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; + shouldNavigateNext: boolean; +}; + +type WizardAction = + | { type: "UPDATE_DATA"; payload: Partial } + | { type: "UPDATE_AND_NEXT"; payload: Partial } + | { 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) => { + 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) => { + 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 = {}; + + 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 ( + + { + e.preventDefault(); + }} + > + + + {t("cameraWizard.title")} + {currentStep === 0 && ( + + {t("cameraWizard.description")} + + )} + + + {currentStep > 0 && state.wizardData.cameraName && ( +
+ {state.wizardData.cameraName} +
+ )} + +
+
+ {currentStep === 0 && ( + + )} + {currentStep === 1 && ( + + )} + {currentStep === 2 && ( + + )} +
+
+
+
+ ); +} diff --git a/web/src/components/settings/wizard/Step1NameCamera.tsx b/web/src/components/settings/wizard/Step1NameCamera.tsx new file mode 100644 index 000000000..87eb74f06 --- /dev/null +++ b/web/src/components/settings/wizard/Step1NameCamera.tsx @@ -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; + onUpdate: (data: Partial) => void; + onNext: (data?: Partial) => void; + onCancel: () => void; + canProceed?: boolean; +}; + +export default function Step1NameCamera({ + wizardData, + onUpdate, + onNext, + onCancel, +}: Step1NameCameraProps) { + const { t } = useTranslation(["views/settings"]); + const { data: config } = useSWR("config"); + const [showPassword, setShowPassword] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [testResult, setTestResult] = useState(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>({ + 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): 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((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) => { + 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 ( +
+ {!testResult?.success && ( + <> +
+ {t("cameraWizard.step1.description")} +
+ +
+ + ( + + {t("cameraWizard.step1.cameraName")} + + + + + + )} + /> + + ( + + {t("cameraWizard.step1.cameraBrand")} + + + {field.value && + (() => { + const selectedBrand = CAMERA_BRANDS.find( + (brand) => brand.value === field.value, + ); + return selectedBrand && + selectedBrand.value != "other" ? ( + + + +
+ + {t("cameraWizard.step1.brandInformation")} +
+
+ +
+

+ {selectedBrand.label} +

+

+ {t("cameraWizard.step1.brandUrlFormat", { + exampleUrl: selectedBrand.exampleUrl, + })} +

+
+
+
+
+ ) : null; + })()} +
+ )} + /> + + {watchedBrand !== "other" && ( + <> + ( + + {t("cameraWizard.step1.host")} + + + + + + )} + /> + + ( + + + {t("cameraWizard.step1.username")} + + + + + + + )} + /> + + ( + + + {t("cameraWizard.step1.password")} + + +
+ + +
+
+ +
+ )} + /> + + )} + + {watchedBrand == "other" && ( + ( + + {t("cameraWizard.step1.customUrl")} + + + + + + )} + /> + )} + + + + )} + + {testResult?.success && ( +
+
+ + {t("cameraWizard.step1.testSuccess")} +
+ +
+ {testResult.snapshot ? ( +
+ Camera snapshot +
+
+ +
+
+
+ ) : ( + + + {t("cameraWizard.step1.streamDetails")} + + + + + + )} +
+
+ )} + +
+ + {testResult?.success ? ( + + ) : ( + + )} +
+
+ ); +} + +function StreamDetails({ testResult }: { testResult: TestResult }) { + const { t } = useTranslation(["views/settings"]); + + return ( + <> + {testResult.resolution && ( +
+ + {t("cameraWizard.testResultLabels.resolution")}: + {" "} + {testResult.resolution} +
+ )} + {testResult.fps && ( +
+ + {t("cameraWizard.testResultLabels.fps")}: + {" "} + {testResult.fps} +
+ )} + {testResult.videoCodec && ( +
+ + {t("cameraWizard.testResultLabels.video")}: + {" "} + {testResult.videoCodec} +
+ )} + {testResult.audioCodec && ( +
+ + {t("cameraWizard.testResultLabels.audio")}: + {" "} + {testResult.audioCodec} +
+ )} + + ); +} diff --git a/web/src/components/settings/wizard/Step2StreamConfig.tsx b/web/src/components/settings/wizard/Step2StreamConfig.tsx new file mode 100644 index 000000000..0a3419940 --- /dev/null +++ b/web/src/components/settings/wizard/Step2StreamConfig.tsx @@ -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; + onUpdate: (data: Partial) => 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>(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) => { + onUpdate({ + streams: streams.map((s) => + s.id === streamId ? { ...s, ...updates } : s, + ), + }); + }, + [streams, onUpdate], + ); + + const getUsedRolesExcludingStream = useCallback( + (excludeStreamId: string) => { + const roles = new Set(); + 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 ( +
+
+ {t("cameraWizard.step2.description")} +
+ +
+ {streams.map((stream, index) => ( + + +
+
+

+ {t("cameraWizard.step2.streamTitle", { number: index + 1 })} +

+ {stream.testResult && stream.testResult.success && ( +
+ {[ + stream.testResult.resolution, + stream.testResult.fps + ? `${stream.testResult.fps} ${t("cameraWizard.testResultLabels.fps")}` + : null, + stream.testResult.videoCodec, + stream.testResult.audioCodec, + ] + .filter(Boolean) + .join(" ยท ")} +
+ )} +
+
+ {stream.testResult?.success && ( +
+ + + {t("cameraWizard.step2.connected")} + +
+ )} + {stream.testResult && !stream.testResult.success && ( +
+ + + {t("cameraWizard.step2.notConnected")} + +
+ )} + {streams.length > 1 && ( + + )} +
+
+ +
+
+ +
+ + updateStream(stream.id, { + url: e.target.value, + testResult: undefined, + }) + } + className="h-8 flex-1" + placeholder={t("cameraWizard.step2.streamUrlPlaceholder")} + /> + +
+
+
+ + {stream.testResult && + !stream.testResult.success && + stream.userTested && ( +
+
+ {t("cameraWizard.step2.testFailedTitle")} +
+
+ {stream.testResult.error} +
+
+ )} + +
+
+ + + + + + +
+
+ {t("cameraWizard.step2.rolesPopover.title")} +
+
+
+ detect -{" "} + {t("cameraWizard.step2.rolesPopover.detect")} +
+
+ record -{" "} + {t("cameraWizard.step2.rolesPopover.record")} +
+
+ audio -{" "} + {t("cameraWizard.step2.rolesPopover.audio")} +
+
+
+ + {t("readTheDocumentation", { ns: "common" })} + + +
+
+
+
+
+
+
+ {(["detect", "record", "audio"] as const).map((role) => { + const isUsedElsewhere = getUsedRolesExcludingStream( + stream.id, + ).has(role); + const isChecked = stream.roles.includes(role); + return ( +
+ {role} + toggleRole(stream.id, role)} + disabled={!isChecked && isUsedElsewhere} + /> +
+ ); + })} +
+
+
+ +
+
+ + + + + + +
+
+ {t("cameraWizard.step2.featuresPopover.title")} +
+
+ {t("cameraWizard.step2.featuresPopover.description")} +
+
+ + {t("readTheDocumentation", { ns: "common" })} + + +
+
+
+
+
+
+
+ + {t("cameraWizard.step2.go2rtc")} + + setRestream(stream.id)} + /> +
+
+
+
+
+ ))} + + +
+ + {!hasDetectRole && ( +
+ {t("cameraWizard.step2.detectRoleWarning")} +
+ )} + +
+ {onBack && ( + + )} + {onNext && ( + + )} +
+
+ ); +} diff --git a/web/src/components/settings/wizard/Step3Validation.tsx b/web/src/components/settings/wizard/Step3Validation.tsx new file mode 100644 index 000000000..195d4223a --- /dev/null +++ b/web/src/components/settings/wizard/Step3Validation.tsx @@ -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; + onUpdate: (data: Partial) => 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>(new Set()); + const [measuredBandwidth, setMeasuredBandwidth] = useState< + Map + >(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(); + streams.forEach((stream) => { + if (stream.testResult) { + results.set(stream.id, stream.testResult); + } + }); + return results; + }, [streams]); + + const performStreamValidation = useCallback( + async (stream: StreamConfig): Promise => { + 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(); + + // 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 ( +
+
+ {t("cameraWizard.step3.description")} +
+ +
+
+

+ {t("cameraWizard.step3.validationTitle")} +

+ +
+ +
+ {streams.map((stream, index) => { + const result = validationResults.get(stream.id); + return ( + + +
+
+
+
+

+ {t("cameraWizard.step3.streamTitle", { + number: index + 1, + })} +

+ {stream.roles.map((role) => ( + + {role} + + ))} +
+ {result && result.success && ( +
+ {[ + result.resolution, + result.fps + ? `${result.fps} ${t("cameraWizard.testResultLabels.fps")}` + : null, + result.videoCodec, + result.audioCodec, + ] + .filter(Boolean) + .join(" ยท ")} +
+ )} +
+
+ {result?.success && ( +
+ + + {t("cameraWizard.step2.connected")} + +
+ )} + {result && !result.success && ( +
+ + + {t("cameraWizard.step2.notConnected")} + +
+ )} +
+ + {result?.success && ( +
+ +
+ )} + +
+ + {stream.url} + + +
+ + {result && ( +
+
+ {t("cameraWizard.step3.issues.title")} +
+
+ +
+
+ )} + + {result && !result.success && ( +
+
+ {t("cameraWizard.step2.testFailedTitle")} +
+
{result.error}
+
+ )} +
+
+ ); + })} +
+
+ +
+ {onBack && ( + + )} + +
+
+ ); +} + +type StreamIssuesProps = { + stream: StreamConfig; + measuredBandwidth: Map; + wizardData: Partial; +}; + +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 ( +
+ +
+ {issues.map((issue, index) => ( +
+ {issue.type === "good" && ( + + )} + {issue.type === "warning" && ( + + )} + {issue.type === "error" && ( + + )} + + {issue.message} + +
+ ))} +
+
+ ); +} + +type BandwidthDisplayProps = { + streamId: string; + measuredBandwidth: Map; +}; + +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 ( +
+ + {t("cameraWizard.step3.estimatedBandwidth")}: + {" "} + + {streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })} + + ({perHourDisplay}) +
+ ); +} + +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 ( +
+ + {t("cameraWizard.step3.streamUnavailable")} + + +
+ ); + } + + if (!registered) { + return ( +
+ + + {t("cameraWizard.step3.connecting")} + +
+ ); + } + + return ( +
+ setError(true)} + /> +
+ ); +} diff --git a/web/src/components/ui/form.tsx b/web/src/components/ui/form.tsx index 4603f8b3d..dc6102ea2 100644 --- a/web/src/components/ui/form.tsx +++ b/web/src/components/ui/form.tsx @@ -1,6 +1,6 @@ -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" -import { Slot } from "@radix-ui/react-slot" +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; import { Controller, ControllerProps, @@ -8,27 +8,27 @@ import { FieldValues, FormProvider, useFormContext, -} from "react-hook-form" +} from "react-hook-form"; -import { cn } from "@/lib/utils" -import { Label } from "@/components/ui/label" +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; -const Form = FormProvider +const Form = FormProvider; type FormFieldContextValue< TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath + TName extends FieldPath = FieldPath, > = { - name: TName -} + name: TName; +}; const FormFieldContext = React.createContext( - {} as FormFieldContextValue -) + {} as FormFieldContextValue, +); const FormField = < TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath + TName extends FieldPath = FieldPath, >({ ...props }: ControllerProps) => { @@ -36,21 +36,21 @@ const FormField = < - ) -} + ); +}; const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext) - const itemContext = React.useContext(FormItemContext) - const { getFieldState, formState } = useFormContext() + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); - const fieldState = getFieldState(fieldContext.name, formState) + const fieldState = getFieldState(fieldContext.name, formState); if (!fieldContext) { - throw new Error("useFormField should be used within ") + throw new Error("useFormField should be used within "); } - const { id } = itemContext + const { id } = itemContext; return { id, @@ -59,36 +59,36 @@ const useFormField = () => { formDescriptionId: `${id}-form-item-description`, formMessageId: `${id}-form-item-message`, ...fieldState, - } -} + }; +}; type FormItemContextValue = { - id: string -} + id: string; +}; const FormItemContext = React.createContext( - {} as FormItemContextValue -) + {} as FormItemContextValue, +); const FormItem = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => { - const id = React.useId() + const id = React.useId(); return ( -
+
- ) -}) -FormItem.displayName = "FormItem" + ); +}); +FormItem.displayName = "FormItem"; const FormLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => { - const { error, formItemId } = useFormField() + const { error, formItemId } = useFormField(); return (
@@ -428,7 +356,7 @@ export default function CameraSettingsView({ - camera.object_descriptions.title + cameraReview.object_descriptions.title @@ -450,7 +378,7 @@ export default function CameraSettingsView({
- camera.object_descriptions.desc + cameraReview.object_descriptions.desc
@@ -463,7 +391,7 @@ export default function CameraSettingsView({ - camera.review_descriptions.title + cameraReview.review_descriptions.title @@ -485,7 +413,7 @@ export default function CameraSettingsView({
- camera.review_descriptions.desc + cameraReview.review_descriptions.desc
@@ -496,7 +424,7 @@ export default function CameraSettingsView({ - camera.reviewClassification.title + cameraReview.reviewClassification.title @@ -504,7 +432,7 @@ export default function CameraSettingsView({

- camera.reviewClassification.desc + cameraReview.reviewClassification.desc

@@ -550,7 +478,7 @@ export default function CameraSettingsView({ - camera.reviewClassification.selectAlertsZones + cameraReview.reviewClassification.selectAlertsZones
@@ -599,7 +527,7 @@ export default function CameraSettingsView({ ) : (
- camera.reviewClassification.noDefinedZones + cameraReview.reviewClassification.noDefinedZones
)} @@ -607,7 +535,7 @@ export default function CameraSettingsView({
{watchedAlertsZones && watchedAlertsZones.length > 0 ? t( - "camera.reviewClassification.zoneObjectAlertsTips", + "cameraReview.reviewClassification.zoneObjectAlertsTips", { alertsLabels, zone: watchedAlertsZones @@ -622,7 +550,7 @@ export default function CameraSettingsView({ }, ) : t( - "camera.reviewClassification.objectAlertsTips", + "cameraReview.reviewClassification.objectAlertsTips", { alertsLabels, cameraName: selectCameraName, @@ -650,7 +578,7 @@ export default function CameraSettingsView({ {selectDetections && ( - camera.reviewClassification.selectDetectionsZones + cameraReview.reviewClassification.selectDetectionsZones )} @@ -713,7 +641,7 @@ export default function CameraSettingsView({ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" > - camera.reviewClassification.limitDetections + cameraReview.reviewClassification.limitDetections
@@ -726,7 +654,7 @@ export default function CameraSettingsView({ watchedDetectionsZones.length > 0 ? ( !selectDetections ? ( ) : (
+ + setShowWizard(false)} + /> ); }