import { Button } from "@/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { useTranslation } from "react-i18next"; import { useState, useCallback, useMemo } from "react"; import { LuEye, LuEyeOff } from "react-icons/lu"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import axios from "axios"; import { toast } from "sonner"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { WizardFormData, CameraBrand, CAMERA_BRANDS, CAMERA_BRAND_VALUES, TestResult, FfprobeStream, StreamRole, StreamConfig, } from "@/types/cameraWizard"; import { FaCircleCheck } from "react-icons/fa6"; import { Card, CardContent, CardTitle } from "../../ui/card"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { LuInfo } from "react-icons/lu"; import { detectReolinkCamera } from "@/utils/cameraUtil"; type Step1NameCameraProps = { wizardData: Partial; onUpdate: (data: Partial) => void; onNext: (data?: Partial) => void; onCancel: () => void; canProceed?: boolean; }; export default function Step1NameCamera({ wizardData, onUpdate, onNext, onCancel, }: Step1NameCameraProps) { const { t } = useTranslation(["views/settings"]); const { data: config } = useSWR("config"); const [showPassword, setShowPassword] = useState(false); const [isTesting, setIsTesting] = useState(false); const [testResult, setTestResult] = useState(null); const existingCameraNames = useMemo(() => { if (!config?.cameras) { return []; } return Object.keys(config.cameras); }, [config]); const step1FormData = z .object({ cameraName: z .string() .min(1, t("cameraWizard.step1.errors.nameRequired")) .max(64, t("cameraWizard.step1.errors.nameLength")) .refine( (value) => !existingCameraNames.includes(value), t("cameraWizard.step1.errors.nameExists"), ), host: z.string().optional(), username: z.string().optional(), password: z.string().optional(), brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(), customUrl: z.string().optional(), }) .refine( (data) => { // If brand is "other", customUrl is required if (data.brandTemplate === "other") { return data.customUrl && data.customUrl.trim().length > 0; } // If brand is not "other", host is required return data.host && data.host.trim().length > 0; }, { message: t("cameraWizard.step1.errors.brandOrCustomUrlRequired"), path: ["customUrl"], }, ); const form = useForm>({ resolver: zodResolver(step1FormData), defaultValues: { cameraName: wizardData.cameraName || "", host: wizardData.host || "", username: wizardData.username || "", password: wizardData.password || "", brandTemplate: wizardData.brandTemplate && CAMERA_BRAND_VALUES.includes(wizardData.brandTemplate as CameraBrand) ? (wizardData.brandTemplate as CameraBrand) : "dahua", customUrl: wizardData.customUrl || "", }, mode: "onChange", }); const watchedBrand = form.watch("brandTemplate"); const watchedHost = form.watch("host"); const watchedCustomUrl = form.watch("customUrl"); const isTestButtonEnabled = watchedBrand === "other" ? !!(watchedCustomUrl && watchedCustomUrl.trim()) : !!(watchedHost && watchedHost.trim()); 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 testConnection = useCallback(async () => { const data = form.getValues(); const streamUrl = await generateStreamUrl(data); if (!streamUrl) { toast.error(t("cameraWizard.commonErrors.noUrl")); return; } setIsTesting(true); setTestResult(null); // First get probe data for metadata const probePromise = axios.get("ffprobe", { params: { paths: streamUrl, detailed: true }, timeout: 10000, }); // Then get snapshot for preview const snapshotPromise = axios.get("ffprobe/snapshot", { params: { url: streamUrl }, responseType: "blob", timeout: 10000, }); try { // First get probe data for metadata const probeResponse = await probePromise; let probeData = null; if ( probeResponse.data && probeResponse.data.length > 0 && probeResponse.data[0].return_code === 0 ) { probeData = probeResponse.data[0]; } // Then get snapshot for preview (only if probe succeeded) let snapshotBlob = null; if (probeData) { try { const snapshotResponse = await snapshotPromise; snapshotBlob = snapshotResponse.data; } catch (snapshotError) { // Snapshot is optional, don't fail if it doesn't work toast.warning(t("cameraWizard.step1.warnings.noSnapshot")); } } if (probeData) { const ffprobeData = probeData.stdout; 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; // Extract FPS from rational (e.g., "15/1" -> 15) const fps = videoStream?.avg_frame_rate ? parseFloat(videoStream.avg_frame_rate.split("/")[0]) / parseFloat(videoStream.avg_frame_rate.split("/")[1]) : undefined; // Convert snapshot blob to base64 if available let snapshotBase64 = undefined; if (snapshotBlob) { snapshotBase64 = await new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.readAsDataURL(snapshotBlob); }); } const testResult: TestResult = { success: true, snapshot: snapshotBase64, resolution, videoCodec: videoStream?.codec_name, audioCodec: audioStream?.codec_name, fps: fps && !isNaN(fps) ? fps : undefined, }; setTestResult(testResult); toast.success(t("cameraWizard.step1.testSuccess")); } else { const error = probeData?.stderr || "Unknown error"; setTestResult({ success: false, error: error, }); toast.error(t("cameraWizard.commonErrors.testFailed", { error })); } } 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); } }, [form, generateStreamUrl, t]); const onSubmit = (data: z.infer) => { onUpdate(data); }; const handleContinue = useCallback(async () => { 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]); return (
{!testResult?.success && ( <>
{t("cameraWizard.step1.description")}
( {t("cameraWizard.step1.cameraName")} )} /> ( {t("cameraWizard.step1.cameraBrand")} {field.value && (() => { const selectedBrand = CAMERA_BRANDS.find( (brand) => brand.value === field.value, ); return selectedBrand && selectedBrand.value != "other" ? (
{t("cameraWizard.step1.brandInformation")}

{selectedBrand.label}

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

) : null; })()}
)} /> {watchedBrand !== "other" && ( <> ( {t("cameraWizard.step1.host")} )} /> ( {t("cameraWizard.step1.username")} )} /> ( {t("cameraWizard.step1.password")}
)} /> )} {watchedBrand == "other" && ( ( {t("cameraWizard.step1.customUrl")} )} /> )} )} {testResult?.success && (
{t("cameraWizard.step1.testSuccess")}
{testResult.snapshot ? (
Camera snapshot
) : ( {t("cameraWizard.step1.streamDetails")} )}
)}
{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}
)} ); }