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; 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 [testingCandidates, setTestingCandidates] = useState< Record >({} as Record); const [candidateTests, setCandidateTests] = useState( {} as CandidateTestMap, ); 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"), ); 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((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): 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 results directly <> {probeResult && (
)} v) } candidateCount={ (probeResult?.rtsp_candidates || []).filter( (c) => c.source === "GetStreamUri", ).length } /> ) : ( // 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}
)} v) } candidateCount={ (probeResult?.rtsp_candidates || []).filter( (c) => c.source === "GetStreamUri", ).length } manualTestSuccess={!!testResult?.success} onContinue={handleContinue} onManualTest={testConnection} /> )}
); } 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}
)} ); } 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 (
{t("cameraWizard.step2.probing")}
); } // Error footer if (probeError) { return (
{probeError}
); } // 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 (
{manualTestSuccess ? ( ) : ( )}
); } // Default probe footer return (
); }