From 5c41107fcddc5eaf7e3029c574881a74e1a262df Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:19:30 -0600 Subject: [PATCH] refactor to add probe and snapshot as step 2 --- web/public/locales/en/views/settings.json | 43 +- .../settings/CameraWizardDialog.tsx | 37 +- .../settings/wizard/OnvifProbeResults.tsx | 38 +- .../settings/wizard/ProbeDialog.tsx | 2 +- .../settings/wizard/Step1NameCamera.tsx | 1088 ++++------------- .../settings/wizard/Step2ProbeOrSnapshot.tsx | 640 ++++++++++ ...StreamConfig.tsx => Step3StreamConfig.tsx} | 46 +- ...tep3Validation.tsx => Step4Validation.tsx} | 72 +- 8 files changed, 1028 insertions(+), 938 deletions(-) create mode 100644 web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx rename web/src/components/settings/wizard/{Step2StreamConfig.tsx => Step3StreamConfig.tsx} (92%) rename web/src/components/settings/wizard/{Step3Validation.tsx => Step4Validation.tsx} (92%) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index e91f2d29c..7714a00e7 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -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" }, @@ -240,6 +241,43 @@ } }, "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.", + "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", + "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", @@ -278,7 +316,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", @@ -314,6 +352,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." }, diff --git a/web/src/components/settings/CameraWizardDialog.tsx b/web/src/components/settings/CameraWizardDialog.tsx index 6611a9dd6..7bffd772d 100644 --- a/web/src/components/settings/CameraWizardDialog.tsx +++ b/web/src/components/settings/CameraWizardDialog.tsx @@ -12,8 +12,9 @@ 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, @@ -57,6 +58,7 @@ const wizardReducer = ( const STEPS = [ "cameraWizard.steps.nameAndConnection", + "cameraWizard.steps.probeOrSnapshot", "cameraWizard.steps.streamConfiguration", "cameraWizard.steps.validationAndTesting", ]; @@ -100,20 +102,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; @@ -385,7 +387,16 @@ export default function CameraWizardDialog({ /> )} {currentStep === 1 && ( - + )} + {currentStep === 2 && ( + )} - {currentStep === 2 && ( - { navigator.clipboard.writeText(uri); setCopiedUri(uri); - toast.success(t("cameraWizard.step1.uriCopied")); + toast.success(t("cameraWizard.step2.uriCopied")); setTimeout(() => setCopiedUri(null), 2000); }; @@ -58,7 +58,7 @@ export default function OnvifProbeResults({

