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; onUpdate: (data: Partial) => 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>(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: 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) => { onUpdate({ streams: streams.map((s) => s.id === streamId ? { ...s, ...updates } : s, ), }); }, [streams, onUpdate], ); const getUsedRolesExcludingStream = useCallback( (excludeStreamId: string) => { const roles = new Set(); streams.forEach((stream) => { if (stream.id !== excludeStreamId) { stream.roles.forEach((role) => roles.add(role)); } }); return roles; }, [streams], ); const 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); 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 (
{t("cameraWizard.step3.description")}
{streams.map((stream, index) => (

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

{stream.testResult && stream.testResult.success && (
{[ stream.testResult.resolution, stream.testResult.fps ? `${stream.testResult.fps} ${t("cameraWizard.testResultLabels.fps")}` : null, stream.testResult.videoCodec, stream.testResult.audioCodec, ] .filter(Boolean) .join(" ยท ")}
)}
{stream.testResult?.success && (
{t("cameraWizard.step3.connected")}
)} {stream.testResult && !stream.testResult.success && (
{t("cameraWizard.step3.notConnected")}
)} {streams.length > 1 && ( )}
{isProbeMode && probeCandidates.length > 0 ? ( // Responsive: Popover on desktop, Drawer on mobile !isMobile ? ( { 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} ))}
) : ( 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", )} /> )}
{stream.testResult && !stream.testResult.success && stream.userTested && (
{t("cameraWizard.step3.testFailedTitle")}
{stream.testResult.error}
)}
{t("cameraWizard.step3.rolesPopover.title")}
detect -{" "} {t("cameraWizard.step3.rolesPopover.detect")}
record -{" "} {t("cameraWizard.step3.rolesPopover.record")}
audio -{" "} {t("cameraWizard.step3.rolesPopover.audio")}
{t("readTheDocumentation", { ns: "common" })}
{(["detect", "record", "audio"] as const).map((role) => { const isUsedElsewhere = getUsedRolesExcludingStream( stream.id, ).has(role); const isChecked = stream.roles.includes(role); return (
{role} toggleRole(stream.id, role)} disabled={!isChecked && isUsedElsewhere} />
); })}
{t("cameraWizard.step3.featuresPopover.title")}
{t("cameraWizard.step3.featuresPopover.description")}
{t("readTheDocumentation", { ns: "common" })}
{t("cameraWizard.step3.go2rtc")} setRestream(stream.id)} />
))}
{!hasDetectRole && (
{t("cameraWizard.step3.detectRoleWarning")}
)}
{onBack && ( )} {onNext && ( )}
); }