2025-10-13 19:52:08 +03:00
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import {
|
|
|
|
|
Form,
|
|
|
|
|
FormControl,
|
2025-11-10 16:32:41 +03:00
|
|
|
FormDescription,
|
2025-10-13 19:52:08 +03:00
|
|
|
FormField,
|
|
|
|
|
FormItem,
|
|
|
|
|
FormLabel,
|
|
|
|
|
FormMessage,
|
|
|
|
|
} from "@/components/ui/form";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from "@/components/ui/select";
|
2025-11-09 21:17:46 +03:00
|
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
2025-10-13 19:52:08 +03:00
|
|
|
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,
|
2025-11-09 21:17:46 +03:00
|
|
|
FfprobeData,
|
|
|
|
|
FfprobeResponse,
|
2025-10-13 19:52:08 +03:00
|
|
|
StreamRole,
|
|
|
|
|
StreamConfig,
|
2025-11-09 21:17:46 +03:00
|
|
|
OnvifProbeResponse,
|
2025-10-13 19:52:08 +03:00
|
|
|
} from "@/types/cameraWizard";
|
2025-11-09 21:17:46 +03:00
|
|
|
import type { CandidateTestMap } from "@/types/cameraWizard";
|
2025-10-13 19:52:08 +03:00
|
|
|
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";
|
2025-10-14 01:47:26 +03:00
|
|
|
import { detectReolinkCamera } from "@/utils/cameraUtil";
|
2025-11-09 21:17:46 +03:00
|
|
|
import ProbeDialog from "./ProbeDialog";
|
2025-10-13 19:52:08 +03:00
|
|
|
|
|
|
|
|
type Step1NameCameraProps = {
|
|
|
|
|
wizardData: Partial<WizardFormData>;
|
|
|
|
|
onUpdate: (data: Partial<WizardFormData>) => void;
|
|
|
|
|
onNext: (data?: Partial<WizardFormData>) => void;
|
|
|
|
|
onCancel: () => void;
|
|
|
|
|
canProceed?: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default function Step1NameCamera({
|
|
|
|
|
wizardData,
|
|
|
|
|
onUpdate,
|
|
|
|
|
onNext,
|
|
|
|
|
onCancel,
|
|
|
|
|
}: Step1NameCameraProps) {
|
|
|
|
|
const { t } = useTranslation(["views/settings"]);
|
|
|
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
|
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
|
|
|
const [isTesting, setIsTesting] = useState(false);
|
2025-10-23 16:34:52 +03:00
|
|
|
const [testStatus, setTestStatus] = useState<string>("");
|
2025-10-13 19:52:08 +03:00
|
|
|
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
2025-11-09 21:17:46 +03:00
|
|
|
const [probeMode, setProbeMode] = useState<boolean>(true);
|
|
|
|
|
const [isProbing, setIsProbing] = useState(false);
|
|
|
|
|
const [probeError, setProbeError] = useState<string | null>(null);
|
|
|
|
|
const [probeResult, setProbeResult] = useState<OnvifProbeResponse | null>(
|
|
|
|
|
null,
|
|
|
|
|
);
|
|
|
|
|
const [probeDialogOpen, setProbeDialogOpen] = useState(false);
|
|
|
|
|
const [selectedCandidateUris, setSelectedCandidateUris] = useState<string[]>(
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
const [candidateTests, setCandidateTests] = useState<CandidateTestMap>(
|
|
|
|
|
{} as CandidateTestMap,
|
|
|
|
|
);
|
|
|
|
|
const [testingCandidates, setTestingCandidates] = useState<
|
|
|
|
|
Record<string, boolean>
|
|
|
|
|
>({} as Record<string, boolean>);
|
2025-10-13 19:52:08 +03:00
|
|
|
|
|
|
|
|
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(),
|
2025-11-10 16:32:41 +03:00
|
|
|
onvifPort: z.coerce.number().int().min(1).max(65535).optional(),
|
2025-10-23 16:34:52 +03:00
|
|
|
customUrl: z
|
|
|
|
|
.string()
|
|
|
|
|
.optional()
|
|
|
|
|
.refine(
|
|
|
|
|
(val) => !val || val.startsWith("rtsp://"),
|
|
|
|
|
t("cameraWizard.step1.errors.customUrlRtspRequired"),
|
|
|
|
|
),
|
2025-10-13 19:52:08 +03:00
|
|
|
})
|
|
|
|
|
.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<z.infer<typeof step1FormData>>({
|
|
|
|
|
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 || "",
|
2025-11-10 16:32:41 +03:00
|
|
|
onvifPort: wizardData.onvifPort ?? 80,
|
2025-10-13 19:52:08 +03:00
|
|
|
},
|
|
|
|
|
mode: "onChange",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const watchedBrand = form.watch("brandTemplate");
|
|
|
|
|
const watchedHost = form.watch("host");
|
|
|
|
|
const watchedCustomUrl = form.watch("customUrl");
|
|
|
|
|
|
2025-11-09 21:17:46 +03:00
|
|
|
const hostPresent = !!(watchedHost && watchedHost.trim());
|
|
|
|
|
const customPresent = !!(watchedCustomUrl && watchedCustomUrl.trim());
|
|
|
|
|
const cameraNamePresent = !!(form.getValues().cameraName || "").trim();
|
|
|
|
|
|
2025-10-13 19:52:08 +03:00
|
|
|
const isTestButtonEnabled =
|
2025-11-09 21:17:46 +03:00
|
|
|
cameraNamePresent &&
|
|
|
|
|
(probeMode
|
|
|
|
|
? hostPresent
|
|
|
|
|
: watchedBrand === "other"
|
|
|
|
|
? customPresent
|
|
|
|
|
: hostPresent);
|
2025-10-13 19:52:08 +03:00
|
|
|
|
2025-10-14 01:47:26 +03:00
|
|
|
const generateDynamicStreamUrl = useCallback(
|
|
|
|
|
async (data: z.infer<typeof step1FormData>): Promise<string | null> => {
|
|
|
|
|
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<string, string> = 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;
|
|
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
2025-10-13 19:52:08 +03:00
|
|
|
const generateStreamUrl = useCallback(
|
2025-10-14 01:47:26 +03:00
|
|
|
async (data: z.infer<typeof step1FormData>): Promise<string> => {
|
2025-10-13 19:52:08 +03:00
|
|
|
if (data.brandTemplate === "other") {
|
|
|
|
|
return data.customUrl || "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
|
|
|
|
if (!brand || !data.host) return "";
|
|
|
|
|
|
2025-10-14 01:47:26 +03:00
|
|
|
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
|
|
|
|
|
const dynamicUrl = await generateDynamicStreamUrl(data);
|
|
|
|
|
|
|
|
|
|
if (dynamicUrl) {
|
|
|
|
|
return dynamicUrl;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-13 19:52:08 +03:00
|
|
|
return brand.template
|
|
|
|
|
.replace("{username}", data.username || "")
|
|
|
|
|
.replace("{password}", data.password || "")
|
|
|
|
|
.replace("{host}", data.host);
|
|
|
|
|
},
|
2025-10-14 01:47:26 +03:00
|
|
|
[generateDynamicStreamUrl],
|
2025-10-13 19:52:08 +03:00
|
|
|
);
|
|
|
|
|
|
2025-11-09 21:17:46 +03:00
|
|
|
const probeCamera = useCallback(async () => {
|
2025-10-13 19:52:08 +03:00
|
|
|
const data = form.getValues();
|
|
|
|
|
|
2025-11-09 21:17:46 +03:00
|
|
|
if (!data.host) {
|
|
|
|
|
toast.error(t("cameraWizard.step1.errors.hostRequired"));
|
2025-10-13 19:52:08 +03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-09 21:17:46 +03:00
|
|
|
setIsProbing(true);
|
|
|
|
|
setProbeError(null);
|
|
|
|
|
setProbeResult(null);
|
2025-10-13 19:52:08 +03:00
|
|
|
|
|
|
|
|
try {
|
2025-11-09 21:17:46 +03:00
|
|
|
const response = await axios.get("/onvif/probe", {
|
|
|
|
|
params: {
|
|
|
|
|
host: data.host,
|
2025-11-10 16:32:41 +03:00
|
|
|
port: data.onvifPort ?? 80,
|
2025-11-09 21:17:46 +03:00
|
|
|
username: data.username || "",
|
|
|
|
|
password: data.password || "",
|
|
|
|
|
test: false,
|
|
|
|
|
},
|
|
|
|
|
timeout: 30000,
|
2025-10-23 16:34:52 +03:00
|
|
|
});
|
|
|
|
|
|
2025-11-09 21:17:46 +03:00
|
|
|
if (response.data && response.data.success) {
|
|
|
|
|
setProbeResult(response.data);
|
|
|
|
|
// open the probe dialog to show results
|
|
|
|
|
setProbeDialogOpen(true);
|
|
|
|
|
} 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.step1.probeFailed", { error: errorMessage }));
|
|
|
|
|
} finally {
|
|
|
|
|
setIsProbing(false);
|
|
|
|
|
}
|
2025-11-10 16:32:41 +03:00
|
|
|
}, [form, t]);
|
2025-11-09 21:17:46 +03:00
|
|
|
|
|
|
|
|
const handleSelectCandidate = useCallback((uri: string) => {
|
|
|
|
|
// toggle selection: add or remove from selectedCandidateUris
|
|
|
|
|
setSelectedCandidateUris((s) => {
|
|
|
|
|
if (s.includes(uri)) {
|
|
|
|
|
return s.filter((u) => u !== uri);
|
2025-10-13 19:52:08 +03:00
|
|
|
}
|
2025-11-09 21:17:46 +03:00
|
|
|
return [...s, uri];
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Probe a single URI and return a TestResult. If fetchSnapshot is true,
|
|
|
|
|
// also attempt to fetch a snapshot (may be undefined on failure).
|
|
|
|
|
const probeUri = useCallback(
|
|
|
|
|
async (
|
|
|
|
|
uri: string,
|
|
|
|
|
fetchSnapshot = false,
|
|
|
|
|
setStatus?: (s: string) => void,
|
|
|
|
|
): Promise<TestResult> => {
|
|
|
|
|
try {
|
|
|
|
|
const probeResponse = await axios.get("ffprobe", {
|
|
|
|
|
params: { paths: uri, detailed: true },
|
|
|
|
|
timeout: 10000,
|
|
|
|
|
});
|
2025-10-13 19:52:08 +03:00
|
|
|
|
2025-11-09 21:17:46 +03:00
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// stdout may be a string or structured object. Normalize to FfprobeData.
|
|
|
|
|
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;
|
2025-10-13 19:52:08 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const streams = ffprobeData.streams || [];
|
|
|
|
|
|
|
|
|
|
const videoStream = streams.find(
|
|
|
|
|
(s: FfprobeStream) =>
|
|
|
|
|
s.codec_type === "video" ||
|
|
|
|
|
s.codec_name?.includes("h264") ||
|
2025-10-14 01:47:26 +03:00
|
|
|
s.codec_name?.includes("hevc"),
|
2025-10-13 19:52:08 +03:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const audioStream = streams.find(
|
|
|
|
|
(s: FfprobeStream) =>
|
|
|
|
|
s.codec_type === "audio" ||
|
|
|
|
|
s.codec_name?.includes("aac") ||
|
2025-10-14 01:47:26 +03:00
|
|
|
s.codec_name?.includes("mp3") ||
|
|
|
|
|
s.codec_name?.includes("pcm_mulaw") ||
|
|
|
|
|
s.codec_name?.includes("pcm_alaw"),
|
2025-10-13 19:52:08 +03:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const resolution = videoStream
|
|
|
|
|
? `${videoStream.width}x${videoStream.height}`
|
|
|
|
|
: undefined;
|
|
|
|
|
|
2025-10-14 01:47:26 +03:00
|
|
|
const fps = videoStream?.avg_frame_rate
|
|
|
|
|
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
|
|
|
|
|
parseFloat(videoStream.avg_frame_rate.split("/")[1])
|
2025-10-13 19:52:08 +03:00
|
|
|
: undefined;
|
|
|
|
|
|
2025-11-09 21:17:46 +03:00
|
|
|
let snapshotBase64: string | undefined = undefined;
|
|
|
|
|
if (fetchSnapshot) {
|
|
|
|
|
if (setStatus) {
|
|
|
|
|
setStatus(t("cameraWizard.step1.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<string>((resolve) => {
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
reader.onload = () => resolve(reader.result as string);
|
|
|
|
|
reader.readAsDataURL(snapshotBlob);
|
|
|
|
|
});
|
|
|
|
|
} catch (snapshotError) {
|
|
|
|
|
snapshotBase64 = undefined;
|
|
|
|
|
}
|
2025-10-13 19:52:08 +03:00
|
|
|
}
|
|
|
|
|
|
2025-11-09 21:17:46 +03:00
|
|
|
const streamTestResult: TestResult = {
|
2025-10-13 19:52:08 +03:00
|
|
|
success: true,
|
|
|
|
|
snapshot: snapshotBase64,
|
|
|
|
|
resolution,
|
|
|
|
|
videoCodec: videoStream?.codec_name,
|
|
|
|
|
audioCodec: audioStream?.codec_name,
|
|
|
|
|
fps: fps && !isNaN(fps) ? fps : undefined,
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-09 21:17:46 +03:00
|
|
|
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 testAllSelectedCandidates = useCallback(async () => {
|
|
|
|
|
const uris = selectedCandidateUris;
|
|
|
|
|
if (!uris || uris.length === 0) {
|
|
|
|
|
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsTesting(true);
|
|
|
|
|
setTestStatus(t("cameraWizard.step1.testing.probingMetadata"));
|
|
|
|
|
|
|
|
|
|
const streamConfigs: StreamConfig[] = [];
|
|
|
|
|
let firstSuccessfulTestResult: TestResult | null = null;
|
|
|
|
|
let firstSuccessfulUri: string | undefined = undefined;
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
// Only the first stream should have the detect role
|
|
|
|
|
roles:
|
|
|
|
|
streamConfigs.length === 0
|
|
|
|
|
? (["detect"] as StreamRole[])
|
|
|
|
|
: ([] as StreamRole[]),
|
|
|
|
|
testResult: streamTestResult,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// keep first successful for main pane snapshot
|
|
|
|
|
if (!firstSuccessfulTestResult) {
|
|
|
|
|
firstSuccessfulTestResult = streamTestResult;
|
|
|
|
|
firstSuccessfulUri = uri;
|
|
|
|
|
}
|
|
|
|
|
// also store candidate test summary
|
|
|
|
|
setCandidateTests((s) => ({ ...s, [uri]: streamTestResult }));
|
|
|
|
|
} else {
|
|
|
|
|
setCandidateTests((s) => ({
|
|
|
|
|
...s,
|
|
|
|
|
[uri]: streamTestResult,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (streamConfigs.length > 0) {
|
|
|
|
|
// Add all successful streams and navigate to Step 2
|
2025-11-10 18:10:17 +03:00
|
|
|
const values = form.getValues();
|
|
|
|
|
const cameraName = values.cameraName;
|
|
|
|
|
onNext({
|
|
|
|
|
cameraName,
|
|
|
|
|
streams: streamConfigs,
|
|
|
|
|
customUrl: firstSuccessfulUri,
|
|
|
|
|
});
|
2025-11-09 21:17:46 +03:00
|
|
|
toast.success(t("cameraWizard.step1.testSuccess"));
|
|
|
|
|
setProbeDialogOpen(false);
|
|
|
|
|
setProbeResult(null);
|
|
|
|
|
} 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";
|
|
|
|
|
setTestResult({
|
|
|
|
|
success: false,
|
|
|
|
|
error: errorMessage,
|
|
|
|
|
});
|
|
|
|
|
toast.error(
|
|
|
|
|
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
setIsTesting(false);
|
|
|
|
|
setTestStatus("");
|
|
|
|
|
}
|
2025-11-10 18:10:17 +03:00
|
|
|
}, [selectedCandidateUris, t, onNext, probeUri, form]);
|
2025-11-09 21:17:46 +03:00
|
|
|
|
|
|
|
|
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 testConnection = useCallback(async () => {
|
|
|
|
|
const _data = form.getValues();
|
|
|
|
|
const streamUrl = await generateStreamUrl(_data);
|
|
|
|
|
|
|
|
|
|
if (!streamUrl) {
|
|
|
|
|
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsTesting(true);
|
|
|
|
|
setTestStatus("");
|
|
|
|
|
setTestResult(null);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
setTestStatus(t("cameraWizard.step1.testing.probingMetadata"));
|
|
|
|
|
const result = await probeUri(streamUrl, true, setTestStatus);
|
|
|
|
|
|
|
|
|
|
if (result && result.success) {
|
|
|
|
|
setTestResult(result);
|
|
|
|
|
onUpdate({
|
|
|
|
|
streams: [{ id: "", url: streamUrl, roles: [], testResult: result }],
|
|
|
|
|
});
|
2025-10-13 19:52:08 +03:00
|
|
|
toast.success(t("cameraWizard.step1.testSuccess"));
|
|
|
|
|
} else {
|
2025-11-09 21:17:46 +03:00
|
|
|
const errMsg = result?.error || "Unable to probe stream";
|
2025-10-13 19:52:08 +03:00
|
|
|
setTestResult({
|
|
|
|
|
success: false,
|
2025-11-09 21:17:46 +03:00
|
|
|
error: errMsg,
|
2025-10-23 16:34:52 +03:00
|
|
|
});
|
2025-11-09 21:17:46 +03:00
|
|
|
toast.error(
|
|
|
|
|
t("cameraWizard.commonErrors.testFailed", { error: errMsg }),
|
|
|
|
|
{
|
|
|
|
|
duration: 6000,
|
|
|
|
|
},
|
|
|
|
|
);
|
2025-10-13 19:52:08 +03:00
|
|
|
}
|
|
|
|
|
} 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 }),
|
2025-10-23 16:34:52 +03:00
|
|
|
{
|
|
|
|
|
duration: 10000,
|
|
|
|
|
},
|
2025-10-13 19:52:08 +03:00
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
setIsTesting(false);
|
2025-10-23 16:34:52 +03:00
|
|
|
setTestStatus("");
|
2025-10-13 19:52:08 +03:00
|
|
|
}
|
2025-11-09 21:17:46 +03:00
|
|
|
}, [form, generateStreamUrl, t, onUpdate, probeUri]);
|
2025-10-13 19:52:08 +03:00
|
|
|
|
|
|
|
|
const onSubmit = (data: z.infer<typeof step1FormData>) => {
|
|
|
|
|
onUpdate(data);
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-14 01:47:26 +03:00
|
|
|
const handleContinue = useCallback(async () => {
|
2025-10-13 19:52:08 +03:00
|
|
|
const data = form.getValues();
|
2025-10-14 01:47:26 +03:00
|
|
|
const streamUrl = await generateStreamUrl(data);
|
2025-10-13 19:52:08 +03:00
|
|
|
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 (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{!testResult?.success && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="text-sm text-muted-foreground">
|
|
|
|
|
{t("cameraWizard.step1.description")}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Form {...form}>
|
|
|
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
|
|
|
<FormField
|
|
|
|
|
control={form.control}
|
|
|
|
|
name="cameraName"
|
|
|
|
|
render={({ field }) => (
|
|
|
|
|
<FormItem>
|
2025-10-23 22:58:22 +03:00
|
|
|
<FormLabel className="text-primary-variant">
|
|
|
|
|
{t("cameraWizard.step1.cameraName")}
|
|
|
|
|
</FormLabel>
|
2025-10-13 19:52:08 +03:00
|
|
|
<FormControl>
|
|
|
|
|
<Input
|
2025-11-05 17:49:31 +03:00
|
|
|
className="text-md h-8"
|
2025-10-13 19:52:08 +03:00
|
|
|
placeholder={t(
|
|
|
|
|
"cameraWizard.step1.cameraNamePlaceholder",
|
|
|
|
|
)}
|
|
|
|
|
{...field}
|
|
|
|
|
/>
|
|
|
|
|
</FormControl>
|
|
|
|
|
<FormMessage />
|
|
|
|
|
</FormItem>
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
|
2025-11-09 21:17:46 +03:00
|
|
|
<div className="space-y-4">
|
|
|
|
|
<FormField
|
|
|
|
|
control={form.control}
|
|
|
|
|
name="host"
|
|
|
|
|
render={({ field }) => (
|
|
|
|
|
<FormItem>
|
2025-10-23 22:58:22 +03:00
|
|
|
<FormLabel className="text-primary-variant">
|
2025-11-09 21:17:46 +03:00
|
|
|
{t("cameraWizard.step1.host")}
|
2025-10-23 22:58:22 +03:00
|
|
|
</FormLabel>
|
2025-10-13 19:52:08 +03:00
|
|
|
<FormControl>
|
2025-11-09 21:17:46 +03:00
|
|
|
<Input
|
|
|
|
|
className="text-md h-8"
|
|
|
|
|
placeholder="192.168.1.100"
|
|
|
|
|
{...field}
|
|
|
|
|
/>
|
2025-10-13 19:52:08 +03:00
|
|
|
</FormControl>
|
2025-11-09 21:17:46 +03:00
|
|
|
<FormMessage />
|
|
|
|
|
</FormItem>
|
|
|
|
|
)}
|
|
|
|
|
/>
|
2025-10-13 19:52:08 +03:00
|
|
|
|
2025-11-09 21:17:46 +03:00
|
|
|
<FormField
|
|
|
|
|
control={form.control}
|
|
|
|
|
name="username"
|
|
|
|
|
render={({ field }) => (
|
|
|
|
|
<FormItem>
|
|
|
|
|
<FormLabel className="text-primary-variant">
|
|
|
|
|
{t("cameraWizard.step1.username")}
|
|
|
|
|
</FormLabel>
|
|
|
|
|
<FormControl>
|
|
|
|
|
<Input
|
|
|
|
|
className="text-md h-8"
|
|
|
|
|
placeholder={t(
|
|
|
|
|
"cameraWizard.step1.usernamePlaceholder",
|
|
|
|
|
)}
|
|
|
|
|
{...field}
|
|
|
|
|
/>
|
|
|
|
|
</FormControl>
|
|
|
|
|
<FormMessage />
|
|
|
|
|
</FormItem>
|
|
|
|
|
)}
|
|
|
|
|
/>
|
2025-10-13 19:52:08 +03:00
|
|
|
|
2025-11-09 21:17:46 +03:00
|
|
|
<FormField
|
|
|
|
|
control={form.control}
|
|
|
|
|
name="password"
|
|
|
|
|
render={({ field }) => (
|
|
|
|
|
<FormItem>
|
|
|
|
|
<FormLabel className="text-primary-variant">
|
|
|
|
|
{t("cameraWizard.step1.password")}
|
|
|
|
|
</FormLabel>
|
|
|
|
|
<FormControl>
|
|
|
|
|
<div className="relative">
|
2025-10-13 19:52:08 +03:00
|
|
|
<Input
|
2025-11-09 21:17:46 +03:00
|
|
|
className="text-md h-8 pr-10"
|
|
|
|
|
type={showPassword ? "text" : "password"}
|
2025-10-13 19:52:08 +03:00
|
|
|
placeholder={t(
|
2025-11-09 21:17:46 +03:00
|
|
|
"cameraWizard.step1.passwordPlaceholder",
|
2025-10-13 19:52:08 +03:00
|
|
|
)}
|
|
|
|
|
{...field}
|
|
|
|
|
/>
|
2025-11-09 21:17:46 +03:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
|
|
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
|
|
|
>
|
|
|
|
|
{showPassword ? (
|
|
|
|
|
<LuEyeOff className="size-4" />
|
|
|
|
|
) : (
|
|
|
|
|
<LuEye className="size-4" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</FormControl>
|
|
|
|
|
<FormMessage />
|
|
|
|
|
</FormItem>
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2025-10-13 19:52:08 +03:00
|
|
|
|
2025-11-09 21:17:46 +03:00
|
|
|
<div className="space-y-3 pt-4">
|
|
|
|
|
<FormLabel className="text-primary-variant">
|
|
|
|
|
{t("cameraWizard.step1.detectionMethod")}
|
|
|
|
|
</FormLabel>
|
|
|
|
|
<RadioGroup
|
|
|
|
|
value={probeMode ? "probe" : "manual"}
|
|
|
|
|
onValueChange={(value) => {
|
|
|
|
|
setProbeMode(value === "probe");
|
|
|
|
|
setProbeResult(null);
|
|
|
|
|
setProbeError(null);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<RadioGroupItem value="probe" id="probe-mode" />
|
|
|
|
|
<label
|
|
|
|
|
htmlFor="probe-mode"
|
|
|
|
|
className="cursor-pointer text-sm"
|
|
|
|
|
>
|
|
|
|
|
{t("cameraWizard.step1.probeMode")}
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<RadioGroupItem value="manual" id="manual-mode" />
|
|
|
|
|
<label
|
|
|
|
|
htmlFor="manual-mode"
|
|
|
|
|
className="cursor-pointer text-sm"
|
|
|
|
|
>
|
|
|
|
|
{t("cameraWizard.step1.manualMode")}
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</RadioGroup>
|
2025-11-10 16:32:41 +03:00
|
|
|
<FormDescription>
|
|
|
|
|
{t("cameraWizard.step1.detectionMethodDescription")}
|
|
|
|
|
</FormDescription>
|
2025-11-09 21:17:46 +03:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{probeMode && (
|
2025-11-10 16:32:41 +03:00
|
|
|
<FormField
|
|
|
|
|
control={form.control}
|
|
|
|
|
name="onvifPort"
|
|
|
|
|
render={({ field, fieldState }) => (
|
|
|
|
|
<FormItem>
|
|
|
|
|
<FormLabel className="text-primary-variant">
|
|
|
|
|
{t("cameraWizard.step1.onvifPort")}
|
|
|
|
|
</FormLabel>
|
|
|
|
|
<FormControl>
|
|
|
|
|
<Input
|
|
|
|
|
className="text-md h-8"
|
|
|
|
|
type="text"
|
|
|
|
|
{...field}
|
|
|
|
|
placeholder="80"
|
|
|
|
|
/>
|
|
|
|
|
</FormControl>
|
2025-11-10 17:03:22 +03:00
|
|
|
<FormDescription>
|
|
|
|
|
{t("cameraWizard.step1.onvifPortDescription")}
|
|
|
|
|
</FormDescription>
|
2025-11-10 16:32:41 +03:00
|
|
|
<FormMessage>
|
|
|
|
|
{fieldState.error ? fieldState.error.message : null}
|
|
|
|
|
</FormMessage>
|
|
|
|
|
</FormItem>
|
|
|
|
|
)}
|
|
|
|
|
/>
|
2025-11-09 21:17:46 +03:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{!probeMode && (
|
|
|
|
|
<div className="space-y-4">
|
2025-10-13 19:52:08 +03:00
|
|
|
<FormField
|
|
|
|
|
control={form.control}
|
2025-11-09 21:17:46 +03:00
|
|
|
name="brandTemplate"
|
2025-10-13 19:52:08 +03:00
|
|
|
render={({ field }) => (
|
|
|
|
|
<FormItem>
|
2025-11-09 21:17:46 +03:00
|
|
|
<div className="flex items-center gap-1 pb-1">
|
|
|
|
|
<FormLabel className="text-primary-variant">
|
|
|
|
|
{t("cameraWizard.step1.cameraBrand")}
|
|
|
|
|
</FormLabel>
|
|
|
|
|
{field.value &&
|
|
|
|
|
(() => {
|
|
|
|
|
const selectedBrand = CAMERA_BRANDS.find(
|
|
|
|
|
(brand) => brand.value === field.value,
|
|
|
|
|
);
|
|
|
|
|
return selectedBrand &&
|
|
|
|
|
selectedBrand.value != "other" ? (
|
|
|
|
|
<Popover>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-4 w-4 p-0"
|
|
|
|
|
>
|
|
|
|
|
<LuInfo className="size-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="pointer-events-auto w-80 text-primary-variant">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<h4 className="font-medium">
|
|
|
|
|
{selectedBrand.label}
|
|
|
|
|
</h4>
|
|
|
|
|
<p className="break-all text-sm text-muted-foreground">
|
|
|
|
|
{t(
|
|
|
|
|
"cameraWizard.step1.brandUrlFormat",
|
|
|
|
|
{
|
|
|
|
|
exampleUrl:
|
|
|
|
|
selectedBrand.exampleUrl,
|
|
|
|
|
},
|
|
|
|
|
)}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
) : null;
|
|
|
|
|
})()}
|
|
|
|
|
</div>
|
|
|
|
|
<Select
|
|
|
|
|
onValueChange={field.onChange}
|
|
|
|
|
defaultValue={field.value}
|
|
|
|
|
>
|
|
|
|
|
<FormControl>
|
|
|
|
|
<SelectTrigger className="h-8">
|
|
|
|
|
<SelectValue
|
|
|
|
|
placeholder={t(
|
|
|
|
|
"cameraWizard.step1.selectBrand",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
</FormControl>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{CAMERA_BRANDS.map((brand) => (
|
|
|
|
|
<SelectItem key={brand.value} value={brand.value}>
|
|
|
|
|
{brand.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
2025-10-13 19:52:08 +03:00
|
|
|
<FormMessage />
|
|
|
|
|
</FormItem>
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
|
2025-11-09 21:17:46 +03:00
|
|
|
{watchedBrand == "other" && (
|
|
|
|
|
<FormField
|
|
|
|
|
control={form.control}
|
|
|
|
|
name="customUrl"
|
|
|
|
|
render={({ field }) => (
|
|
|
|
|
<FormItem>
|
|
|
|
|
<FormLabel className="text-primary-variant">
|
|
|
|
|
{t("cameraWizard.step1.customUrl")}
|
|
|
|
|
</FormLabel>
|
|
|
|
|
<FormControl>
|
|
|
|
|
<Input
|
|
|
|
|
className="text-md h-8"
|
|
|
|
|
placeholder="rtsp://username:password@host:port/path"
|
|
|
|
|
{...field}
|
|
|
|
|
/>
|
|
|
|
|
</FormControl>
|
|
|
|
|
<FormMessage />
|
|
|
|
|
</FormItem>
|
|
|
|
|
)}
|
|
|
|
|
/>
|
2025-10-13 19:52:08 +03:00
|
|
|
)}
|
2025-11-09 21:17:46 +03:00
|
|
|
</div>
|
2025-10-13 19:52:08 +03:00
|
|
|
)}
|
|
|
|
|
</form>
|
|
|
|
|
</Form>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-11-09 21:17:46 +03:00
|
|
|
{probeMode && probeResult && (
|
|
|
|
|
<div className="p-4">
|
|
|
|
|
<ProbeDialog
|
|
|
|
|
open={probeDialogOpen}
|
|
|
|
|
onOpenChange={setProbeDialogOpen}
|
|
|
|
|
isLoading={isProbing}
|
|
|
|
|
isError={!!probeError}
|
|
|
|
|
error={probeError || undefined}
|
|
|
|
|
probeResult={probeResult}
|
|
|
|
|
onSelectCandidate={handleSelectCandidate}
|
|
|
|
|
onRetry={probeCamera}
|
|
|
|
|
selectedCandidateUris={selectedCandidateUris}
|
|
|
|
|
testAllSelectedCandidates={testAllSelectedCandidates}
|
|
|
|
|
isTesting={isTesting}
|
|
|
|
|
testStatus={testStatus}
|
|
|
|
|
testCandidate={testCandidate}
|
|
|
|
|
candidateTests={candidateTests}
|
|
|
|
|
testingCandidates={testingCandidates}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-10-13 19:52:08 +03:00
|
|
|
{testResult?.success && (
|
|
|
|
|
<div className="p-4">
|
|
|
|
|
<div className="mb-3 flex flex-row items-center gap-2 text-sm font-medium text-success">
|
|
|
|
|
<FaCircleCheck className="size-4" />
|
|
|
|
|
{t("cameraWizard.step1.testSuccess")}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{testResult.snapshot ? (
|
|
|
|
|
<div className="relative flex justify-center">
|
|
|
|
|
<img
|
|
|
|
|
src={testResult.snapshot}
|
|
|
|
|
alt="Camera snapshot"
|
|
|
|
|
className="max-h-[50dvh] max-w-full rounded-lg object-contain"
|
|
|
|
|
/>
|
|
|
|
|
<div className="absolute bottom-2 right-2 rounded-md bg-black/70 p-3 text-sm backdrop-blur-sm">
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<StreamDetails testResult={testResult} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<Card className="p-4">
|
|
|
|
|
<CardTitle className="mb-2 text-sm">
|
|
|
|
|
{t("cameraWizard.step1.streamDetails")}
|
|
|
|
|
</CardTitle>
|
|
|
|
|
<CardContent className="p-0 text-sm">
|
|
|
|
|
<StreamDetails testResult={testResult} />
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-11-03 18:42:38 +03:00
|
|
|
{isTesting && (
|
|
|
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
|
|
|
<ActivityIndicator className="size-4" />
|
|
|
|
|
{testStatus}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-10-13 19:52:08 +03:00
|
|
|
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={testResult?.success ? () => setTestResult(null) : onCancel}
|
|
|
|
|
className="sm:flex-1"
|
|
|
|
|
>
|
|
|
|
|
{testResult?.success
|
|
|
|
|
? t("button.back", { ns: "common" })
|
|
|
|
|
: t("button.cancel", { ns: "common" })}
|
|
|
|
|
</Button>
|
|
|
|
|
{testResult?.success ? (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={handleContinue}
|
|
|
|
|
variant="select"
|
|
|
|
|
className="flex items-center justify-center gap-2 sm:flex-1"
|
|
|
|
|
>
|
|
|
|
|
{t("button.continue", { ns: "common" })}
|
|
|
|
|
</Button>
|
|
|
|
|
) : (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
2025-11-09 21:17:46 +03:00
|
|
|
onClick={probeMode ? probeCamera : testConnection}
|
|
|
|
|
disabled={
|
|
|
|
|
(probeMode ? isProbing : isTesting) || !isTestButtonEnabled
|
|
|
|
|
}
|
2025-10-13 19:52:08 +03:00
|
|
|
variant="select"
|
|
|
|
|
className="flex items-center justify-center gap-2 sm:flex-1"
|
|
|
|
|
>
|
2025-11-10 16:32:41 +03:00
|
|
|
{(isProbing || isTesting) && (
|
|
|
|
|
<ActivityIndicator className="size-4" />
|
|
|
|
|
)}
|
2025-11-09 21:17:46 +03:00
|
|
|
{probeMode
|
|
|
|
|
? t("cameraWizard.step1.probeMode")
|
|
|
|
|
: t("cameraWizard.step1.testConnection")}
|
2025-10-13 19:52:08 +03:00
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function StreamDetails({ testResult }: { testResult: TestResult }) {
|
|
|
|
|
const { t } = useTranslation(["views/settings"]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{testResult.resolution && (
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-white/70">
|
|
|
|
|
{t("cameraWizard.testResultLabels.resolution")}:
|
|
|
|
|
</span>{" "}
|
|
|
|
|
<span className="text-white">{testResult.resolution}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{testResult.fps && (
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-white/70">
|
|
|
|
|
{t("cameraWizard.testResultLabels.fps")}:
|
|
|
|
|
</span>{" "}
|
|
|
|
|
<span className="text-white">{testResult.fps}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{testResult.videoCodec && (
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-white/70">
|
|
|
|
|
{t("cameraWizard.testResultLabels.video")}:
|
|
|
|
|
</span>{" "}
|
|
|
|
|
<span className="text-white">{testResult.videoCodec}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{testResult.audioCodec && (
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-white/70">
|
|
|
|
|
{t("cameraWizard.testResultLabels.audio")}:
|
|
|
|
|
</span>{" "}
|
|
|
|
|
<span className="text-white">{testResult.audioCodec}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|