Add Camera Wizard improvements (#20876)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* backend api endpoint

* don't add no-credentials version of streams to rtsp_candidates

* frontend types

* improve types

* add optional probe dialog to wizard step 1

* i18n

* form description and field change

* add onvif form description

* match onvif probe pane with other steps in the wizard

* refactor to add probe and snapshot as step 2

* consolidate probe dialog

* don't change dialog size

* radio button style

* refactor to select onvif urls via combobox in step 3

* i18n

* add scrollbar container

* i18n cleanup

* fix button activity indicator

* match test parsing in step 3 with step 2

* hide resolution if both width and height are zero

* use drawer for stream selection on mobile in step 3

* suppress double toasts

* api endpoint description
This commit is contained in:
Josh Hawkins 2025-11-10 15:49:52 -06:00 committed by GitHub
parent c371fc0c87
commit e4eac4ac81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 2683 additions and 1017 deletions

View File

@ -3,11 +3,15 @@
import json
import logging
import re
from importlib.util import find_spec
from pathlib import Path
from urllib.parse import quote_plus
import requests
from fastapi import APIRouter, Depends, Request, Response
from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi.responses import JSONResponse
from onvif import ONVIFCamera, ONVIFError
from zeep.exceptions import Fault, TransportError
from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags
@ -452,3 +456,471 @@ def _extract_fps(r_frame_rate: str) -> float | None:
return round(float(num) / float(den), 2)
except (ValueError, ZeroDivisionError):
return None
@router.get(
"/onvif/probe",
dependencies=[Depends(require_role(["admin"]))],
summary="Probe ONVIF device",
description=(
"Probe an ONVIF device to determine capabilities and optionally test available stream URIs. "
"Query params: host (required), port (default 80), username, password, test (boolean)."
),
)
async def onvif_probe(
request: Request,
host: str = Query(None),
port: int = Query(80),
username: str = Query(""),
password: str = Query(""),
test: bool = Query(False),
):
"""
Probe a single ONVIF device to determine capabilities.
Connects to an ONVIF device and queries for:
- Device information (manufacturer, model)
- Media profiles count
- PTZ support
- Available presets
- Autotracking support
Query Parameters:
host: Device host/IP address (required)
port: Device port (default 80)
username: ONVIF username (optional)
password: ONVIF password (optional)
test: run ffprobe on the stream (optional)
Returns:
JSON with device capabilities information
"""
if not host:
return JSONResponse(
content={"success": False, "message": "host parameter is required"},
status_code=400,
)
# Validate host format
if not _is_valid_host(host):
return JSONResponse(
content={"success": False, "message": "Invalid host format"},
status_code=400,
)
onvif_camera = None
try:
logger.debug(f"Probing ONVIF device at {host}:{port}")
try:
wsdl_base = None
spec = find_spec("onvif")
if spec and getattr(spec, "origin", None):
wsdl_base = str(Path(spec.origin).parent / "wsdl")
except Exception:
wsdl_base = None
onvif_camera = ONVIFCamera(
host, port, username or "", password or "", wsdl_dir=wsdl_base
)
await onvif_camera.update_xaddrs()
# Get device information
device_info = {
"manufacturer": "Unknown",
"model": "Unknown",
"firmware_version": "Unknown",
}
try:
device_service = await onvif_camera.create_devicemgmt_service()
device_info_resp = await device_service.GetDeviceInformation()
manufacturer = getattr(device_info_resp, "Manufacturer", None) or (
device_info_resp.get("Manufacturer")
if isinstance(device_info_resp, dict)
else None
)
model = getattr(device_info_resp, "Model", None) or (
device_info_resp.get("Model")
if isinstance(device_info_resp, dict)
else None
)
firmware = getattr(device_info_resp, "FirmwareVersion", None) or (
device_info_resp.get("FirmwareVersion")
if isinstance(device_info_resp, dict)
else None
)
device_info.update(
{
"manufacturer": manufacturer or "Unknown",
"model": model or "Unknown",
"firmware_version": firmware or "Unknown",
}
)
except Exception:
logger.debug("Failed to get device info")
# Get media profiles
profiles = []
profiles_count = 0
first_profile_token = None
ptz_config_token = None
try:
media_service = await onvif_camera.create_media_service()
profiles = await media_service.GetProfiles()
profiles_count = len(profiles) if profiles else 0
if profiles and len(profiles) > 0:
p = profiles[0]
first_profile_token = getattr(p, "token", None) or (
p.get("token") if isinstance(p, dict) else None
)
# Get PTZ configuration token from the profile
ptz_configuration = getattr(p, "PTZConfiguration", None) or (
p.get("PTZConfiguration") if isinstance(p, dict) else None
)
if ptz_configuration:
ptz_config_token = getattr(ptz_configuration, "token", None) or (
ptz_configuration.get("token")
if isinstance(ptz_configuration, dict)
else None
)
except Exception:
logger.debug("Failed to get media profiles")
# Check PTZ support and capabilities
ptz_supported = False
presets_count = 0
autotrack_supported = False
try:
ptz_service = await onvif_camera.create_ptz_service()
# Check if PTZ service is available
try:
await ptz_service.GetServiceCapabilities()
ptz_supported = True
logger.debug("PTZ service is available")
except Exception as e:
logger.debug(f"PTZ service not available: {e}")
ptz_supported = False
# Try to get presets if PTZ is supported and we have a profile
if ptz_supported and first_profile_token:
try:
presets_resp = await ptz_service.GetPresets(
{"ProfileToken": first_profile_token}
)
presets_count = len(presets_resp) if presets_resp else 0
logger.debug(f"Found {presets_count} presets")
except Exception as e:
logger.debug(f"Failed to get presets: {e}")
presets_count = 0
# Check for autotracking support - requires both FOV relative movement and MoveStatus
if ptz_supported and first_profile_token and ptz_config_token:
# First check for FOV relative movement support
pt_r_fov_supported = False
try:
config_request = ptz_service.create_type("GetConfigurationOptions")
config_request.ConfigurationToken = ptz_config_token
ptz_config = await ptz_service.GetConfigurationOptions(
config_request
)
if ptz_config:
# Check for pt-r-fov support
spaces = getattr(ptz_config, "Spaces", None) or (
ptz_config.get("Spaces")
if isinstance(ptz_config, dict)
else None
)
if spaces:
rel_pan_tilt_space = getattr(
spaces, "RelativePanTiltTranslationSpace", None
) or (
spaces.get("RelativePanTiltTranslationSpace")
if isinstance(spaces, dict)
else None
)
if rel_pan_tilt_space:
# Look for FOV space
for i, space in enumerate(rel_pan_tilt_space):
uri = None
if isinstance(space, dict):
uri = space.get("URI")
else:
uri = getattr(space, "URI", None)
if uri and "TranslationSpaceFov" in uri:
pt_r_fov_supported = True
logger.debug(
"FOV relative movement (pt-r-fov) supported"
)
break
logger.debug(f"PTZ config spaces: {ptz_config}")
except Exception as e:
logger.debug(f"Failed to check FOV relative movement: {e}")
pt_r_fov_supported = False
# Now check for MoveStatus support via GetServiceCapabilities
if pt_r_fov_supported:
try:
service_capabilities_request = ptz_service.create_type(
"GetServiceCapabilities"
)
service_capabilities = await ptz_service.GetServiceCapabilities(
service_capabilities_request
)
# Look for MoveStatus in the capabilities
move_status_capable = False
if service_capabilities:
# Try to find MoveStatus key recursively
def find_move_status(obj, key="MoveStatus"):
if isinstance(obj, dict):
if key in obj:
return obj[key]
for v in obj.values():
result = find_move_status(v, key)
if result is not None:
return result
elif hasattr(obj, key):
return getattr(obj, key)
elif hasattr(obj, "__dict__"):
for v in vars(obj).values():
result = find_move_status(v, key)
if result is not None:
return result
return None
move_status_value = find_move_status(service_capabilities)
# MoveStatus should return "true" if supported
if isinstance(move_status_value, bool):
move_status_capable = move_status_value
elif isinstance(move_status_value, str):
move_status_capable = (
move_status_value.lower() == "true"
)
logger.debug(f"MoveStatus capability: {move_status_value}")
# Autotracking is supported if both conditions are met
autotrack_supported = pt_r_fov_supported and move_status_capable
if autotrack_supported:
logger.debug(
"Autotracking fully supported (pt-r-fov + MoveStatus)"
)
else:
logger.debug(
f"Autotracking not fully supported - pt-r-fov: {pt_r_fov_supported}, MoveStatus: {move_status_capable}"
)
except Exception as e:
logger.debug(f"Failed to check MoveStatus support: {e}")
autotrack_supported = False
except Exception as e:
logger.debug(f"Failed to probe PTZ service: {e}")
result = {
"success": True,
"host": host,
"port": port,
"manufacturer": device_info["manufacturer"],
"model": device_info["model"],
"firmware_version": device_info["firmware_version"],
"profiles_count": profiles_count,
"ptz_supported": ptz_supported,
"presets_count": presets_count,
"autotrack_supported": autotrack_supported,
}
# Gather RTSP candidates
rtsp_candidates: list[dict] = []
try:
media_service = await onvif_camera.create_media_service()
if profiles_count and media_service:
for p in profiles or []:
token = getattr(p, "token", None) or (
p.get("token") if isinstance(p, dict) else None
)
if not token:
continue
try:
stream_setup = {
"Stream": "RTP-Unicast",
"Transport": {"Protocol": "RTSP"},
}
stream_req = {
"ProfileToken": token,
"StreamSetup": stream_setup,
}
stream_uri_resp = await media_service.GetStreamUri(stream_req)
uri = (
stream_uri_resp.get("Uri")
if isinstance(stream_uri_resp, dict)
else getattr(stream_uri_resp, "Uri", None)
)
if uri:
logger.debug(
f"GetStreamUri returned for token {token}: {uri}"
)
# If credentials were provided, do NOT add the unauthenticated URI.
try:
if isinstance(uri, str) and uri.startswith("rtsp://"):
if username and password and "@" not in uri:
# Inject URL-encoded credentials and add only the
# authenticated version.
cred = f"{quote_plus(username)}:{quote_plus(password)}@"
injected = uri.replace(
"rtsp://", f"rtsp://{cred}", 1
)
rtsp_candidates.append(
{
"source": "GetStreamUri",
"profile_token": token,
"uri": injected,
}
)
else:
# No credentials provided or URI already contains
# credentials — add the URI as returned.
rtsp_candidates.append(
{
"source": "GetStreamUri",
"profile_token": token,
"uri": uri,
}
)
else:
# Non-RTSP URIs (e.g., http-flv) — add as returned.
rtsp_candidates.append(
{
"source": "GetStreamUri",
"profile_token": token,
"uri": uri,
}
)
except Exception as e:
logger.debug(
f"Skipping stream URI for token {token} due to processing error: {e}"
)
continue
except Exception:
logger.debug(
f"GetStreamUri failed for token {token}", exc_info=True
)
continue
# Add common RTSP patterns as fallback
if not rtsp_candidates:
common_paths = [
"/h264",
"/live.sdp",
"/media.amp",
"/Streaming/Channels/101",
"/Streaming/Channels/1",
"/stream1",
"/cam/realmonitor?channel=1&subtype=0",
"/11",
]
# Use URL-encoded credentials for pattern fallback URIs when provided
auth_str = (
f"{quote_plus(username)}:{quote_plus(password)}@"
if username and password
else ""
)
rtsp_port = 554
for path in common_paths:
uri = f"rtsp://{auth_str}{host}:{rtsp_port}{path}"
rtsp_candidates.append({"source": "pattern", "uri": uri})
except Exception:
logger.debug("Failed to collect RTSP candidates")
# Optionally test RTSP candidates using ffprobe_stream
tested_candidates = []
if test and rtsp_candidates:
for c in rtsp_candidates:
uri = c["uri"]
to_test = [uri]
try:
if (
username
and password
and isinstance(uri, str)
and uri.startswith("rtsp://")
and "@" not in uri
):
cred = f"{quote_plus(username)}:{quote_plus(password)}@"
cred_uri = uri.replace("rtsp://", f"rtsp://{cred}", 1)
if cred_uri not in to_test:
to_test.append(cred_uri)
except Exception:
pass
for test_uri in to_test:
try:
probe = ffprobe_stream(
request.app.frigate_config.ffmpeg, test_uri, detailed=False
)
print(probe)
ok = probe is not None and getattr(probe, "returncode", 1) == 0
tested_candidates.append(
{
"uri": test_uri,
"source": c.get("source"),
"ok": ok,
"profile_token": c.get("profile_token"),
}
)
except Exception as e:
logger.debug(f"Unable to probe stream: {e}")
tested_candidates.append(
{
"uri": test_uri,
"source": c.get("source"),
"ok": False,
"profile_token": c.get("profile_token"),
}
)
result["rtsp_candidates"] = rtsp_candidates
if test:
result["rtsp_tested"] = tested_candidates
logger.debug(f"ONVIF probe successful: {result}")
return JSONResponse(content=result)
except ONVIFError as e:
logger.warning(f"ONVIF error probing {host}:{port}: {e}")
return JSONResponse(
content={"success": False, "message": "ONVIF error"},
status_code=400,
)
except (Fault, TransportError) as e:
logger.warning(f"Connection error probing {host}:{port}: {e}")
return JSONResponse(
content={"success": False, "message": "Connection error"},
status_code=503,
)
except Exception as e:
logger.warning(f"Error probing ONVIF device at {host}:{port}, {e}")
return JSONResponse(
content={"success": False, "message": "Probe failed"},
status_code=500,
)
finally:
# Best-effort cleanup of ONVIF camera client session
if onvif_camera is not None:
try:
# Check if the camera has a close method and call it
if hasattr(onvif_camera, "close"):
await onvif_camera.close()
except Exception as e:
logger.debug(f"Error closing ONVIF camera session: {e}")

View File

@ -154,6 +154,7 @@
"description": "Follow the steps below to add a new camera to your Frigate installation.",
"steps": {
"nameAndConnection": "Name & Connection",
"probeOrSnapshot": "Probe or Snapshot",
"streamConfiguration": "Stream Configuration",
"validationAndTesting": "Validation & Testing"
},
@ -172,7 +173,7 @@
"testFailed": "Stream test failed: {{error}}"
},
"step1": {
"description": "Enter your camera details and test the connection.",
"description": "Enter your camera details and choose to probe the camera or manually select the brand.",
"cameraName": "Camera Name",
"cameraNamePlaceholder": "e.g., front_door or Back Yard Overview",
"host": "Host/IP Address",
@ -188,33 +189,63 @@
"brandInformation": "Brand information",
"brandUrlFormat": "For cameras with the RTSP URL format as: {{exampleUrl}}",
"customUrlPlaceholder": "rtsp://username:password@host:port/path",
"testConnection": "Test Connection",
"testSuccess": "Connection test successful!",
"testFailed": "Connection test failed. Please check your input and try again.",
"streamDetails": "Stream Details",
"testing": {
"probingMetadata": "Probing camera metadata...",
"fetchingSnapshot": "Fetching camera snapshot..."
},
"warnings": {
"noSnapshot": "Unable to fetch a snapshot from the configured stream."
},
"connectionSettings": "Connection Settings",
"detectionMethod": "Stream Detection Method",
"onvifPort": "ONVIF Port",
"probeMode": "Probe camera",
"manualMode": "Manual selection",
"detectionMethodDescription": "Probe the camera with ONVIF (if supported) to find camera stream URLs, or manually select the camera brand to use pre-defined URLs. To enter a custom RTSP URL, choose the manual method and select \"Other\".",
"onvifPortDescription": "For cameras that support ONVIF, this is usually 80 or 8080.",
"errors": {
"brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL",
"nameRequired": "Camera name is required",
"nameLength": "Camera name must be 64 characters or less",
"invalidCharacters": "Camera name contains invalid characters",
"nameExists": "Camera name already exists",
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\". Manual configuration is required for non-RTSP camera streams.",
"brands": {
"reolink-rtsp": "Reolink RTSP is not recommended. Enable HTTP in the camera's firmware settings and restart the wizard."
}
},
"docs": {
"reolink": "https://docs.frigate.video/configuration/camera_specific.html#reolink-cameras"
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\". Manual configuration is required for non-RTSP camera streams."
}
},
"step2": {
"description": "Probe the camera for available streams or configure manual settings based on your selected detection method.",
"testSuccess": "Connection test successful!",
"testFailed": "Connection test failed. Please check your input and try again.",
"testFailedTitle": "Test Failed",
"streamDetails": "Stream Details",
"probing": "Probing camera...",
"retry": "Retry",
"testing": {
"probingMetadata": "Probing camera metadata...",
"fetchingSnapshot": "Fetching camera snapshot..."
},
"probeFailed": "Failed to probe camera: {{error}}",
"probingDevice": "Probing device...",
"probeSuccessful": "Probe successful",
"probeError": "Probe Error",
"probeNoSuccess": "Probe unsuccessful",
"deviceInfo": "Device Information",
"manufacturer": "Manufacturer",
"model": "Model",
"firmware": "Firmware",
"profiles": "Profiles",
"ptzSupport": "PTZ Support",
"autotrackingSupport": "Autotracking Support",
"presets": "Presets",
"rtspCandidates": "RTSP Candidates",
"rtspCandidatesDescription": "The following RTSP URLs were found from the camera probe. Test the connection to view stream metadata.",
"noRtspCandidates": "No RTSP URLs were found from the camera. Your credentials may be incorrect, or the camera may not support ONVIF or the method used to retrieve RTSP URLs. Go back and enter the RTSP URL manually.",
"candidateStreamTitle": "Candidate {{number}}",
"useCandidate": "Use",
"uriCopy": "Copy",
"uriCopied": "URI copied to clipboard",
"testConnection": "Test Connection",
"toggleUriView": "Click to toggle full URI view",
"connected": "Connected",
"notConnected": "Not Connected",
"errors": {
"hostRequired": "Host/IP address is required"
}
},
"step3": {
"description": "Configure stream roles and add additional streams for your camera.",
"streamsTitle": "Camera Streams",
"addStream": "Add Stream",
@ -222,6 +253,9 @@
"streamTitle": "Stream {{number}}",
"streamUrl": "Stream URL",
"streamUrlPlaceholder": "rtsp://username:password@host:port/path",
"selectStream": "Select a stream",
"searchCandidates": "Search candidates...",
"noStreamFound": "No stream found",
"url": "URL",
"resolution": "Resolution",
"selectResolution": "Select resolution",
@ -253,7 +287,7 @@
"description": "Use go2rtc restreaming to reduce connections to your camera."
}
},
"step3": {
"step4": {
"description": "Final validation and analysis before saving your new camera. Connect each stream before saving.",
"validationTitle": "Stream Validation",
"connectAllStreams": "Connect All Streams",
@ -289,6 +323,9 @@
"audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.",
"audioCodecRequired": "An audio stream is required to support audio detection.",
"restreamingWarning": "Reducing connections to the camera for the record stream may increase CPU usage slightly.",
"brands": {
"reolink-rtsp": "Reolink RTSP is not recommended. Enable HTTP in the camera's firmware settings and restart the wizard."
},
"dahua": {
"substreamWarning": "Substream 1 is locked to a low resolution. Many Dahua / Amcrest / EmpireTech cameras support additional substreams that need to be enabled in the camera's settings. It is recommended to check and utilize those streams if available."
},

View File

@ -12,15 +12,15 @@ import { toast } from "sonner";
import useSWR from "swr";
import axios from "axios";
import Step1NameCamera from "@/components/settings/wizard/Step1NameCamera";
import Step2StreamConfig from "@/components/settings/wizard/Step2StreamConfig";
import Step3Validation from "@/components/settings/wizard/Step3Validation";
import Step2ProbeOrSnapshot from "@/components/settings/wizard/Step2ProbeOrSnapshot";
import Step3StreamConfig from "@/components/settings/wizard/Step3StreamConfig";
import Step4Validation from "@/components/settings/wizard/Step4Validation";
import type {
WizardFormData,
CameraConfigData,
ConfigSetBody,
} from "@/types/cameraWizard";
import { processCameraName } from "@/utils/cameraUtil";
import { isDesktop } from "react-device-detect";
import { cn } from "@/lib/utils";
type WizardState = {
@ -57,6 +57,7 @@ const wizardReducer = (
const STEPS = [
"cameraWizard.steps.nameAndConnection",
"cameraWizard.steps.probeOrSnapshot",
"cameraWizard.steps.streamConfiguration",
"cameraWizard.steps.validationAndTesting",
];
@ -100,20 +101,20 @@ export default function CameraWizardDialog({
const canProceedToNext = useCallback((): boolean => {
switch (currentStep) {
case 0:
// Can proceed if camera name is set and at least one stream exists
return !!(
state.wizardData.cameraName &&
(state.wizardData.streams?.length ?? 0) > 0
);
// Step 1: Can proceed if camera name is set
return !!state.wizardData.cameraName;
case 1:
// Can proceed if at least one stream has 'detect' role
// Step 2: Can proceed if at least one stream exists (from probe or manual test)
return (state.wizardData.streams?.length ?? 0) > 0;
case 2:
// Step 3: Can proceed if at least one stream has 'detect' role
return !!(
state.wizardData.streams?.some((stream) =>
stream.roles.includes("detect"),
) ?? false
);
case 2:
// Always can proceed from final step (save will be handled there)
case 3:
// Step 4: Always can proceed from final step (save will be handled there)
return true;
default:
return false;
@ -340,13 +341,7 @@ export default function CameraWizardDialog({
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
className={cn(
"max-h-[90dvh] max-w-xl overflow-y-auto",
isDesktop &&
currentStep == 0 &&
state.wizardData?.streams?.[0]?.testResult?.snapshot &&
"max-w-4xl",
isDesktop && currentStep == 1 && "max-w-2xl",
isDesktop && currentStep > 1 && "max-w-4xl",
"scrollbar-container max-h-[90dvh] max-w-3xl overflow-y-auto",
)}
onInteractOutside={(e) => {
e.preventDefault();
@ -385,7 +380,16 @@ export default function CameraWizardDialog({
/>
)}
{currentStep === 1 && (
<Step2StreamConfig
<Step2ProbeOrSnapshot
wizardData={state.wizardData}
onUpdate={onUpdate}
onNext={handleNext}
onBack={handleBack}
probeMode={state.wizardData.probeMode ?? true}
/>
)}
{currentStep === 2 && (
<Step3StreamConfig
wizardData={state.wizardData}
onUpdate={onUpdate}
onBack={handleBack}
@ -393,8 +397,8 @@ export default function CameraWizardDialog({
canProceed={canProceedToNext()}
/>
)}
{currentStep === 2 && (
<Step3Validation
{currentStep === 3 && (
<Step4Validation
wizardData={state.wizardData}
onUpdate={onUpdate}
onSave={handleSave}

View File

@ -0,0 +1,368 @@
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { FaCopy, FaCheck } from "react-icons/fa";
import { LuX } from "react-icons/lu";
import { CiCircleAlert } from "react-icons/ci";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useState } from "react";
import { toast } from "sonner";
import type {
OnvifProbeResponse,
OnvifRtspCandidate,
TestResult,
CandidateTestMap,
} from "@/types/cameraWizard";
import { FaCircleCheck } from "react-icons/fa6";
import { cn } from "@/lib/utils";
type OnvifProbeResultsProps = {
isLoading: boolean;
isError: boolean;
error?: string;
probeResult?: OnvifProbeResponse;
onRetry: () => void;
selectedUris?: string[];
testCandidate?: (uri: string) => void;
candidateTests?: CandidateTestMap;
testingCandidates?: Record<string, boolean>;
};
export default function OnvifProbeResults({
isLoading,
isError,
error,
probeResult,
onRetry,
selectedUris,
testCandidate,
candidateTests,
testingCandidates,
}: OnvifProbeResultsProps) {
const { t } = useTranslation(["views/settings"]);
const [copiedUri, setCopiedUri] = useState<string | null>(null);
const handleCopyUri = (uri: string) => {
navigator.clipboard.writeText(uri);
setCopiedUri(uri);
toast.success(t("cameraWizard.step2.uriCopied"));
setTimeout(() => setCopiedUri(null), 2000);
};
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-8">
<ActivityIndicator className="size-6" />
<p className="text-sm text-muted-foreground">
{t("cameraWizard.step2.probingDevice")}
</p>
</div>
);
}
if (isError) {
return (
<div className="space-y-4">
<Alert variant="destructive">
<CiCircleAlert className="size-5" />
<AlertTitle>{t("cameraWizard.step2.probeError")}</AlertTitle>
{error && <AlertDescription>{error}</AlertDescription>}
</Alert>
<Button onClick={onRetry} variant="outline" className="w-full">
{t("button.retry", { ns: "common" })}
</Button>
</div>
);
}
if (!probeResult?.success) {
return (
<div className="space-y-4">
<Alert variant="destructive">
<CiCircleAlert className="size-5" />
<AlertTitle>{t("cameraWizard.step2.probeNoSuccess")}</AlertTitle>
{probeResult?.message && (
<AlertDescription>{probeResult.message}</AlertDescription>
)}
</Alert>
<Button onClick={onRetry} variant="outline" className="w-full">
{t("button.retry", { ns: "common" })}
</Button>
</div>
);
}
const rtspCandidates = (probeResult.rtsp_candidates || []).filter(
(c) => c.source === "GetStreamUri",
);
if (probeResult?.success && rtspCandidates.length === 0) {
return (
<div className="space-y-4">
<Alert variant="destructive">
<CiCircleAlert className="size-5" />
<AlertTitle>{t("cameraWizard.step2.noRtspCandidates")}</AlertTitle>
</Alert>
</div>
);
}
return (
<>
<div className="space-y-2">
{probeResult?.success && (
<div className="mb-3 flex flex-row items-center gap-2 text-sm text-success">
<FaCircleCheck className="size-4" />
<span>{t("cameraWizard.step2.probeSuccessful")}</span>
</div>
)}
<div className="text-sm">{t("cameraWizard.step2.deviceInfo")}</div>
<Card>
<CardContent className="space-y-2 p-4 text-sm">
{probeResult.manufacturer && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.manufacturer")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.manufacturer}
</span>
</div>
)}
{probeResult.model && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.model")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.model}
</span>
</div>
)}
{probeResult.firmware_version && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.firmware")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.firmware_version}
</span>
</div>
)}
{probeResult.profiles_count !== undefined && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.profiles")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.profiles_count}
</span>
</div>
)}
{probeResult.ptz_supported !== undefined && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.ptzSupport")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.ptz_supported
? t("yes", { ns: "common" })
: t("no", { ns: "common" })}
</span>
</div>
)}
{probeResult.ptz_supported && probeResult.autotrack_supported && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.autotrackingSupport")}:
</span>{" "}
<span className="text-primary-variant">
{t("yes", { ns: "common" })}
</span>
</div>
)}
{probeResult.ptz_supported &&
probeResult.presets_count !== undefined && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.presets")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.presets_count}
</span>
</div>
)}
</CardContent>
</Card>
</div>
<div className="space-y-2">
{rtspCandidates.length > 0 && (
<div className="mt-5 space-y-2">
<div className="text-sm">
{t("cameraWizard.step2.rtspCandidates")}
</div>
<div className="text-sm text-muted-foreground">
{t("cameraWizard.step2.rtspCandidatesDescription")}
</div>
<div className="space-y-2">
{rtspCandidates.map((candidate, idx) => {
const isSelected = !!selectedUris?.includes(candidate.uri);
const candidateTest = candidateTests?.[candidate.uri];
const isTesting = testingCandidates?.[candidate.uri];
return (
<CandidateItem
key={idx}
index={idx}
candidate={candidate}
copiedUri={copiedUri}
onCopy={() => handleCopyUri(candidate.uri)}
isSelected={isSelected}
testCandidate={testCandidate}
candidateTest={candidateTest}
isTesting={isTesting}
/>
);
})}
</div>
</div>
)}
</div>
</>
);
}
type CandidateItemProps = {
candidate: OnvifRtspCandidate;
index?: number;
copiedUri: string | null;
onCopy: () => void;
isSelected?: boolean;
testCandidate?: (uri: string) => void;
candidateTest?: TestResult | { success: false; error: string };
isTesting?: boolean;
};
function CandidateItem({
index,
candidate,
copiedUri,
onCopy,
isSelected,
testCandidate,
candidateTest,
isTesting,
}: CandidateItemProps) {
const { t } = useTranslation(["views/settings"]);
const [showFull, setShowFull] = useState(false);
const maskUri = (uri: string) => {
const match = uri.match(/rtsp:\/\/([^:]+):([^@]+)@(.+)/);
if (match) return `rtsp://${match[1]}:••••@${match[3]}`;
return uri;
};
return (
<Card
className={cn(
isSelected &&
"outline outline-[3px] -outline-offset-[2.8px] outline-selected duration-200",
)}
>
<CardContent className="p-4">
<div className="flex flex-col space-y-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">
{t("cameraWizard.step2.candidateStreamTitle", {
number: (index ?? 0) + 1,
})}
</h4>
{candidateTest?.success && (
<div className="mt-1 text-sm text-muted-foreground">
{[
candidateTest.resolution,
candidateTest.fps
? `${candidateTest.fps} ${t(
"cameraWizard.testResultLabels.fps",
)}`
: null,
candidateTest.videoCodec,
candidateTest.audioCodec,
]
.filter(Boolean)
.join(" · ")}
</div>
)}
</div>
<div className="flex flex-shrink-0 items-center gap-2">
{candidateTest?.success && (
<div className="flex items-center gap-2 text-sm">
<FaCircleCheck className="size-4 text-success" />
<span className="text-success">
{t("cameraWizard.step2.connected")}
</span>
</div>
)}
{candidateTest && !candidateTest.success && (
<div className="flex items-center gap-2 text-sm">
<LuX className="size-4 text-danger" />
<span className="text-danger">
{t("cameraWizard.step2.notConnected")}
</span>
</div>
)}
</div>
</div>
<div className="mt-1 flex items-start gap-2">
<p
className="flex-1 cursor-pointer break-all text-sm text-primary-variant hover:underline"
onClick={() => setShowFull((s) => !s)}
title={t("cameraWizard.step2.toggleUriView")}
>
{showFull ? candidate.uri : maskUri(candidate.uri)}
</p>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={onCopy}
className="mr-4 size-8 p-0"
title={t("cameraWizard.step2.uriCopy")}
>
{copiedUri === candidate.uri ? (
<FaCheck className="size-3" />
) : (
<FaCopy className="size-3" />
)}
</Button>
<Button
size="sm"
variant="outline"
disabled={isTesting}
onClick={() => testCandidate?.(candidate.uri)}
className="h-8 px-3 text-sm"
>
{isTesting ? (
<>
<ActivityIndicator className="mr-2 size-4" />{" "}
{t("cameraWizard.step2.testConnection")}
</>
) : (
t("cameraWizard.step2.testConnection")
)}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@ -15,15 +16,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useState, useCallback, useMemo } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import axios from "axios";
import { toast } from "sonner";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import {
@ -31,20 +30,13 @@ import {
CameraBrand,
CAMERA_BRANDS,
CAMERA_BRAND_VALUES,
TestResult,
FfprobeStream,
StreamRole,
StreamConfig,
} from "@/types/cameraWizard";
import { FaCircleCheck } from "react-icons/fa6";
import { Card, CardContent, CardTitle } from "../../ui/card";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { LuInfo } from "react-icons/lu";
import { detectReolinkCamera } from "@/utils/cameraUtil";
type Step1NameCameraProps = {
wizardData: Partial<WizardFormData>;
@ -63,9 +55,9 @@ export default function Step1NameCamera({
const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config");
const [showPassword, setShowPassword] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [testStatus, setTestStatus] = useState<string>("");
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [probeMode, setProbeMode] = useState<boolean>(
wizardData.probeMode ?? true,
);
const existingCameraNames = useMemo(() => {
if (!config?.cameras) {
@ -88,6 +80,7 @@ export default function Step1NameCamera({
username: z.string().optional(),
password: z.string().optional(),
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
onvifPort: z.coerce.number().int().min(1).max(65535).optional(),
customUrl: z
.string()
.optional()
@ -124,6 +117,7 @@ export default function Step1NameCamera({
? (wizardData.brandTemplate as CameraBrand)
: "dahua",
customUrl: wizardData.customUrl || "",
onvifPort: wizardData.onvifPort ?? 80,
},
mode: "onChange",
});
@ -132,271 +126,212 @@ export default function Step1NameCamera({
const watchedHost = form.watch("host");
const watchedCustomUrl = form.watch("customUrl");
const isTestButtonEnabled =
watchedBrand === "other"
? !!(watchedCustomUrl && watchedCustomUrl.trim())
: !!(watchedHost && watchedHost.trim());
const hostPresent = !!(watchedHost && watchedHost.trim());
const customPresent = !!(watchedCustomUrl && watchedCustomUrl.trim());
const cameraNamePresent = !!(form.getValues().cameraName || "").trim();
const generateDynamicStreamUrl = useCallback(
async (data: z.infer<typeof step1FormData>): Promise<string | null> => {
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
if (!brand || !data.host) return null;
let protocol = undefined;
if (data.brandTemplate === "reolink" && data.username && data.password) {
try {
protocol = await detectReolinkCamera(
data.host,
data.username,
data.password,
);
} catch (error) {
return null;
}
}
// Use detected protocol or fallback to rtsp
const protocolKey = protocol || "rtsp";
const templates: Record<string, string> = brand.dynamicTemplates || {};
if (Object.keys(templates).includes(protocolKey)) {
const template =
templates[protocolKey as keyof typeof brand.dynamicTemplates];
return template
.replace("{username}", data.username || "")
.replace("{password}", data.password || "")
.replace("{host}", data.host);
}
return null;
},
[],
);
const generateStreamUrl = useCallback(
async (data: z.infer<typeof step1FormData>): Promise<string> => {
if (data.brandTemplate === "other") {
return data.customUrl || "";
}
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
if (!brand || !data.host) return "";
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
const dynamicUrl = await generateDynamicStreamUrl(data);
if (dynamicUrl) {
return dynamicUrl;
}
return "";
}
return brand.template
.replace("{username}", data.username || "")
.replace("{password}", data.password || "")
.replace("{host}", data.host);
},
[generateDynamicStreamUrl],
);
const testConnection = useCallback(async () => {
const data = form.getValues();
const streamUrl = await generateStreamUrl(data);
if (!streamUrl) {
toast.error(t("cameraWizard.commonErrors.noUrl"));
return;
}
setIsTesting(true);
setTestStatus("");
setTestResult(null);
try {
// First get probe data for metadata
setTestStatus(t("cameraWizard.step1.testing.probingMetadata"));
const probeResponse = await axios.get("ffprobe", {
params: { paths: streamUrl, detailed: true },
timeout: 10000,
});
let probeData = null;
if (
probeResponse.data &&
probeResponse.data.length > 0 &&
probeResponse.data[0].return_code === 0
) {
probeData = probeResponse.data[0];
}
// Then get snapshot for preview (only if probe succeeded)
let snapshotBlob = null;
if (probeData) {
setTestStatus(t("cameraWizard.step1.testing.fetchingSnapshot"));
try {
const snapshotResponse = await axios.get("ffprobe/snapshot", {
params: { url: streamUrl },
responseType: "blob",
timeout: 10000,
});
snapshotBlob = snapshotResponse.data;
} catch (snapshotError) {
// Snapshot is optional, don't fail if it doesn't work
toast.warning(t("cameraWizard.step1.warnings.noSnapshot"));
}
}
if (probeData) {
const ffprobeData = probeData.stdout;
const streams = ffprobeData.streams || [];
const videoStream = streams.find(
(s: FfprobeStream) =>
s.codec_type === "video" ||
s.codec_name?.includes("h264") ||
s.codec_name?.includes("hevc"),
);
const audioStream = streams.find(
(s: FfprobeStream) =>
s.codec_type === "audio" ||
s.codec_name?.includes("aac") ||
s.codec_name?.includes("mp3") ||
s.codec_name?.includes("pcm_mulaw") ||
s.codec_name?.includes("pcm_alaw"),
);
const resolution = videoStream
? `${videoStream.width}x${videoStream.height}`
: undefined;
// Extract FPS from rational (e.g., "15/1" -> 15)
const fps = videoStream?.avg_frame_rate
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
parseFloat(videoStream.avg_frame_rate.split("/")[1])
: undefined;
// Convert snapshot blob to base64 if available
let snapshotBase64 = undefined;
if (snapshotBlob) {
snapshotBase64 = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(snapshotBlob);
});
}
const testResult: TestResult = {
success: true,
snapshot: snapshotBase64,
resolution,
videoCodec: videoStream?.codec_name,
audioCodec: audioStream?.codec_name,
fps: fps && !isNaN(fps) ? fps : undefined,
};
setTestResult(testResult);
onUpdate({ streams: [{ id: "", url: "", roles: [], testResult }] });
toast.success(t("cameraWizard.step1.testSuccess"));
} else {
const error =
Array.isArray(probeResponse.data?.[0]?.stderr) &&
probeResponse.data[0].stderr.length > 0
? probeResponse.data[0].stderr.join("\n")
: "Unable to probe stream";
setTestResult({
success: false,
error: error,
});
toast.error(t("cameraWizard.commonErrors.testFailed", { error }), {
duration: 6000,
});
}
} catch (error) {
const axiosError = error as {
response?: { data?: { message?: string; detail?: string } };
message?: string;
};
const errorMessage =
axiosError.response?.data?.message ||
axiosError.response?.data?.detail ||
axiosError.message ||
"Connection failed";
setTestResult({
success: false,
error: errorMessage,
});
toast.error(
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
{
duration: 10000,
},
);
} finally {
setIsTesting(false);
setTestStatus("");
}
}, [form, generateStreamUrl, t, onUpdate]);
const isContinueButtonEnabled =
cameraNamePresent &&
(probeMode
? hostPresent
: watchedBrand === "other"
? customPresent
: hostPresent);
const onSubmit = (data: z.infer<typeof step1FormData>) => {
onUpdate(data);
onUpdate({ ...data, probeMode });
};
const handleContinue = useCallback(async () => {
const data = form.getValues();
const streamUrl = await generateStreamUrl(data);
const streamId = `stream_${Date.now()}`;
const streamConfig: StreamConfig = {
id: streamId,
url: streamUrl,
roles: ["detect" as StreamRole],
resolution: testResult?.resolution,
testResult: testResult || undefined,
userTested: false,
};
const updatedData = {
...data,
streams: [streamConfig],
};
onNext(updatedData);
}, [form, generateStreamUrl, testResult, onNext]);
const isValid = await form.trigger();
if (isValid) {
const data = form.getValues();
onNext({ ...data, probeMode });
}
}, [form, probeMode, onNext]);
return (
<div className="space-y-6">
{!testResult?.success && (
<>
<div className="text-sm text-muted-foreground">
{t("cameraWizard.step1.description")}
</div>
<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 className="text-primary-variant">
{t("cameraWizard.step1.cameraName")}
</FormLabel>
<FormControl>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="cameraName"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("cameraWizard.step1.cameraName")}
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
placeholder={t("cameraWizard.step1.cameraNamePlaceholder")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-4">
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("cameraWizard.step1.host")}
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
placeholder="192.168.1.100"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("cameraWizard.step1.username")}
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
placeholder={t("cameraWizard.step1.usernamePlaceholder")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("cameraWizard.step1.password")}
</FormLabel>
<FormControl>
<div className="relative">
<Input
className="text-md h-8"
className="text-md h-8 pr-10"
type={showPassword ? "text" : "password"}
placeholder={t(
"cameraWizard.step1.cameraNamePlaceholder",
"cameraWizard.step1.passwordPlaceholder",
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<LuEyeOff className="size-4" />
) : (
<LuEye className="size-4" />
)}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-3 pt-4">
<FormLabel className="text-primary-variant">
{t("cameraWizard.step1.detectionMethod")}
</FormLabel>
<RadioGroup
value={probeMode ? "probe" : "manual"}
onValueChange={(value) => {
setProbeMode(value === "probe");
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="probe"
id="probe-mode"
className={
probeMode
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<label htmlFor="probe-mode" className="cursor-pointer text-sm">
{t("cameraWizard.step1.probeMode")}
</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="manual"
id="manual-mode"
className={
!probeMode
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<label htmlFor="manual-mode" className="cursor-pointer text-sm">
{t("cameraWizard.step1.manualMode")}
</label>
</div>
</RadioGroup>
<FormDescription>
{t("cameraWizard.step1.detectionMethodDescription")}
</FormDescription>
</div>
{probeMode && (
<FormField
control={form.control}
name="onvifPort"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("cameraWizard.step1.onvifPort")}
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
type="text"
{...field}
placeholder="80"
/>
</FormControl>
<FormDescription>
{t("cameraWizard.step1.onvifPortDescription")}
</FormDescription>
<FormMessage>
{fieldState.error ? fieldState.error.message : null}
</FormMessage>
</FormItem>
)}
/>
)}
{!probeMode && (
<div className="space-y-4">
<FormField
control={form.control}
name="brandTemplate"
@ -463,90 +398,6 @@ export default function Step1NameCamera({
)}
/>
{watchedBrand !== "other" && (
<>
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("cameraWizard.step1.host")}
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
placeholder="192.168.1.100"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("cameraWizard.step1.username")}
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
placeholder={t(
"cameraWizard.step1.usernamePlaceholder",
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("cameraWizard.step1.password")}
</FormLabel>
<FormControl>
<div className="relative">
<Input
className="text-md h-8 pr-10"
type={showPassword ? "text" : "password"}
placeholder={t(
"cameraWizard.step1.passwordPlaceholder",
)}
{...field}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<LuEyeOff className="size-4" />
) : (
<LuEye className="size-4" />
)}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBrand == "other" && (
<FormField
control={form.control}
@ -568,124 +419,25 @@ export default function Step1NameCamera({
)}
/>
)}
</form>
</Form>
</>
)}
</div>
)}
</form>
</Form>
{testResult?.success && (
<div className="p-4">
<div className="mb-3 flex flex-row items-center gap-2 text-sm font-medium text-success">
<FaCircleCheck className="size-4" />
{t("cameraWizard.step1.testSuccess")}
</div>
<div className="space-y-3">
{testResult.snapshot ? (
<div className="relative flex justify-center">
<img
src={testResult.snapshot}
alt="Camera snapshot"
className="max-h-[50dvh] max-w-full rounded-lg object-contain"
/>
<div className="absolute bottom-2 right-2 rounded-md bg-black/70 p-3 text-sm backdrop-blur-sm">
<div className="space-y-1">
<StreamDetails testResult={testResult} />
</div>
</div>
</div>
) : (
<Card className="p-4">
<CardTitle className="mb-2 text-sm">
{t("cameraWizard.step1.streamDetails")}
</CardTitle>
<CardContent className="p-0 text-sm">
<StreamDetails testResult={testResult} />
</CardContent>
</Card>
)}
</div>
</div>
)}
{isTesting && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ActivityIndicator className="size-4" />
{testStatus}
</div>
)}
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onCancel} className="sm:flex-1">
{t("button.cancel", { ns: "common" })}
</Button>
<Button
type="button"
onClick={testResult?.success ? () => setTestResult(null) : onCancel}
className="sm:flex-1"
onClick={handleContinue}
disabled={!isContinueButtonEnabled}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{testResult?.success
? t("button.back", { ns: "common" })
: t("button.cancel", { ns: "common" })}
{t("button.continue", { 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"
>
{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>
)}
</>
);
}

View File

@ -0,0 +1,724 @@
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { useState, useCallback, useEffect } from "react";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import axios from "axios";
import { toast } from "sonner";
import type {
WizardFormData,
TestResult,
StreamConfig,
StreamRole,
OnvifProbeResponse,
CandidateTestMap,
FfprobeStream,
FfprobeData,
FfprobeResponse,
} from "@/types/cameraWizard";
import { FaCircleCheck } from "react-icons/fa6";
import { Card, CardContent, CardTitle } from "../../ui/card";
import OnvifProbeResults from "./OnvifProbeResults";
import { CAMERA_BRANDS } from "@/types/cameraWizard";
import { detectReolinkCamera } from "@/utils/cameraUtil";
type Step2ProbeOrSnapshotProps = {
wizardData: Partial<WizardFormData>;
onUpdate: (data: Partial<WizardFormData>) => void;
onNext: (data?: Partial<WizardFormData>) => void;
onBack: () => void;
probeMode: boolean;
};
export default function Step2ProbeOrSnapshot({
wizardData,
onUpdate,
onNext,
onBack,
probeMode,
}: Step2ProbeOrSnapshotProps) {
const { t } = useTranslation(["views/settings"]);
const [isTesting, setIsTesting] = useState(false);
const [testStatus, setTestStatus] = useState<string>("");
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [isProbing, setIsProbing] = useState(false);
const [probeError, setProbeError] = useState<string | null>(null);
const [probeResult, setProbeResult] = useState<OnvifProbeResponse | null>(
null,
);
const [testingCandidates, setTestingCandidates] = useState<
Record<string, boolean>
>({} as Record<string, boolean>);
const [candidateTests, setCandidateTests] = useState<CandidateTestMap>(
{} as CandidateTestMap,
);
const probeUri = useCallback(
async (
uri: string,
fetchSnapshot = false,
setStatus?: (s: string) => void,
): Promise<TestResult> => {
try {
const probeResponse = await axios.get("ffprobe", {
params: { paths: uri, detailed: true },
timeout: 10000,
});
let probeData: FfprobeResponse | null = null;
if (
probeResponse.data &&
probeResponse.data.length > 0 &&
probeResponse.data[0].return_code === 0
) {
probeData = probeResponse.data[0];
}
if (!probeData) {
const error =
Array.isArray(probeResponse.data?.[0]?.stderr) &&
probeResponse.data[0].stderr.length > 0
? probeResponse.data[0].stderr.join("\n")
: "Unable to probe stream";
return { success: false, error };
}
let ffprobeData: FfprobeData;
if (typeof probeData.stdout === "string") {
try {
ffprobeData = JSON.parse(probeData.stdout as string) as FfprobeData;
} catch {
ffprobeData = { streams: [] };
}
} else {
ffprobeData = probeData.stdout as FfprobeData;
}
const streams = ffprobeData.streams || [];
const videoStream = streams.find(
(s: FfprobeStream) =>
s.codec_type === "video" ||
s.codec_name?.includes("h264") ||
s.codec_name?.includes("hevc"),
);
const audioStream = streams.find(
(s: FfprobeStream) =>
s.codec_type === "audio" ||
s.codec_name?.includes("aac") ||
s.codec_name?.includes("mp3") ||
s.codec_name?.includes("pcm_mulaw") ||
s.codec_name?.includes("pcm_alaw"),
);
let resolution: string | undefined = undefined;
if (videoStream) {
const width = Number(videoStream.width || 0);
const height = Number(videoStream.height || 0);
if (width > 0 && height > 0) {
resolution = `${width}x${height}`;
}
}
const fps = videoStream?.avg_frame_rate
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
parseFloat(videoStream.avg_frame_rate.split("/")[1])
: undefined;
let snapshotBase64: string | undefined = undefined;
if (fetchSnapshot) {
if (setStatus) {
setStatus(t("cameraWizard.step2.testing.fetchingSnapshot"));
}
try {
const snapshotResponse = await axios.get("ffprobe/snapshot", {
params: { url: uri },
responseType: "blob",
timeout: 10000,
});
const snapshotBlob = snapshotResponse.data;
snapshotBase64 = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(snapshotBlob);
});
} catch (snapshotError) {
snapshotBase64 = undefined;
}
}
const streamTestResult: TestResult = {
success: true,
snapshot: snapshotBase64,
resolution,
videoCodec: videoStream?.codec_name,
audioCodec: audioStream?.codec_name,
fps: fps && !isNaN(fps) ? fps : undefined,
};
return streamTestResult;
} catch (err) {
const axiosError = err as {
response?: { data?: { message?: string; detail?: string } };
message?: string;
};
const errorMessage =
axiosError.response?.data?.message ||
axiosError.response?.data?.detail ||
axiosError.message ||
"Connection failed";
return { success: false, error: errorMessage };
}
},
[t],
);
const probeCamera = useCallback(async () => {
if (!wizardData.host) {
toast.error(t("cameraWizard.step2.errors.hostRequired"));
return;
}
setIsProbing(true);
setProbeError(null);
setProbeResult(null);
try {
const response = await axios.get("/onvif/probe", {
params: {
host: wizardData.host,
port: wizardData.onvifPort ?? 80,
username: wizardData.username || "",
password: wizardData.password || "",
test: false,
},
timeout: 30000,
});
if (response.data && response.data.success) {
setProbeResult(response.data);
// Extract candidate URLs and pass to wizardData
const candidateUris = (response.data.rtsp_candidates || [])
.filter((c: { source: string }) => c.source === "GetStreamUri")
.map((c: { uri: string }) => c.uri);
onUpdate({
probeMode: true,
probeCandidates: candidateUris,
candidateTests: {},
});
} else {
setProbeError(response.data?.message || "Probe failed");
}
} catch (error) {
const axiosError = error as {
response?: { data?: { message?: string; detail?: string } };
message?: string;
};
const errorMessage =
axiosError.response?.data?.message ||
axiosError.response?.data?.detail ||
axiosError.message ||
"Failed to probe camera";
setProbeError(errorMessage);
toast.error(t("cameraWizard.step2.probeFailed", { error: errorMessage }));
} finally {
setIsProbing(false);
}
}, [wizardData, onUpdate, t]);
const testAllSelectedCandidates = useCallback(async () => {
const uris = (probeResult?.rtsp_candidates || [])
.filter((c) => c.source === "GetStreamUri")
.map((c) => c.uri);
if (!uris || uris.length === 0) {
toast.error(t("cameraWizard.commonErrors.noUrl"));
return;
}
// Prepare an initial stream so the wizard can proceed to step 3.
// Use the first candidate as the initial stream (no extra probing here).
const streamsToCreate: StreamConfig[] = [];
if (uris.length > 0) {
const first = uris[0];
streamsToCreate.push({
id: `stream_${Date.now()}`,
url: first,
roles: ["detect" as const],
testResult: candidateTests[first],
});
}
// Use existing candidateTests state (may contain entries from individual tests)
onNext({
probeMode: true,
probeCandidates: uris,
candidateTests: candidateTests,
streams: streamsToCreate,
});
}, [probeResult, candidateTests, onNext, t]);
const testCandidate = useCallback(
async (uri: string) => {
if (!uri) return;
setTestingCandidates((s) => ({ ...s, [uri]: true }));
try {
const result = await probeUri(uri, false);
setCandidateTests((s) => ({ ...s, [uri]: result }));
} finally {
setTestingCandidates((s) => ({ ...s, [uri]: false }));
}
},
[probeUri],
);
const generateDynamicStreamUrl = useCallback(
async (data: Partial<WizardFormData>): Promise<string | null> => {
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
if (!brand || !data.host) return null;
let protocol = undefined;
if (data.brandTemplate === "reolink" && data.username && data.password) {
try {
protocol = await detectReolinkCamera(
data.host,
data.username,
data.password,
);
} catch (error) {
return null;
}
}
const protocolKey = protocol || "rtsp";
const templates: Record<string, string> = brand.dynamicTemplates || {};
if (Object.keys(templates).includes(protocolKey)) {
const template =
templates[protocolKey as keyof typeof brand.dynamicTemplates];
return template
.replace("{username}", data.username || "")
.replace("{password}", data.password || "")
.replace("{host}", data.host);
}
return null;
},
[],
);
const generateStreamUrl = useCallback(
async (data: Partial<WizardFormData>): Promise<string> => {
if (data.brandTemplate === "other") {
return data.customUrl || "";
}
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
if (!brand || !data.host) return "";
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
const dynamicUrl = await generateDynamicStreamUrl(data);
if (dynamicUrl) {
return dynamicUrl;
}
return "";
}
return brand.template
.replace("{username}", data.username || "")
.replace("{password}", data.password || "")
.replace("{host}", data.host);
},
[generateDynamicStreamUrl],
);
const testConnection = useCallback(
async (showToast = true) => {
const streamUrl = await generateStreamUrl(wizardData);
if (!streamUrl) {
toast.error(t("cameraWizard.commonErrors.noUrl"));
return;
}
setIsTesting(true);
setTestStatus("");
setTestResult(null);
try {
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
const result = await probeUri(streamUrl, true, setTestStatus);
if (result && result.success) {
setTestResult(result);
const streamId = `stream_${Date.now()}`;
onUpdate({
streams: [
{
id: streamId,
url: streamUrl,
roles: ["detect"] as StreamRole[],
testResult: result,
},
],
});
if (showToast) {
toast.success(t("cameraWizard.step2.testSuccess"));
}
} else {
const errMsg = result?.error || "Unable to probe stream";
setTestResult({
success: false,
error: errMsg,
});
if (showToast) {
toast.error(
t("cameraWizard.commonErrors.testFailed", { error: errMsg }),
{
duration: 6000,
},
);
}
}
} catch (error) {
const axiosError = error as {
response?: { data?: { message?: string; detail?: string } };
message?: string;
};
const errorMessage =
axiosError.response?.data?.message ||
axiosError.response?.data?.detail ||
axiosError.message ||
"Connection failed";
setTestResult({
success: false,
error: errorMessage,
});
if (showToast) {
toast.error(
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
{
duration: 10000,
},
);
}
} finally {
setIsTesting(false);
setTestStatus("");
}
},
[wizardData, generateStreamUrl, t, onUpdate, probeUri],
);
const handleContinue = useCallback(() => {
onNext();
}, [onNext]);
// Auto-start probe or test when step loads
const [hasStarted, setHasStarted] = useState(false);
useEffect(() => {
if (!hasStarted) {
setHasStarted(true);
if (probeMode) {
probeCamera();
} else {
// Auto-run the connection test but suppress toasts to avoid duplicates
testConnection(false);
}
}
}, [hasStarted, probeMode, probeCamera, testConnection]);
return (
<div className="space-y-6">
{probeMode ? (
// Probe mode: show probe results directly
<>
{probeResult && (
<div className="space-y-4">
<OnvifProbeResults
isLoading={isProbing}
isError={!!probeError}
error={probeError || undefined}
probeResult={probeResult}
onRetry={probeCamera}
testCandidate={testCandidate}
candidateTests={candidateTests}
testingCandidates={testingCandidates}
/>
</div>
)}
<ProbeFooterButtons
isProbing={isProbing}
probeError={probeError}
onBack={onBack}
onTestAll={testAllSelectedCandidates}
onRetry={probeCamera}
// disable next if either the overall testConnection is running or any candidate test is running
isTesting={
isTesting || Object.values(testingCandidates).some((v) => v)
}
candidateCount={
(probeResult?.rtsp_candidates || []).filter(
(c) => c.source === "GetStreamUri",
).length
}
/>
</>
) : (
// Manual mode: show snapshot and stream details
<>
{testResult?.success && (
<div className="p-4">
<div className="mb-3 flex flex-row items-center gap-2 text-sm font-medium text-success">
<FaCircleCheck className="size-4" />
{t("cameraWizard.step2.testSuccess")}
</div>
<div className="space-y-3">
{testResult.snapshot ? (
<div className="relative flex justify-center">
<img
src={testResult.snapshot}
alt="Camera snapshot"
className="max-h-[50dvh] max-w-full rounded-lg object-contain"
/>
<div className="absolute bottom-2 right-2 rounded-md bg-black/70 p-3 text-sm backdrop-blur-sm">
<div className="space-y-1">
<StreamDetails testResult={testResult} />
</div>
</div>
</div>
) : (
<Card className="p-4">
<CardTitle className="mb-2 text-sm">
{t("cameraWizard.step2.streamDetails")}
</CardTitle>
<CardContent className="p-0 text-sm">
<StreamDetails testResult={testResult} />
</CardContent>
</Card>
)}
</div>
</div>
)}
{isTesting && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ActivityIndicator className="size-4" />
{testStatus}
</div>
)}
{testResult && !testResult.success && (
<div className="space-y-4">
<div className="text-sm text-destructive">{testResult.error}</div>
</div>
)}
<ProbeFooterButtons
mode="manual"
isProbing={false}
probeError={null}
onBack={onBack}
onTestAll={testAllSelectedCandidates}
onRetry={probeCamera}
isTesting={
isTesting || Object.values(testingCandidates).some((v) => v)
}
candidateCount={
(probeResult?.rtsp_candidates || []).filter(
(c) => c.source === "GetStreamUri",
).length
}
manualTestSuccess={!!testResult?.success}
onContinue={handleContinue}
onManualTest={testConnection}
/>
</>
)}
</div>
);
}
function StreamDetails({ testResult }: { testResult: TestResult }) {
const { t } = useTranslation(["views/settings"]);
return (
<>
{testResult.resolution && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.resolution")}:
</span>{" "}
<span className="text-white">{testResult.resolution}</span>
</div>
)}
{testResult.fps && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.fps")}:
</span>{" "}
<span className="text-white">{testResult.fps}</span>
</div>
)}
{testResult.videoCodec && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.video")}:
</span>{" "}
<span className="text-white">{testResult.videoCodec}</span>
</div>
)}
{testResult.audioCodec && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.audio")}:
</span>{" "}
<span className="text-white">{testResult.audioCodec}</span>
</div>
)}
</>
);
}
type ProbeFooterProps = {
isProbing: boolean;
probeError: string | null;
onBack: () => void;
onTestAll: () => void;
onRetry: () => void;
isTesting: boolean;
candidateCount?: number;
mode?: "probe" | "manual";
manualTestSuccess?: boolean;
onContinue?: () => void;
onManualTest?: () => void;
};
function ProbeFooterButtons({
isProbing,
probeError,
onBack,
onTestAll,
onRetry,
isTesting,
candidateCount = 0,
mode = "probe",
manualTestSuccess,
onContinue,
onManualTest,
}: ProbeFooterProps) {
const { t } = useTranslation(["views/settings"]);
// Loading footer
if (isProbing) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ActivityIndicator className="size-4" />
{t("cameraWizard.step2.probing")}
</div>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} disabled className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
disabled
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
<ActivityIndicator className="size-4" />
{t("cameraWizard.step2.probing")}
</Button>
</div>
</div>
);
}
// Error footer
if (probeError) {
return (
<div className="space-y-4">
<div className="text-sm text-destructive">{probeError}</div>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
onClick={onRetry}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{t("cameraWizard.step2.retry")}
</Button>
</div>
</div>
);
}
// Default footer: show back + test (test disabled if none selected or testing)
// If manual mode, show Continue when test succeeded, otherwise show Test (calls onManualTest)
if (mode === "manual") {
return (
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
{manualTestSuccess ? (
<Button
type="button"
onClick={onContinue}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{t("button.continue", { ns: "common" })}
</Button>
) : (
<Button
type="button"
onClick={onManualTest}
disabled={isTesting}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{isTesting ? (
<>
<ActivityIndicator className="size-4" />{" "}
{t("button.continue", { ns: "common" })}
</>
) : (
t("cameraWizard.step2.retry")
)}
</Button>
)}
</div>
);
}
// Default probe footer
return (
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
onClick={onTestAll}
disabled={isTesting || (candidateCount ?? 0) === 0}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{t("button.next", { ns: "common" })}
</Button>
</div>
);
}