- {t("cameraWizard.step1.probingDevice")} + {t("cameraWizard.step2.probingDevice")}

); @@ -69,7 +69,7 @@ export default function OnvifProbeResults({
- {t("cameraWizard.step1.probeError")} + {t("cameraWizard.step2.probeError")} {error && {error}}
@@ -353,7 +353,7 @@ function CandidateItem({ variant="select" className="h-8 px-3 text-sm" > - {t("cameraWizard.step1.useCandidate")} + {t("cameraWizard.step2.useCandidate")} diff --git a/web/src/components/settings/wizard/ProbeDialog.tsx b/web/src/components/settings/wizard/ProbeDialog.tsx index 582ba6c40..c340426d0 100644 --- a/web/src/components/settings/wizard/ProbeDialog.tsx +++ b/web/src/components/settings/wizard/ProbeDialog.tsx @@ -61,7 +61,7 @@ export default function ProbeDialog({ return ( - + ; @@ -70,25 +55,9 @@ export default function Step1NameCamera({ const { t } = useTranslation(["views/settings"]); const { data: config } = useSWR("config"); const [showPassword, setShowPassword] = useState(false); - const [isTesting, setIsTesting] = useState(false); - const [testStatus, setTestStatus] = useState(""); - const [testResult, setTestResult] = useState(null); - const [probeMode, setProbeMode] = useState(true); - const [isProbing, setIsProbing] = useState(false); - const [probeError, setProbeError] = useState(null); - const [probeResult, setProbeResult] = useState( - null, + const [probeMode, setProbeMode] = useState( + wizardData.probeMode ?? true, ); - const [probeDialogOpen, setProbeDialogOpen] = useState(false); - const [selectedCandidateUris, setSelectedCandidateUris] = useState( - [], - ); - const [candidateTests, setCandidateTests] = useState( - {} as CandidateTestMap, - ); - const [testingCandidates, setTestingCandidates] = useState< - Record - >({} as Record); const existingCameraNames = useMemo(() => { if (!config?.cameras) { @@ -161,7 +130,7 @@ export default function Step1NameCamera({ const customPresent = !!(watchedCustomUrl && watchedCustomUrl.trim()); const cameraNamePresent = !!(form.getValues().cameraName || "").trim(); - const isTestButtonEnabled = + const isContinueButtonEnabled = cameraNamePresent && (probeMode ? hostPresent @@ -169,482 +138,261 @@ export default function Step1NameCamera({ ? customPresent : hostPresent); - const generateDynamicStreamUrl = useCallback( - async (data: z.infer): Promise => { - 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 = 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): Promise => { - 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 probeCamera = useCallback(async () => { - const data = form.getValues(); - - if (!data.host) { - toast.error(t("cameraWizard.step1.errors.hostRequired")); - return; - } - - setIsProbing(true); - setProbeError(null); - setProbeResult(null); - - try { - const response = await axios.get("/onvif/probe", { - params: { - host: data.host, - port: data.onvifPort ?? 80, - username: data.username || "", - password: data.password || "", - test: false, - }, - timeout: 30000, - }); - - if (response.data && response.data.success) { - setProbeResult(response.data); - // open the probe dialog to show results - setProbeDialogOpen(true); - } 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.step1.probeFailed", { error: errorMessage })); - } finally { - setIsProbing(false); - } - }, [form, t]); - - const handleSelectCandidate = useCallback((uri: string) => { - // toggle selection: add or remove from selectedCandidateUris - setSelectedCandidateUris((s) => { - if (s.includes(uri)) { - return s.filter((u) => u !== uri); - } - return [...s, uri]; - }); - }, []); - - // Probe a single URI and return a TestResult. If fetchSnapshot is true, - // also attempt to fetch a snapshot (may be undefined on failure). - const probeUri = useCallback( - async ( - uri: string, - fetchSnapshot = false, - setStatus?: (s: string) => void, - ): Promise => { - 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 }; - } - - // stdout may be a string or structured object. Normalize to FfprobeData. - 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"), - ); - - 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; - - let snapshotBase64: string | undefined = undefined; - if (fetchSnapshot) { - if (setStatus) { - setStatus(t("cameraWizard.step1.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((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 testAllSelectedCandidates = useCallback(async () => { - const uris = selectedCandidateUris; - if (!uris || uris.length === 0) { - toast.error(t("cameraWizard.commonErrors.noUrl")); - return; - } - - setIsTesting(true); - setTestStatus(t("cameraWizard.step1.testing.probingMetadata")); - - const streamConfigs: StreamConfig[] = []; - let firstSuccessfulTestResult: TestResult | null = null; - let firstSuccessfulUri: string | undefined = undefined; - - try { - for (let i = 0; i < uris.length; i++) { - const uri = uris[i]; - const streamTestResult = await probeUri(uri, false); - - if (streamTestResult && streamTestResult.success) { - const streamId = `stream_${Date.now()}_${i}`; - streamConfigs.push({ - id: streamId, - url: uri, - // Only the first stream should have the detect role - roles: - streamConfigs.length === 0 - ? (["detect"] as StreamRole[]) - : ([] as StreamRole[]), - testResult: streamTestResult, - }); - - // keep first successful for main pane snapshot - if (!firstSuccessfulTestResult) { - firstSuccessfulTestResult = streamTestResult; - firstSuccessfulUri = uri; - } - // also store candidate test summary - setCandidateTests((s) => ({ ...s, [uri]: streamTestResult })); - } else { - setCandidateTests((s) => ({ - ...s, - [uri]: streamTestResult, - })); - } - } - - if (streamConfigs.length > 0) { - // Add all successful streams and navigate to Step 2 - const values = form.getValues(); - const cameraName = values.cameraName; - onNext({ - cameraName, - streams: streamConfigs, - customUrl: firstSuccessfulUri, - }); - toast.success(t("cameraWizard.step1.testSuccess")); - setProbeDialogOpen(false); - setProbeResult(null); - } else { - toast.error( - t("cameraWizard.commonErrors.testFailed", { - error: "No streams succeeded", - }), - ); - } - } catch (error) { - const axiosError = error as { - response?: { data?: { message?: string; detail?: string } }; - message?: string; - }; - const errorMessage = - axiosError.response?.data?.message || - axiosError.response?.data?.detail || - axiosError.message || - "Connection failed"; - setTestResult({ - success: false, - error: errorMessage, - }); - toast.error( - t("cameraWizard.commonErrors.testFailed", { error: errorMessage }), - ); - } finally { - setIsTesting(false); - setTestStatus(""); - } - }, [selectedCandidateUris, t, onNext, probeUri, form]); - - 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 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 { - setTestStatus(t("cameraWizard.step1.testing.probingMetadata")); - const result = await probeUri(streamUrl, true, setTestStatus); - - if (result && result.success) { - setTestResult(result); - onUpdate({ - streams: [{ id: "", url: streamUrl, roles: [], testResult: result }], - }); - toast.success(t("cameraWizard.step1.testSuccess")); - } else { - const errMsg = result?.error || "Unable to probe stream"; - setTestResult({ - success: false, - error: errMsg, - }); - 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, - }); - toast.error( - t("cameraWizard.commonErrors.testFailed", { error: errorMessage }), - { - duration: 10000, - }, - ); - } finally { - setIsTesting(false); - setTestStatus(""); - } - }, [form, generateStreamUrl, t, onUpdate, probeUri]); - const onSubmit = (data: z.infer) => { - onUpdate(data); + onUpdate({ ...data, probeMode }); }; - const handleContinue = useCallback(async () => { + const handleContinue = useCallback(() => { 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]); + onUpdate({ ...data, probeMode }); + onNext(); + }, [form, probeMode, onUpdate, onNext]); return (
- {!testResult?.success && ( - <> -
- {t("cameraWizard.step1.description")} -
+
+ {t("cameraWizard.step1.description")} +
-
- - ( - - - {t("cameraWizard.step1.cameraName")} - - + + + ( + + + {t("cameraWizard.step1.cameraName")} + + + + + + + )} + /> + +
+ ( + + + {t("cameraWizard.step1.host")} + + + + + + + )} + /> + + ( + + + {t("cameraWizard.step1.username")} + + + + + + + )} + /> + + ( + + + {t("cameraWizard.step1.password")} + + +
- + +
+
+ +
+ )} + /> +
+ +
+ + {t("cameraWizard.step1.detectionMethod")} + + { + setProbeMode(value === "probe"); + }} + > +
+ + +
+
+ + +
+
+ + {t("cameraWizard.step1.detectionMethodDescription")} + +
+ + {probeMode && ( + ( + + + {t("cameraWizard.step1.onvifPort")} + + + + + + {t("cameraWizard.step1.onvifPortDescription")} + + + {fieldState.error ? fieldState.error.message : null} + + + )} + /> + )} + + {!probeMode && ( +
+ ( + +
+ + {t("cameraWizard.step1.cameraBrand")} + + {field.value && + (() => { + const selectedBrand = CAMERA_BRANDS.find( + (brand) => brand.value === field.value, + ); + return selectedBrand && + selectedBrand.value != "other" ? ( + + + + + +
+

+ {selectedBrand.label} +

+

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

+
+
+
+ ) : null; + })()} +
+
)} /> -
+ {watchedBrand == "other" && ( ( - {t("cameraWizard.step1.host")} + {t("cameraWizard.step1.customUrl")} @@ -652,376 +400,26 @@ export default function Step1NameCamera({ )} /> - - ( - - - {t("cameraWizard.step1.username")} - - - - - - - )} - /> - - ( - - - {t("cameraWizard.step1.password")} - - -
- - -
-
- -
- )} - /> -
- -
- - {t("cameraWizard.step1.detectionMethod")} - - { - setProbeMode(value === "probe"); - setProbeResult(null); - setProbeError(null); - }} - > -
- - -
-
- - -
-
- - {t("cameraWizard.step1.detectionMethodDescription")} - -
- - {probeMode && ( - ( - - - {t("cameraWizard.step1.onvifPort")} - - - - - - {t("cameraWizard.step1.onvifPortDescription")} - - - {fieldState.error ? fieldState.error.message : null} - - - )} - /> )} +
+ )} + + - {!probeMode && ( -
- ( - -
- - {t("cameraWizard.step1.cameraBrand")} - - {field.value && - (() => { - const selectedBrand = CAMERA_BRANDS.find( - (brand) => brand.value === field.value, - ); - return selectedBrand && - selectedBrand.value != "other" ? ( - - - - - -
-

- {selectedBrand.label} -

-

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

-
-
-
- ) : null; - })()} -
- - -
- )} - /> - - {watchedBrand == "other" && ( - ( - - - {t("cameraWizard.step1.customUrl")} - - - - - - - )} - /> - )} -
- )} - - - - )} - - {probeMode && probeResult && ( -
- -
- )} - - {testResult?.success && ( -
-
- - {t("cameraWizard.step1.testSuccess")} -
- -
- {testResult.snapshot ? ( -
- Camera snapshot -
-
- -
-
-
- ) : ( - - - {t("cameraWizard.step1.streamDetails")} - - - - - - )} -
-
- )} - - {isTesting && ( -
- - {testStatus} -
- )}
+ - {testResult?.success ? ( - - ) : ( - - )}
); } - -function StreamDetails({ testResult }: { testResult: TestResult }) { - const { t } = useTranslation(["views/settings"]); - - return ( - <> - {testResult.resolution && ( -
- - {t("cameraWizard.testResultLabels.resolution")}: - {" "} - {testResult.resolution} -
- )} - {testResult.fps && ( -
- - {t("cameraWizard.testResultLabels.fps")}: - {" "} - {testResult.fps} -
- )} - {testResult.videoCodec && ( -
- - {t("cameraWizard.testResultLabels.video")}: - {" "} - {testResult.videoCodec} -
- )} - {testResult.audioCodec && ( -
- - {t("cameraWizard.testResultLabels.audio")}: - {" "} - {testResult.audioCodec} -
- )} - - ); -} diff --git a/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx b/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx new file mode 100644 index 000000000..8e3234f9e --- /dev/null +++ b/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx @@ -0,0 +1,640 @@ +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 ProbeDialog from "./ProbeDialog"; +import { CAMERA_BRANDS } from "@/types/cameraWizard"; +import { detectReolinkCamera } from "@/utils/cameraUtil"; + +type Step2ProbeOrSnapshotProps = { + wizardData: Partial; + onUpdate: (data: Partial) => void; + onNext: (data?: Partial) => 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(""); + const [testResult, setTestResult] = useState(null); + const [isProbing, setIsProbing] = useState(false); + const [probeError, setProbeError] = useState(null); + const [probeResult, setProbeResult] = useState( + null, + ); + const [probeDialogOpen, setProbeDialogOpen] = useState(false); + const [selectedCandidateUris, setSelectedCandidateUris] = useState( + [], + ); + const [candidateTests, setCandidateTests] = useState( + {} as CandidateTestMap, + ); + const [testingCandidates, setTestingCandidates] = useState< + Record + >({} as Record); + + const handleSelectCandidate = useCallback((uri: string) => { + setSelectedCandidateUris((s) => { + if (s.includes(uri)) { + return s.filter((u) => u !== uri); + } + return [...s, uri]; + }); + }, []); + + const probeUri = useCallback( + async ( + uri: string, + fetchSnapshot = false, + setStatus?: (s: string) => void, + ): Promise => { + 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"), + ); + + 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; + + 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((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); + setProbeDialogOpen(true); + } 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, t]); + + const testAllSelectedCandidates = useCallback(async () => { + const uris = selectedCandidateUris; + if (!uris || uris.length === 0) { + toast.error(t("cameraWizard.commonErrors.noUrl")); + return; + } + + setIsTesting(true); + setTestStatus(t("cameraWizard.step2.testing.probingMetadata")); + + const streamConfigs: StreamConfig[] = []; + + try { + for (let i = 0; i < uris.length; i++) { + const uri = uris[i]; + const streamTestResult = await probeUri(uri, false); + + if (streamTestResult && streamTestResult.success) { + const streamId = `stream_${Date.now()}_${i}`; + streamConfigs.push({ + id: streamId, + url: uri, + roles: + streamConfigs.length === 0 + ? (["detect"] as StreamRole[]) + : ([] as StreamRole[]), + testResult: streamTestResult, + }); + setCandidateTests((s) => ({ ...s, [uri]: streamTestResult })); + } else { + setCandidateTests((s) => ({ + ...s, + [uri]: streamTestResult, + })); + } + } + + if (streamConfigs.length > 0) { + onNext({ streams: streamConfigs }); + toast.success(t("cameraWizard.step2.testSuccess")); + setProbeDialogOpen(false); + } else { + toast.error( + t("cameraWizard.commonErrors.testFailed", { + error: "No streams succeeded", + }), + ); + } + } 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"; + toast.error( + t("cameraWizard.commonErrors.testFailed", { error: errorMessage }), + ); + } finally { + setIsTesting(false); + setTestStatus(""); + } + }, [selectedCandidateUris, t, onNext, probeUri]); + + 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): Promise => { + 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 = 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): Promise => { + 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 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, + }, + ], + }); + toast.success(t("cameraWizard.step2.testSuccess")); + } else { + const errMsg = result?.error || "Unable to probe stream"; + setTestResult({ + success: false, + error: errMsg, + }); + 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, + }); + 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 { + testConnection(); + } + } + }, [hasStarted, probeMode, probeCamera, testConnection]); + + return ( +
+ {probeMode ? ( + // Probe mode: show probe dialog + <> + {probeResult && ( +
+ { + setProbeDialogOpen(open); + // If dialog is being closed (open=false), go back + if (!open) { + onBack(); + } + }} + isLoading={isProbing} + isError={!!probeError} + error={probeError || undefined} + probeResult={probeResult} + onSelectCandidate={handleSelectCandidate} + onRetry={probeCamera} + selectedCandidateUris={selectedCandidateUris} + testAllSelectedCandidates={testAllSelectedCandidates} + isTesting={isTesting} + testStatus={testStatus} + testCandidate={testCandidate} + candidateTests={candidateTests} + testingCandidates={testingCandidates} + /> +
+ )} + + {isProbing && !probeResult && ( +
+ + {t("cameraWizard.step2.probing")} +
+ )} + + {probeError && !probeResult && ( +
+
{probeError}
+
+ + +
+
+ )} + + ) : ( + // Manual mode: show snapshot and stream details + <> + {testResult?.success && ( +
+
+ + {t("cameraWizard.step2.testSuccess")} +
+ +
+ {testResult.snapshot ? ( +
+ Camera snapshot +
+
+ +
+
+
+ ) : ( + + + {t("cameraWizard.step2.streamDetails")} + + + + + + )} +
+
+ )} + + {isTesting && ( +
+ + {testStatus} +
+ )} + + {testResult && !testResult.success && ( +
+
{testResult.error}
+
+ )} + +
+ + {testResult?.success ? ( + + ) : ( + + )} +
+ + )} +
+ ); +} + +function StreamDetails({ testResult }: { testResult: TestResult }) { + const { t } = useTranslation(["views/settings"]); + + return ( + <> + {testResult.resolution && ( +
+ + {t("cameraWizard.testResultLabels.resolution")}: + {" "} + {testResult.resolution} +
+ )} + {testResult.fps && ( +
+ + {t("cameraWizard.testResultLabels.fps")}: + {" "} + {testResult.fps} +
+ )} + {testResult.videoCodec && ( +
+ + {t("cameraWizard.testResultLabels.video")}: + {" "} + {testResult.videoCodec} +
+ )} + {testResult.audioCodec && ( +
+ + {t("cameraWizard.testResultLabels.audio")}: + {" "} + {testResult.audioCodec} +
+ )} + + ); +} diff --git a/web/src/components/settings/wizard/Step2StreamConfig.tsx b/web/src/components/settings/wizard/Step3StreamConfig.tsx similarity index 92% rename from web/src/components/settings/wizard/Step2StreamConfig.tsx rename to web/src/components/settings/wizard/Step3StreamConfig.tsx index a9cb00c2e..9168d693b 100644 --- a/web/src/components/settings/wizard/Step2StreamConfig.tsx +++ b/web/src/components/settings/wizard/Step3StreamConfig.tsx @@ -26,7 +26,7 @@ import { LuInfo, LuExternalLink } from "react-icons/lu"; import { Link } from "react-router-dom"; import { useDocDomain } from "@/hooks/use-doc-domain"; -type Step2StreamConfigProps = { +type Step3StreamConfigProps = { wizardData: Partial; onUpdate: (data: Partial) => void; onBack?: () => void; @@ -34,13 +34,13 @@ type Step2StreamConfigProps = { canProceed?: boolean; }; -export default function Step2StreamConfig({ +export default function Step3StreamConfig({ wizardData, onUpdate, onBack, onNext, canProceed, -}: Step2StreamConfigProps) { +}: Step3StreamConfigProps) { const { t } = useTranslation(["views/settings", "components/dialog"]); const { getLocaleDocUrl } = useDocDomain(); const [testingStreams, setTestingStreams] = useState>(new Set()); @@ -165,7 +165,7 @@ export default function Step2StreamConfig({ }; updateStream(stream.id, { testResult, userTested: true }); - toast.success(t("cameraWizard.step2.testSuccess")); + toast.success(t("cameraWizard.step3.testSuccess")); } else { const error = response.data?.[0]?.stderr || "Unknown error"; updateStream(stream.id, { @@ -214,7 +214,7 @@ export default function Step2StreamConfig({ return (
- {t("cameraWizard.step2.description")} + {t("cameraWizard.step3.description")}
@@ -224,7 +224,7 @@ export default function Step2StreamConfig({

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

{stream.testResult && stream.testResult.success && (
@@ -246,7 +246,7 @@ export default function Step2StreamConfig({
- {t("cameraWizard.step2.connected")} + {t("cameraWizard.step3.connected")}
)} @@ -254,7 +254,7 @@ export default function Step2StreamConfig({
- {t("cameraWizard.step2.notConnected")} + {t("cameraWizard.step3.notConnected")}
)} @@ -274,7 +274,7 @@ export default function Step2StreamConfig({
@@ -311,7 +311,7 @@ export default function Step2StreamConfig({ stream.userTested && (
- {t("cameraWizard.step2.testFailedTitle")} + {t("cameraWizard.step3.testFailedTitle")}
{stream.testResult.error} @@ -322,7 +322,7 @@ export default function Step2StreamConfig({
@@ -333,20 +333,20 @@ export default function Step2StreamConfig({
- {t("cameraWizard.step2.rolesPopover.title")} + {t("cameraWizard.step3.rolesPopover.title")}
detect -{" "} - {t("cameraWizard.step2.rolesPopover.detect")} + {t("cameraWizard.step3.rolesPopover.detect")}
record -{" "} - {t("cameraWizard.step2.rolesPopover.record")} + {t("cameraWizard.step3.rolesPopover.record")}
audio -{" "} - {t("cameraWizard.step2.rolesPopover.audio")} + {t("cameraWizard.step3.rolesPopover.audio")}
@@ -392,7 +392,7 @@ export default function Step2StreamConfig({
@@ -403,10 +403,10 @@ export default function Step2StreamConfig({
- {t("cameraWizard.step2.featuresPopover.title")} + {t("cameraWizard.step3.featuresPopover.title")}
- {t("cameraWizard.step2.featuresPopover.description")} + {t("cameraWizard.step3.featuresPopover.description")}
- {t("cameraWizard.step2.go2rtc")} + {t("cameraWizard.step3.go2rtc")} - {t("cameraWizard.step2.addAnotherStream")} + {t("cameraWizard.step3.addAnotherStream")}
{!hasDetectRole && (
- {t("cameraWizard.step2.detectRoleWarning")} + {t("cameraWizard.step3.detectRoleWarning")}
)} diff --git a/web/src/components/settings/wizard/Step3Validation.tsx b/web/src/components/settings/wizard/Step4Validation.tsx similarity index 92% rename from web/src/components/settings/wizard/Step3Validation.tsx rename to web/src/components/settings/wizard/Step4Validation.tsx index a0dd72e7e..f99a05305 100644 --- a/web/src/components/settings/wizard/Step3Validation.tsx +++ b/web/src/components/settings/wizard/Step4Validation.tsx @@ -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; onUpdate: (data: Partial) => 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>(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 (
- {t("cameraWizard.step3.description")} + {t("cameraWizard.step4.description")}

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

@@ -270,7 +270,7 @@ export default function Step3Validation({

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

@@ -331,7 +331,7 @@ export default function Step3Validation({
- {t("cameraWizard.step3.ffmpegModule")} + {t("cameraWizard.step4.ffmpegModule")} @@ -346,11 +346,11 @@ export default function Step3Validation({
- {t("cameraWizard.step3.ffmpegModule")} + {t("cameraWizard.step4.ffmpegModule")}
{t( - "cameraWizard.step3.ffmpegModuleDescription", + "cameraWizard.step4.ffmpegModuleDescription", )}
@@ -402,17 +402,17 @@ export default function Step3Validation({ )} {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")}
{result && (
- {t("cameraWizard.step3.issues.title")} + {t("cameraWizard.step4.issues.title")}
} {isLoading ? t("button.saving", { ns: "common" }) - : t("cameraWizard.step3.saveAndApply")} + : t("cameraWizard.step4.saveAndApply")}
@@ -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 (
- {t("cameraWizard.step3.estimatedBandwidth")}: + {t("cameraWizard.step4.estimatedBandwidth")}: {" "} {streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })} @@ -748,7 +748,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) { style={{ aspectRatio }} > - {t("cameraWizard.step3.streamUnavailable")} + {t("cameraWizard.step4.streamUnavailable")}
); @@ -771,7 +771,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) { > - {t("cameraWizard.step3.connecting")} + {t("cameraWizard.step4.connecting")}
);