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 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 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) => { 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 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.step3.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 (
{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 && ( )}
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 && ( )}
); }