diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 37d2e95b1..fcbabc949 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -287,6 +287,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", diff --git a/web/src/components/settings/wizard/OnvifProbeResults.tsx b/web/src/components/settings/wizard/OnvifProbeResults.tsx index 014dfde29..5f3f9af8e 100644 --- a/web/src/components/settings/wizard/OnvifProbeResults.tsx +++ b/web/src/components/settings/wizard/OnvifProbeResults.tsx @@ -22,7 +22,6 @@ type OnvifProbeResultsProps = { isError: boolean; error?: string; probeResult?: OnvifProbeResponse; - onSelectCandidate: (uri: string) => void; onRetry: () => void; selectedUris?: string[]; testCandidate?: (uri: string) => void; @@ -35,7 +34,6 @@ export default function OnvifProbeResults({ isError, error, probeResult, - onSelectCandidate, onRetry, selectedUris, testCandidate, @@ -221,7 +219,6 @@ export default function OnvifProbeResults({ candidate={candidate} copiedUri={copiedUri} onCopy={() => handleCopyUri(candidate.uri)} - onUse={() => onSelectCandidate(candidate.uri)} isSelected={isSelected} testCandidate={testCandidate} candidateTest={candidateTest} @@ -242,7 +239,6 @@ type CandidateItemProps = { index?: number; copiedUri: string | null; onCopy: () => void; - onUse: () => void; isSelected?: boolean; testCandidate?: (uri: string) => void; candidateTest?: TestResult | { success: false; error: string }; @@ -254,7 +250,6 @@ function CandidateItem({ candidate, copiedUri, onCopy, - onUse, isSelected, testCandidate, candidateTest, @@ -366,17 +361,6 @@ function CandidateItem({ - -
- -
diff --git a/web/src/components/settings/wizard/Step1NameCamera.tsx b/web/src/components/settings/wizard/Step1NameCamera.tsx index ae6a23bad..0467b54b8 100644 --- a/web/src/components/settings/wizard/Step1NameCamera.tsx +++ b/web/src/components/settings/wizard/Step1NameCamera.tsx @@ -142,11 +142,13 @@ export default function Step1NameCamera({ onUpdate({ ...data, probeMode }); }; - const handleContinue = useCallback(() => { - const data = form.getValues(); - onUpdate({ ...data, probeMode }); - onNext(); - }, [form, probeMode, onUpdate, onNext]); + const handleContinue = useCallback(async () => { + const isValid = await form.trigger(); + if (isValid) { + const data = form.getValues(); + onNext({ ...data, probeMode }); + } + }, [form, probeMode, onNext]); return (
diff --git a/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx b/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx index b16ddca28..963e413a3 100644 --- a/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx +++ b/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx @@ -48,21 +48,11 @@ export default function Step2ProbeOrSnapshot({ const [testingCandidates, setTestingCandidates] = useState< Record >({} as Record); - const [selectedCandidateUris, setSelectedCandidateUris] = useState( - [], - ); const [candidateTests, setCandidateTests] = useState( {} as CandidateTestMap, ); - const handleSelectCandidate = useCallback((uri: string) => { - setSelectedCandidateUris((s) => { - if (s.includes(uri)) { - return s.filter((u) => u !== uri); - } - return [...s, uri]; - }); - }, []); + // selection is handled in Step 3 now; no local selection needed in Step 2 const probeUri = useCallback( async ( @@ -204,6 +194,15 @@ export default function Step2ProbeOrSnapshot({ 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"); } @@ -222,73 +221,39 @@ export default function Step2ProbeOrSnapshot({ } finally { setIsProbing(false); } - }, [wizardData, t]); + }, [wizardData, onUpdate, t]); const testAllSelectedCandidates = useCallback(async () => { - const uris = selectedCandidateUris; + 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; } - 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")); - } 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(""); + // 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], + }); } - }, [selectedCandidateUris, t, onNext, probeUri]); + + // 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) => { @@ -465,9 +430,7 @@ export default function Step2ProbeOrSnapshot({ isError={!!probeError} error={probeError || undefined} probeResult={probeResult} - onSelectCandidate={handleSelectCandidate} onRetry={probeCamera} - selectedUris={selectedCandidateUris} testCandidate={testCandidate} candidateTests={candidateTests} testingCandidates={testingCandidates} @@ -481,8 +444,15 @@ export default function Step2ProbeOrSnapshot({ onBack={onBack} onTestAll={testAllSelectedCandidates} onRetry={probeCamera} - isTesting={isTesting} - selectedCount={selectedCandidateUris.length} + // 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 + } /> ) : ( @@ -543,8 +513,14 @@ export default function Step2ProbeOrSnapshot({ onBack={onBack} onTestAll={testAllSelectedCandidates} onRetry={probeCamera} - isTesting={isTesting} - selectedCount={selectedCandidateUris.length} + 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} @@ -603,7 +579,7 @@ type ProbeFooterProps = { onTestAll: () => void; onRetry: () => void; isTesting: boolean; - selectedCount: number; + candidateCount?: number; mode?: "probe" | "manual"; manualTestSuccess?: boolean; onContinue?: () => void; @@ -617,7 +593,7 @@ function ProbeFooterButtons({ onTestAll, onRetry, isTesting, - selectedCount, + candidateCount = 0, mode = "probe", manualTestSuccess, onContinue, @@ -698,8 +674,14 @@ function ProbeFooterButtons({ variant="select" className="flex items-center justify-center gap-2 sm:flex-1" > - {isTesting && } - {t("cameraWizard.step2.retry")} + {isTesting ? ( + <> + {" "} + {t("button.continue", { ns: "common" })} + + ) : ( + t("cameraWizard.step2.retry") + )} )}
@@ -715,12 +697,12 @@ function ProbeFooterButtons({ ); diff --git a/web/src/components/settings/wizard/Step3StreamConfig.tsx b/web/src/components/settings/wizard/Step3StreamConfig.tsx index 9168d693b..d8b6d9743 100644 --- a/web/src/components/settings/wizard/Step3StreamConfig.tsx +++ b/web/src/components/settings/wizard/Step3StreamConfig.tsx @@ -14,6 +14,7 @@ import { StreamRole, TestResult, FfprobeStream, + CandidateTestMap, } from "@/types/cameraWizard"; import { Label } from "../../ui/label"; import { FaCircleCheck } from "react-icons/fa6"; @@ -25,6 +26,16 @@ import { import { LuInfo, LuExternalLink } from "react-icons/lu"; import { Link } from "react-router-dom"; import { useDocDomain } from "@/hooks/use-doc-domain"; +import { cn } from "@/lib/utils"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; type Step3StreamConfigProps = { wizardData: Partial; @@ -44,19 +55,48 @@ export default function Step3StreamConfig({ const { t } = useTranslation(["views/settings", "components/dialog"]); const { getLocaleDocUrl } = useDocDomain(); const [testingStreams, setTestingStreams] = useState>(new Set()); + const [openCombobox, setOpenCombobox] = useState(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: `stream_${Date.now()}`, - url: "", + id: newStreamId, + url: initialUrl, roles: [], + testResult: initialUrl ? candidateTests[initialUrl] : undefined, + userTested: initialUrl ? !!candidateTests[initialUrl] : false, }; + onUpdate({ streams: [...streams, newStream], }); - }, [streams, onUpdate]); + }, [streams, onUpdate, isProbeMode, probeCandidates, candidateTests]); const removeStream = useCallback( (streamId: string) => { @@ -91,6 +131,19 @@ export default function Step3StreamConfig({ [streams], ); + const getUsedUrlsExcludingStream = useCallback( + (excludeStreamId: string) => { + const used = new Set(); + 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); @@ -164,14 +217,28 @@ export default function Step3StreamConfig({ fps: fps && !isNaN(fps) ? fps : undefined, }; + // Update the stream and also persist the candidate test result updateStream(stream.id, { testResult, userTested: true }); + onUpdate({ + candidateTests: { + ...(wizardData.candidateTests || {}), + [stream.url]: testResult, + } as CandidateTestMap, + }); toast.success(t("cameraWizard.step3.testSuccess")); } else { const error = response.data?.[0]?.stderr || "Unknown error"; + const failResult: TestResult = { success: false, error }; updateStream(stream.id, { - testResult: { success: false, error }, + testResult: failResult, userTested: true, }); + onUpdate({ + candidateTests: { + ...(wizardData.candidateTests || {}), + [stream.url]: failResult, + } as CandidateTestMap, + }); toast.error(t("cameraWizard.commonErrors.testFailed", { error })); } }) @@ -180,10 +247,20 @@ export default function Step3StreamConfig({ error.response?.data?.message || error.response?.data?.detail || "Connection failed"; + const catchResult: TestResult = { + success: false, + error: errorMessage, + }; updateStream(stream.id, { - testResult: { success: false, error: errorMessage }, + testResult: catchResult, userTested: true, }); + onUpdate({ + candidateTests: { + ...(wizardData.candidateTests || {}), + [stream.url]: catchResult, + } as CandidateTestMap, + }); toast.error( t("cameraWizard.commonErrors.testFailed", { error: errorMessage }), ); @@ -196,7 +273,7 @@ export default function Step3StreamConfig({ }); }); }, - [updateStream, t], + [updateStream, t, onUpdate, wizardData.candidateTests], ); const setRestream = useCallback( @@ -277,17 +354,100 @@ export default function Step3StreamConfig({ {t("cameraWizard.step3.url")}
- - updateStream(stream.id, { - url: e.target.value, - testResult: undefined, - }) - } - className="h-8 flex-1" - placeholder={t("cameraWizard.step3.streamUrlPlaceholder")} - /> + {isProbeMode && probeCandidates.length > 0 ? ( + { + setOpenCombobox(isOpen ? stream.id : null); + }} + > + +
+ +
+
+ + + + + + {t("cameraWizard.step3.noStreamFound")} + + + {probeCandidates + .filter((c) => { + const used = getUsedUrlsExcludingStream( + stream.id, + ); + return !used.has(c); + }) + .map((candidate) => ( + { + updateStream(stream.id, { + url: candidate, + testResult: + candidateTests[candidate] || + undefined, + userTested: + !!candidateTests[candidate], + }); + setOpenCombobox(null); + }} + > + + {candidate} + + ))} + + + + +
+ ) : ( + + updateStream(stream.id, { + url: e.target.value, + testResult: undefined, + }) + } + className="h-8 flex-1" + placeholder={t( + "cameraWizard.step3.streamUrlPlaceholder", + )} + /> + )}