View File

@ -1,481 +0,0 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useTranslation } from "react-i18next";
import { useState, useCallback, useMemo } from "react";
import { LuPlus, LuTrash2, LuX } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import axios from "axios";
import { toast } from "sonner";
import {
WizardFormData,
StreamConfig,
StreamRole,
TestResult,
FfprobeStream,
} from "@/types/cameraWizard";
import { Label } from "../../ui/label";
import { FaCircleCheck } from "react-icons/fa6";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { LuInfo, LuExternalLink } from "react-icons/lu";
import { Link } from "react-router-dom";
import { useDocDomain } from "@/hooks/use-doc-domain";
type Step2StreamConfigProps = {
wizardData: Partial<WizardFormData>;
onUpdate: (data: Partial<WizardFormData>) => void;
onBack?: () => void;
onNext?: () => void;
canProceed?: boolean;
};
export default function Step2StreamConfig({
wizardData,
onUpdate,
onBack,
onNext,
canProceed,
}: Step2StreamConfigProps) {
const { t } = useTranslation(["views/settings", "components/dialog"]);
const { getLocaleDocUrl } = useDocDomain();
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
const addStream = useCallback(() => {
const newStream: StreamConfig = {
id: `stream_${Date.now()}`,
url: "",
roles: [],
};
onUpdate({
streams: [...streams, newStream],
});
}, [streams, onUpdate]);
const removeStream = useCallback(
(streamId: string) => {
onUpdate({
streams: streams.filter((s) => s.id !== streamId),
});
},
[streams, onUpdate],
);
const updateStream = useCallback(
(streamId: string, updates: Partial<StreamConfig>) => {
onUpdate({
streams: streams.map((s) =>
s.id === streamId ? { ...s, ...updates } : s,
),
});
},
[streams, onUpdate],
);
const getUsedRolesExcludingStream = useCallback(
(excludeStreamId: string) => {
const roles = new Set<StreamRole>();
streams.forEach((stream) => {
if (stream.id !== excludeStreamId) {
stream.roles.forEach((role) => roles.add(role));
}
});
return roles;
},
[streams],
);
const toggleRole = useCallback(
(streamId: string, role: StreamRole) => {
const stream = streams.find((s) => s.id === streamId);
if (!stream) return;
const hasRole = stream.roles.includes(role);
if (hasRole) {
// Allow removing the role
const newRoles = stream.roles.filter((r) => r !== role);
updateStream(streamId, { roles: newRoles });
} else {
// Check if role is already used in another stream
const usedRoles = getUsedRolesExcludingStream(streamId);
if (!usedRoles.has(role)) {
// Allow adding the role
const newRoles = [...stream.roles, role];
updateStream(streamId, { roles: newRoles });
}
}
},
[streams, updateStream, getUsedRolesExcludingStream],
);
const testStream = useCallback(
(stream: StreamConfig) => {
if (!stream.url.trim()) {
toast.error(t("cameraWizard.commonErrors.noUrl"));
return;
}
setTestingStreams((prev) => new Set(prev).add(stream.id));
axios
.get("ffprobe", {
params: { paths: stream.url, detailed: true },
timeout: 10000,
})
.then((response) => {
if (response.data?.[0]?.return_code === 0) {
const probeData = response.data[0];
const streams = probeData.stdout.streams || [];
const videoStream = streams.find(
(s: FfprobeStream) =>
s.codec_type === "video" ||
s.codec_name?.includes("h264") ||
s.codec_name?.includes("h265"),
);
const audioStream = streams.find(
(s: FfprobeStream) =>
s.codec_type === "audio" ||
s.codec_name?.includes("aac") ||
s.codec_name?.includes("mp3"),
);
const resolution = videoStream
? `${videoStream.width}x${videoStream.height}`
: undefined;
const fps = videoStream?.avg_frame_rate
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
parseFloat(videoStream.avg_frame_rate.split("/")[1])
: undefined;
const testResult: TestResult = {
success: true,
resolution,
videoCodec: videoStream?.codec_name,
audioCodec: audioStream?.codec_name,
fps: fps && !isNaN(fps) ? fps : undefined,
};
updateStream(stream.id, { testResult, userTested: true });
toast.success(t("cameraWizard.step2.testSuccess"));
} else {
const error = response.data?.[0]?.stderr || "Unknown error";
updateStream(stream.id, {
testResult: { success: false, error },
userTested: true,
});
toast.error(t("cameraWizard.commonErrors.testFailed", { error }));
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Connection failed";
updateStream(stream.id, {
testResult: { success: false, error: errorMessage },
userTested: true,
});
toast.error(
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
);
})
.finally(() => {
setTestingStreams((prev) => {
const newSet = new Set(prev);
newSet.delete(stream.id);
return newSet;
});
});
},
[updateStream, t],
);
const setRestream = useCallback(
(streamId: string) => {
const stream = streams.find((s) => s.id === streamId);
if (!stream) return;
updateStream(streamId, { restream: !stream.restream });
},
[streams, updateStream],
);
const hasDetectRole = streams.some((s) => s.roles.includes("detect"));
return (
<div className="space-y-6">
<div className="text-sm text-secondary-foreground">
{t("cameraWizard.step2.description")}
</div>
<div className="space-y-4">
{streams.map((stream, index) => (
<Card key={stream.id} className="bg-secondary text-primary">
<CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">
{t("cameraWizard.step2.streamTitle", { number: index + 1 })}
</h4>
{stream.testResult && stream.testResult.success && (
<div className="mt-1 text-sm text-muted-foreground">
{[
stream.testResult.resolution,
stream.testResult.fps
? `${stream.testResult.fps} ${t("cameraWizard.testResultLabels.fps")}`
: null,
stream.testResult.videoCodec,
stream.testResult.audioCodec,
]
.filter(Boolean)
.join(" · ")}
</div>
)}
</div>
<div className="flex items-center gap-2">
{stream.testResult?.success && (
<div className="flex items-center gap-2 text-sm">
<FaCircleCheck className="size-4 text-success" />
<span className="text-success">
{t("cameraWizard.step2.connected")}
</span>
</div>
)}
{stream.testResult && !stream.testResult.success && (
<div className="flex items-center gap-2 text-sm">
<LuX className="size-4 text-danger" />
<span className="text-danger">
{t("cameraWizard.step2.notConnected")}
</span>
</div>
)}
{streams.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => removeStream(stream.id)}
className="text-secondary-foreground hover:text-secondary-foreground"
>
<LuTrash2 className="size-5" />
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-primary-variant">
{t("cameraWizard.step2.url")}
</label>
<div className="flex flex-row items-center gap-2">
<Input
value={stream.url}
onChange={(e) =>
updateStream(stream.id, {
url: e.target.value,
testResult: undefined,
})
}
className="h-8 flex-1"
placeholder={t("cameraWizard.step2.streamUrlPlaceholder")}
/>
<Button
type="button"
onClick={() => testStream(stream)}
disabled={
testingStreams.has(stream.id) || !stream.url.trim()
}
variant="outline"
size="sm"
>
{testingStreams.has(stream.id) && (
<ActivityIndicator className="mr-2 size-4" />
)}
{t("cameraWizard.step2.testStream")}
</Button>
</div>
</div>
</div>
{stream.testResult &&
!stream.testResult.success &&
stream.userTested && (
<div className="rounded-md border border-danger/20 bg-danger/10 p-3 text-sm text-danger">
<div className="font-medium">
{t("cameraWizard.step2.testFailedTitle")}
</div>
<div className="mt-1 text-xs">
{stream.testResult.error}
</div>
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-1">
<Label className="text-sm font-medium text-primary-variant">
{t("cameraWizard.step2.roles")}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
<LuInfo className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="pointer-events-auto w-80 text-xs">
<div className="space-y-2">
<div className="font-medium">
{t("cameraWizard.step2.rolesPopover.title")}
</div>
<div className="space-y-1 text-muted-foreground">
<div>
<strong>detect</strong> -{" "}
{t("cameraWizard.step2.rolesPopover.detect")}
</div>
<div>
<strong>record</strong> -{" "}
{t("cameraWizard.step2.rolesPopover.record")}
</div>
<div>
<strong>audio</strong> -{" "}
{t("cameraWizard.step2.rolesPopover.audio")}
</div>
</div>
<div className="mt-3 flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/cameras")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<div className="rounded-lg bg-background p-3">
<div className="flex flex-wrap gap-2">
{(["detect", "record", "audio"] as const).map((role) => {
const isUsedElsewhere = getUsedRolesExcludingStream(
stream.id,
).has(role);
const isChecked = stream.roles.includes(role);
return (
<div
key={role}
className="flex w-full items-center justify-between"
>
<span className="text-sm capitalize">{role}</span>
<Switch
checked={isChecked}
onCheckedChange={() => toggleRole(stream.id, role)}
disabled={!isChecked && isUsedElsewhere}
/>
</div>
);
})}
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1">
<Label className="text-sm font-medium text-primary-variant">
{t("cameraWizard.step2.featuresTitle")}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
<LuInfo className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="pointer-events-auto w-80 text-xs">
<div className="space-y-2">
<div className="font-medium">
{t("cameraWizard.step2.featuresPopover.title")}
</div>
<div className="text-muted-foreground">
{t("cameraWizard.step2.featuresPopover.description")}
</div>
<div className="mt-3 flex items-center text-primary">
<Link
to={getLocaleDocUrl(
"configuration/restream#reduce-connections-to-camera",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<div className="rounded-lg bg-background p-3">
<div className="flex items-center justify-between">
<span className="text-sm">
{t("cameraWizard.step2.go2rtc")}
</span>
<Switch
checked={stream.restream || false}
onCheckedChange={() => setRestream(stream.id)}
/>
</div>
</div>
</div>
</CardContent>
</Card>
))}
<Button
type="button"
onClick={addStream}
variant="outline"
className=""
>
<LuPlus className="mr-2 size-4" />
{t("cameraWizard.step2.addAnotherStream")}
</Button>
</div>
{!hasDetectRole && (
<div className="rounded-lg border border-danger/50 p-3 text-sm text-danger">
{t("cameraWizard.step2.detectRoleWarning")}
</div>
)}
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
{onBack && (
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
)}
{onNext && (
<Button
type="button"
onClick={() => onNext?.()}
disabled={!canProceed}
variant="select"
className="sm:flex-1"
>
{t("button.next", { ns: "common" })}
</Button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,757 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useTranslation } from "react-i18next";
import { useState, useCallback, useMemo } from "react";
import { LuPlus, LuTrash2, LuX } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import axios from "axios";
import { toast } from "sonner";
import {
WizardFormData,
StreamConfig,
StreamRole,
TestResult,
FfprobeStream,
FfprobeData,
FfprobeResponse,
CandidateTestMap,
} from "@/types/cameraWizard";
import { Label } from "../../ui/label";
import { FaCircleCheck } from "react-icons/fa6";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import { isMobile } from "react-device-detect";
import {
LuInfo,
LuExternalLink,
LuCheck,
LuChevronsUpDown,
} from "react-icons/lu";
import { Link } from "react-router-dom";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { cn } from "@/lib/utils";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
type Step3StreamConfigProps = {
wizardData: Partial<WizardFormData>;
onUpdate: (data: Partial<WizardFormData>) => void;
onBack?: () => void;
onNext?: () => void;
canProceed?: boolean;
};
export default function Step3StreamConfig({
wizardData,
onUpdate,
onBack,
onNext,
canProceed,
}: Step3StreamConfigProps) {
const { t } = useTranslation(["views/settings", "components/dialog"]);
const { getLocaleDocUrl } = useDocDomain();
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
const [openCombobox, setOpenCombobox] = useState<string | null>(null);
const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
// Probe mode candidate tracking
const probeCandidates = useMemo(
() => (wizardData.probeCandidates || []) as string[],
[wizardData.probeCandidates],
);
const candidateTests = useMemo(
() => (wizardData.candidateTests || {}) as CandidateTestMap,
[wizardData.candidateTests],
);
const isProbeMode = !!wizardData.probeMode;
const addStream = useCallback(() => {
const newStreamId = `stream_${Date.now()}`;
let initialUrl = "";
if (isProbeMode && probeCandidates.length > 0) {
// pick first candidate not already used
const used = new Set(streams.map((s) => s.url).filter(Boolean));
const firstAvailable = probeCandidates.find((c) => !used.has(c));
if (firstAvailable) {
initialUrl = firstAvailable;
}
}
const newStream: StreamConfig = {
id: newStreamId,
url: initialUrl,
roles: [],
testResult: initialUrl ? candidateTests[initialUrl] : undefined,
userTested: initialUrl ? !!candidateTests[initialUrl] : false,
};
onUpdate({
streams: [...streams, newStream],
});
}, [streams, onUpdate, isProbeMode, probeCandidates, candidateTests]);
const removeStream = useCallback(
(streamId: string) => {
onUpdate({
streams: streams.filter((s) => s.id !== streamId),
});
},
[streams, onUpdate],
);
const updateStream = useCallback(
(streamId: string, updates: Partial<StreamConfig>) => {
onUpdate({
streams: streams.map((s) =>
s.id === streamId ? { ...s, ...updates } : s,
),
});
},
[streams, onUpdate],
);
const getUsedRolesExcludingStream = useCallback(
(excludeStreamId: string) => {
const roles = new Set<StreamRole>();
streams.forEach((stream) => {
if (stream.id !== excludeStreamId) {
stream.roles.forEach((role) => roles.add(role));
}
});
return roles;
},
[streams],
);
const getUsedUrlsExcludingStream = useCallback(
(excludeStreamId: string) => {
const used = new Set<string>();
streams.forEach((s) => {
if (s.id !== excludeStreamId && s.url) {
used.add(s.url);
}
});
return used;
},
[streams],
);
const toggleRole = useCallback(
(streamId: string, role: StreamRole) => {
const stream = streams.find((s) => s.id === streamId);
if (!stream) return;
const hasRole = stream.roles.includes(role);
if (hasRole) {
// Allow removing the role
const newRoles = stream.roles.filter((r) => r !== role);
updateStream(streamId, { roles: newRoles });
} else {
// Check if role is already used in another stream
const usedRoles = getUsedRolesExcludingStream(streamId);
if (!usedRoles.has(role)) {
// Allow adding the role
const newRoles = [...stream.roles, role];
updateStream(streamId, { roles: newRoles });
}
}
},
[streams, updateStream, getUsedRolesExcludingStream],
);
const testStream = useCallback(
async (stream: StreamConfig) => {
if (!stream.url.trim()) {
toast.error(t("cameraWizard.commonErrors.noUrl"));
return;
}
setTestingStreams((prev) => new Set(prev).add(stream.id));
try {
const response = await axios.get("ffprobe", {
params: { paths: stream.url, detailed: true },
timeout: 10000,
});
let probeData: FfprobeResponse | null = null;
if (
response.data &&
response.data.length > 0 &&
response.data[0].return_code === 0
) {
probeData = response.data[0];
}
if (!probeData) {
const error =
Array.isArray(response.data?.[0]?.stderr) &&
response.data[0].stderr.length > 0
? response.data[0].stderr.join("\n")
: "Unable to probe stream";
const failResult: TestResult = { success: false, error };
updateStream(stream.id, { testResult: failResult, userTested: true });
onUpdate({
candidateTests: {
...(wizardData.candidateTests || {}),
[stream.url]: failResult,
} as CandidateTestMap,
});
toast.error(t("cameraWizard.commonErrors.testFailed", { error }));
return;
}
let ffprobeData: FfprobeData;
if (typeof probeData.stdout === "string") {
try {
ffprobeData = JSON.parse(probeData.stdout as string) as FfprobeData;
} catch {
ffprobeData = { streams: [] } as FfprobeData;
}
} else {
ffprobeData = probeData.stdout as FfprobeData;
}
const streamsArr = ffprobeData.streams || [];
const videoStream = streamsArr.find(
(s: FfprobeStream) =>
s.codec_type === "video" ||
s.codec_name?.includes("h264") ||
s.codec_name?.includes("hevc"),
);
const audioStream = streamsArr.find(
(s: FfprobeStream) =>
s.codec_type === "audio" ||
s.codec_name?.includes("aac") ||
s.codec_name?.includes("mp3") ||
s.codec_name?.includes("pcm_mulaw") ||
s.codec_name?.includes("pcm_alaw"),
);
let resolution: string | undefined = undefined;
if (videoStream) {
const width = Number(videoStream.width || 0);
const height = Number(videoStream.height || 0);
if (width > 0 && height > 0) {
resolution = `${width}x${height}`;
}
}
const fps = videoStream?.avg_frame_rate
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
parseFloat(videoStream.avg_frame_rate.split("/")[1])
: undefined;
const testResult: TestResult = {
success: true,
resolution,
videoCodec: videoStream?.codec_name,
audioCodec: audioStream?.codec_name,
fps: fps && !isNaN(fps) ? fps : undefined,
};
updateStream(stream.id, { testResult, userTested: true });
onUpdate({
candidateTests: {
...(wizardData.candidateTests || {}),
[stream.url]: testResult,
} as CandidateTestMap,
});
toast.success(t("cameraWizard.step3.testSuccess"));
} catch (error) {
const axiosError = error as {
response?: { data?: { message?: string; detail?: string } };
message?: string;
};
const errorMessage =
axiosError.response?.data?.message ||
axiosError.response?.data?.detail ||
axiosError.message ||
"Connection failed";
const catchResult: TestResult = {
success: false,
error: errorMessage,
};
updateStream(stream.id, { testResult: catchResult, userTested: true });
onUpdate({
candidateTests: {
...(wizardData.candidateTests || {}),
[stream.url]: catchResult,
} as CandidateTestMap,
});
toast.error(
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
);
} finally {
setTestingStreams((prev) => {
const newSet = new Set(prev);
newSet.delete(stream.id);
return newSet;
});
}
},
[updateStream, t, onUpdate, wizardData.candidateTests],
);
const setRestream = useCallback(
(streamId: string) => {
const stream = streams.find((s) => s.id === streamId);
if (!stream) return;
updateStream(streamId, { restream: !stream.restream });
},
[streams, updateStream],
);
const hasDetectRole = streams.some((s) => s.roles.includes("detect"));
return (
<div className="space-y-6">
<div className="text-sm text-secondary-foreground">
{t("cameraWizard.step3.description")}
</div>
<div className="space-y-4">
{streams.map((stream, index) => (
<Card key={stream.id} className="bg-secondary text-primary">
<CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">
{t("cameraWizard.step3.streamTitle", { number: index + 1 })}
</h4>
{stream.testResult && stream.testResult.success && (
<div className="mt-1 text-sm text-muted-foreground">
{[
stream.testResult.resolution,
stream.testResult.fps
? `${stream.testResult.fps} ${t("cameraWizard.testResultLabels.fps")}`
: null,
stream.testResult.videoCodec,
stream.testResult.audioCodec,
]
.filter(Boolean)
.join(" · ")}
</div>
)}
</div>
<div className="flex items-center gap-2">
{stream.testResult?.success && (
<div className="flex items-center gap-2 text-sm">
<FaCircleCheck className="size-4 text-success" />
<span className="text-success">
{t("cameraWizard.step3.connected")}
</span>
</div>
)}
{stream.testResult && !stream.testResult.success && (
<div className="flex items-center gap-2 text-sm">
<LuX className="size-4 text-danger" />
<span className="text-danger">
{t("cameraWizard.step3.notConnected")}
</span>
</div>
)}
{streams.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => removeStream(stream.id)}
className="text-secondary-foreground hover:text-secondary-foreground"
>
<LuTrash2 className="size-5" />
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-primary-variant">
{t("cameraWizard.step3.url")}
</label>
<div className="flex flex-row items-center gap-2">
{isProbeMode && probeCandidates.length > 0 ? (
// Responsive: Popover on desktop, Drawer on mobile
!isMobile ? (
<Popover
open={openCombobox === stream.id}
onOpenChange={(isOpen) => {
setOpenCombobox(isOpen ? stream.id : null);
}}
>
<PopoverTrigger asChild>
<div className="min-w-0 flex-1">
<Button
variant="outline"
role="combobox"
aria-expanded={openCombobox === stream.id}
className="h-8 w-full justify-between overflow-hidden text-left"
>
<span className="truncate">
{stream.url
? stream.url
: t("cameraWizard.step3.selectStream")}
</span>
<LuChevronsUpDown className="ml-2 size-6 opacity-50" />
</Button>
</div>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-2"
disablePortal
>
<Command>
<CommandInput
placeholder={t(
"cameraWizard.step3.searchCandidates",
)}
className="h-9"
/>
<CommandList>
<CommandEmpty>
{t("cameraWizard.step3.noStreamFound")}
</CommandEmpty>
<CommandGroup>
{probeCandidates
.filter((c) => {
const used = getUsedUrlsExcludingStream(
stream.id,
);
return !used.has(c);
})
.map((candidate) => (
<CommandItem
key={candidate}
value={candidate}
onSelect={() => {
updateStream(stream.id, {
url: candidate,
testResult:
candidateTests[candidate] ||
undefined,
userTested:
!!candidateTests[candidate],
});
setOpenCombobox(null);
}}
>
<LuCheck
className={cn(
"mr-3 size-5",
stream.url === candidate
? "opacity-100"
: "opacity-0",
)}
/>
{candidate}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Drawer
open={openCombobox === stream.id}
onOpenChange={(isOpen) =>
setOpenCombobox(isOpen ? stream.id : null)
}
>
<DrawerTrigger asChild>
<div className="min-w-0 flex-1">
<Button
variant="outline"
role="combobox"
aria-expanded={openCombobox === stream.id}
className="h-8 w-full justify-between overflow-hidden text-left"
>
<span className="truncate">
{stream.url
? stream.url
: t("cameraWizard.step3.selectStream")}
</span>
<LuChevronsUpDown className="ml-2 size-6 opacity-50" />
</Button>
</div>
</DrawerTrigger>
<DrawerContent className="mx-1 max-h-[75dvh] overflow-hidden rounded-t-2xl px-2">
<div className="mt-2">
<Command>
<CommandInput
placeholder={t(
"cameraWizard.step3.searchCandidates",
)}
className="h-9"
/>
<CommandList>
<CommandEmpty>
{t("cameraWizard.step3.noStreamFound")}
</CommandEmpty>
<CommandGroup>
{probeCandidates
.filter((c) => {
const used = getUsedUrlsExcludingStream(
stream.id,
);
return !used.has(c);
})
.map((candidate) => (
<CommandItem
key={candidate}
value={candidate}
onSelect={() => {
updateStream(stream.id, {
url: candidate,
testResult:
candidateTests[candidate] ||
undefined,
userTested:
!!candidateTests[candidate],
});
setOpenCombobox(null);
}}
>
<LuCheck
className={cn(
"mr-3 size-5",
stream.url === candidate
? "opacity-100"
: "opacity-0",
)}
/>
{candidate}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</div>
</DrawerContent>
</Drawer>
)
) : (
<Input
value={stream.url}
onChange={(e) =>
updateStream(stream.id, {
url: e.target.value,
testResult: undefined,
})
}
className="h-8 flex-1"
placeholder={t(
"cameraWizard.step3.streamUrlPlaceholder",
)}
/>
)}
<Button
type="button"
onClick={() => testStream(stream)}
disabled={
testingStreams.has(stream.id) || !stream.url.trim()
}
variant="outline"
size="sm"
>
{testingStreams.has(stream.id) && (
<ActivityIndicator className="mr-2 size-4" />
)}
{t("cameraWizard.step3.testStream")}
</Button>
</div>
</div>
</div>
{stream.testResult &&
!stream.testResult.success &&
stream.userTested && (
<div className="rounded-md border border-danger/20 bg-danger/10 p-3 text-sm text-danger">
<div className="font-medium">
{t("cameraWizard.step3.testFailedTitle")}
</div>
<div className="mt-1 text-xs">
{stream.testResult.error}
</div>
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-1">
<Label className="text-sm font-medium text-primary-variant">
{t("cameraWizard.step3.roles")}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
<LuInfo className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="pointer-events-auto w-80 text-xs">
<div className="space-y-2">
<div className="font-medium">
{t("cameraWizard.step3.rolesPopover.title")}
</div>
<div className="space-y-1 text-muted-foreground">
<div>
<strong>detect</strong> -{" "}
{t("cameraWizard.step3.rolesPopover.detect")}
</div>
<div>
<strong>record</strong> -{" "}
{t("cameraWizard.step3.rolesPopover.record")}
</div>
<div>
<strong>audio</strong> -{" "}
{t("cameraWizard.step3.rolesPopover.audio")}
</div>
</div>
<div className="mt-3 flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/cameras")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<div className="rounded-lg bg-background p-3">
<div className="flex flex-wrap gap-2">
{(["detect", "record", "audio"] as const).map((role) => {
const isUsedElsewhere = getUsedRolesExcludingStream(
stream.id,
).has(role);
const isChecked = stream.roles.includes(role);
return (
<div
key={role}
className="flex w-full items-center justify-between"
>
<span className="text-sm capitalize">{role}</span>
<Switch
checked={isChecked}
onCheckedChange={() => toggleRole(stream.id, role)}
disabled={!isChecked && isUsedElsewhere}
/>
</div>
);
})}
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1">
<Label className="text-sm font-medium text-primary-variant">
{t("cameraWizard.step3.featuresTitle")}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
<LuInfo className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="pointer-events-auto w-80 text-xs">
<div className="space-y-2">
<div className="font-medium">
{t("cameraWizard.step3.featuresPopover.title")}
</div>
<div className="text-muted-foreground">
{t("cameraWizard.step3.featuresPopover.description")}
</div>
<div className="mt-3 flex items-center text-primary">
<Link
to={getLocaleDocUrl(
"configuration/restream#reduce-connections-to-camera",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<div className="rounded-lg bg-background p-3">
<div className="flex items-center justify-between">
<span className="text-sm">
{t("cameraWizard.step3.go2rtc")}
</span>
<Switch
checked={stream.restream || false}
onCheckedChange={() => setRestream(stream.id)}
/>
</div>
</div>
</div>
</CardContent>
</Card>
))}
<Button
type="button"
onClick={addStream}
variant="outline"
className=""
>
<LuPlus className="mr-2 size-4" />
{t("cameraWizard.step3.addAnotherStream")}
</Button>
</div>
{!hasDetectRole && (
<div className="rounded-lg border border-danger/50 p-3 text-sm text-danger">
{t("cameraWizard.step3.detectRoleWarning")}
</div>
)}
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
{onBack && (
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
)}
{onNext && (
<Button
type="button"
onClick={() => onNext?.()}
disabled={!canProceed || testingStreams.size > 0}
variant="select"
className="sm:flex-1"
>
{t("button.next", { ns: "common" })}
</Button>
)}
</div>
</div>
);
}

View File

@ -19,7 +19,7 @@ import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
import { LuX } from "react-icons/lu";
import { Card, CardContent } from "../../ui/card";
type Step3ValidationProps = {
type Step4ValidationProps = {
wizardData: Partial<WizardFormData>;
onUpdate: (data: Partial<WizardFormData>) => void;
onSave: (config: WizardFormData) => void;
@ -27,13 +27,13 @@ type Step3ValidationProps = {
isLoading?: boolean;
};
export default function Step3Validation({
export default function Step4Validation({
wizardData,
onUpdate,
onSave,
onBack,
isLoading = false,
}: Step3ValidationProps) {
}: Step4ValidationProps) {
const { t } = useTranslation(["views/settings"]);
const [isValidating, setIsValidating] = useState(false);
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
@ -143,13 +143,13 @@ export default function Step3Validation({
if (testResult.success) {
toast.success(
t("cameraWizard.step3.streamValidated", {
t("cameraWizard.step4.streamValidated", {
number: streams.findIndex((s) => s.id === stream.id) + 1,
}),
);
} else {
toast.error(
t("cameraWizard.step3.streamValidationFailed", {
t("cameraWizard.step4.streamValidationFailed", {
number: streams.findIndex((s) => s.id === stream.id) + 1,
}),
);
@ -200,16 +200,16 @@ export default function Step3Validation({
(r) => r.success,
).length;
if (successfulTests === results.size) {
toast.success(t("cameraWizard.step3.reconnectionSuccess"));
toast.success(t("cameraWizard.step4.reconnectionSuccess"));
} else {
toast.warning(t("cameraWizard.step3.reconnectionPartial"));
toast.warning(t("cameraWizard.step4.reconnectionPartial"));
}
}
}, [streams, onUpdate, t, performStreamValidation]);
const handleSave = useCallback(() => {
if (!wizardData.cameraName || !wizardData.streams?.length) {
toast.error(t("cameraWizard.step3.saveError"));
toast.error(t("cameraWizard.step4.saveError"));
return;
}
@ -239,13 +239,13 @@ export default function Step3Validation({
return (
<div className="space-y-6">
<div className="text-sm text-muted-foreground">
{t("cameraWizard.step3.description")}
{t("cameraWizard.step4.description")}
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">
{t("cameraWizard.step3.validationTitle")}
{t("cameraWizard.step4.validationTitle")}
</h3>
<Button
onClick={validateAllStreams}
@ -254,8 +254,8 @@ export default function Step3Validation({
>
{isValidating && <ActivityIndicator className="mr-2 size-4" />}
{isValidating
? t("cameraWizard.step3.connecting")
: t("cameraWizard.step3.connectAllStreams")}
? t("cameraWizard.step4.connecting")
: t("cameraWizard.step4.connectAllStreams")}
</Button>
</div>
@ -270,7 +270,7 @@ export default function Step3Validation({
<div className="flex flex-col space-y-1">
<div className="flex flex-row items-center">
<h4 className="mr-2 font-medium">
{t("cameraWizard.step3.streamTitle", {
{t("cameraWizard.step4.streamTitle", {
number: index + 1,
})}
</h4>
@ -331,7 +331,7 @@ export default function Step3Validation({
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm">
{t("cameraWizard.step3.ffmpegModule")}
{t("cameraWizard.step4.ffmpegModule")}
</span>
<Popover>
<PopoverTrigger asChild>
@ -346,11 +346,11 @@ export default function Step3Validation({
<PopoverContent className="pointer-events-auto w-80 text-xs">
<div className="space-y-2">
<div className="font-medium">
{t("cameraWizard.step3.ffmpegModule")}
{t("cameraWizard.step4.ffmpegModule")}
</div>
<div className="text-muted-foreground">
{t(
"cameraWizard.step3.ffmpegModuleDescription",
"cameraWizard.step4.ffmpegModuleDescription",
)}
</div>
</div>
@ -402,17 +402,17 @@ export default function Step3Validation({
<ActivityIndicator className="mr-2 size-4" />
)}
{result?.success
? t("cameraWizard.step3.disconnectStream")
? t("cameraWizard.step4.disconnectStream")
: testingStreams.has(stream.id)
? t("cameraWizard.step3.connectingStream")
: t("cameraWizard.step3.connectStream")}
? t("cameraWizard.step4.connectingStream")
: t("cameraWizard.step4.connectStream")}
</Button>
</div>
{result && (
<div className="space-y-2">
<div className="text-xs">
{t("cameraWizard.step3.issues.title")}
{t("cameraWizard.step4.issues.title")}
</div>
<div className="rounded-lg bg-background p-3">
<StreamIssues
@ -455,7 +455,7 @@ export default function Step3Validation({
{isLoading && <ActivityIndicator className="mr-2 size-4" />}
{isLoading
? t("button.saving", { ns: "common" })
: t("cameraWizard.step3.saveAndApply")}
: t("cameraWizard.step4.saveAndApply")}
</Button>
</div>
</div>
@ -486,7 +486,7 @@ function StreamIssues({
if (streamUrl.startsWith("rtsp://")) {
result.push({
type: "warning",
message: t("cameraWizard.step1.errors.brands.reolink-rtsp"),
message: t("cameraWizard.step4.issues.brands.reolink-rtsp"),
});
}
}
@ -497,7 +497,7 @@ function StreamIssues({
if (["h264", "h265", "hevc"].includes(videoCodec)) {
result.push({
type: "good",
message: t("cameraWizard.step3.issues.videoCodecGood", {
message: t("cameraWizard.step4.issues.videoCodecGood", {
codec: stream.testResult.videoCodec,
}),
});
@ -511,20 +511,20 @@ function StreamIssues({
if (audioCodec === "aac") {
result.push({
type: "good",
message: t("cameraWizard.step3.issues.audioCodecGood", {
message: t("cameraWizard.step4.issues.audioCodecGood", {
codec: stream.testResult.audioCodec,
}),
});
} else {
result.push({
type: "error",
message: t("cameraWizard.step3.issues.audioCodecRecordError"),
message: t("cameraWizard.step4.issues.audioCodecRecordError"),
});
}
} else {
result.push({
type: "warning",
message: t("cameraWizard.step3.issues.noAudioWarning"),
message: t("cameraWizard.step4.issues.noAudioWarning"),
});
}
}
@ -534,7 +534,7 @@ function StreamIssues({
if (!stream.testResult?.audioCodec) {
result.push({
type: "error",
message: t("cameraWizard.step3.issues.audioCodecRequired"),
message: t("cameraWizard.step4.issues.audioCodecRequired"),
});
}
}
@ -544,7 +544,7 @@ function StreamIssues({
if (stream.restream) {
result.push({
type: "warning",
message: t("cameraWizard.step3.issues.restreamingWarning"),
message: t("cameraWizard.step4.issues.restreamingWarning"),
});
}
}
@ -557,14 +557,14 @@ function StreamIssues({
if (minDimension > 1080) {
result.push({
type: "warning",
message: t("cameraWizard.step3.issues.resolutionHigh", {
message: t("cameraWizard.step4.issues.resolutionHigh", {
resolution: stream.resolution,
}),
});
} else if (maxDimension < 640) {
result.push({
type: "error",
message: t("cameraWizard.step3.issues.resolutionLow", {
message: t("cameraWizard.step4.issues.resolutionLow", {
resolution: stream.resolution,
}),
});
@ -580,7 +580,7 @@ function StreamIssues({
) {
result.push({
type: "warning",
message: t("cameraWizard.step3.issues.dahua.substreamWarning"),
message: t("cameraWizard.step4.issues.dahua.substreamWarning"),
});
}
if (
@ -590,7 +590,7 @@ function StreamIssues({
) {
result.push({
type: "warning",
message: t("cameraWizard.step3.issues.hikvision.substreamWarning"),
message: t("cameraWizard.step4.issues.hikvision.substreamWarning"),
});
}
@ -662,7 +662,7 @@ function BandwidthDisplay({
return (
<div className="mb-2 text-sm">
<span className="font-medium text-muted-foreground">
{t("cameraWizard.step3.estimatedBandwidth")}:
{t("cameraWizard.step4.estimatedBandwidth")}:
</span>{" "}
<span className="text-secondary-foreground">
{streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })}
@ -748,7 +748,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
style={{ aspectRatio }}
>
<span className="text-sm text-danger">
{t("cameraWizard.step3.streamUnavailable")}
{t("cameraWizard.step4.streamUnavailable")}
</span>
<Button
variant="outline"
@ -757,7 +757,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
className="flex items-center gap-2"
>
<LuRotateCcw className="size-4" />
{t("cameraWizard.step3.reload")}
{t("cameraWizard.step4.reload")}
</Button>
</div>
);
@ -771,7 +771,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
>
<ActivityIndicator className="size-4" />
<span className="ml-2 text-sm">
{t("cameraWizard.step3.connecting")}
{t("cameraWizard.step4.connecting")}
</span>
</div>
);

View File

@ -99,6 +99,11 @@ export type TestResult = {
error?: string;
};
export type CandidateTestMap = Record<
string,
TestResult | { success: false; error: string }
>;
export type WizardFormData = {
cameraName?: string;
host?: string;
@ -107,12 +112,17 @@ export type WizardFormData = {
brandTemplate?: CameraBrand;
customUrl?: string;
streams?: StreamConfig[];
probeMode?: boolean; // true for probe, false for manual
onvifPort?: number;
probeResult?: OnvifProbeResponse;
probeCandidates?: string[]; // candidate URLs from probe
candidateTests?: CandidateTestMap; // test results for candidates
};
// API Response Types
export type FfprobeResponse = {
return_code: number;
stderr: string;
stderr: string | string[];
stdout: FfprobeData | string;
};
@ -167,3 +177,26 @@ export type ConfigSetBody = {
config_data: CameraConfigData;
update_topic?: string;
};
export type OnvifRtspCandidate = {
source: "GetStreamUri" | "pattern";
profile_token?: string;
uri: string;
};
export type OnvifProbeResponse = {
success: boolean;
host?: string;
port?: number;
manufacturer?: string;
model?: string;
firmware_version?: string;
profiles_count?: number;
ptz_supported?: boolean;
presets_count?: number;
autotrack_supported?: boolean;
move_status_supported?: boolean;
rtsp_candidates?: OnvifRtspCandidate[];
message?: string;
detail?: string;
};