mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-11 01:02:48 +03:00
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* try http-flv first for reolink cameras above 5MP in the camera wizard The wizard picked RTSP for every Reolink above 5MP and then warned that RTSP isn't recommended, telling users to enable HTTP and restart, which just produced the same result. It now tries the http-flv stream first for those cameras and falls back to RTSP when the stream doesn't probe, since only the newer models serve H.265 over enhanced FLV. The RTSP warning is skipped when the camera's resolution made RTSP a valid choice. http-flv no longer needs go2rtc's ffmpeg module, so the compatibility mode warning for Reolink HTTP streams is removed and the docs drop the `ffmpeg:` prefix from http-flv sources that have no transcoding modifiers. * add reolink tests * keep using ffmpeg module
760 lines
23 KiB
TypeScript
760 lines
23 KiB
TypeScript
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<WizardFormData>;
|
|
onUpdate: (data: Partial<WizardFormData>) => void;
|
|
onNext: (data?: Partial<WizardFormData>) => 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<string>("");
|
|
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
|
const [isProbing, setIsProbing] = useState(false);
|
|
const [probeError, setProbeError] = useState<string | null>(null);
|
|
const [probeResult, setProbeResult] = useState<OnvifProbeResponse | null>(
|
|
null,
|
|
);
|
|
const [testingCandidates, setTestingCandidates] = useState<
|
|
Record<string, boolean>
|
|
>({} as Record<string, boolean>);
|
|
const [candidateTests, setCandidateTests] = useState<CandidateTestMap>(
|
|
{} as CandidateTestMap,
|
|
);
|
|
|
|
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,
|
|
});
|
|
|
|
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<string>((resolve) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result as string);
|
|
reader.readAsDataURL(snapshotBlob);
|
|
});
|
|
} catch {
|
|
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,
|
|
auth_type: wizardData.useDigestAuth ? "digest" : "basic",
|
|
},
|
|
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);
|
|
|
|
const update: Partial<WizardFormData> = {
|
|
probeMode: true,
|
|
probeResult: response.data,
|
|
probeCandidates: candidateUris,
|
|
candidateTests: {},
|
|
};
|
|
|
|
const manufacturer: string = response.data.manufacturer || "";
|
|
if (
|
|
manufacturer.toLowerCase().includes("reolink") &&
|
|
wizardData.host &&
|
|
wizardData.username &&
|
|
wizardData.password
|
|
) {
|
|
const protocol = await detectReolinkCamera(
|
|
wizardData.host,
|
|
wizardData.username,
|
|
wizardData.password,
|
|
);
|
|
update.reolinkProtocol = protocol;
|
|
if (protocol === "http-flv") {
|
|
update.brandTemplate = "reolink";
|
|
}
|
|
}
|
|
|
|
onUpdate(update);
|
|
} 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 generateDynamicStreamUrls = useCallback(
|
|
async (data: Partial<WizardFormData>): Promise<string[]> => {
|
|
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
|
const host = data.host;
|
|
if (!brand || !host) return [];
|
|
|
|
let protocol = undefined;
|
|
if (data.brandTemplate === "reolink" && data.username && data.password) {
|
|
try {
|
|
protocol = await detectReolinkCamera(
|
|
host,
|
|
data.username,
|
|
data.password,
|
|
);
|
|
} catch {
|
|
return [];
|
|
}
|
|
onUpdate({ reolinkProtocol: protocol });
|
|
}
|
|
|
|
// Only some Reolink cameras above 5MP serve http-flv, so RTSP is the
|
|
// fallback when the http-flv stream does not probe.
|
|
const protocolKeys =
|
|
protocol === "rtsp" ? ["http-flv", "rtsp"] : [protocol || "rtsp"];
|
|
const templates: Record<string, string> = brand.dynamicTemplates || {};
|
|
|
|
return protocolKeys
|
|
.filter((key) => key in templates)
|
|
.map((key) =>
|
|
templates[key]
|
|
.replace("{username}", data.username || "")
|
|
.replace("{password}", data.password || "")
|
|
.replace("{host}", host),
|
|
);
|
|
},
|
|
[onUpdate],
|
|
);
|
|
|
|
const generateStreamUrls = useCallback(
|
|
async (data: Partial<WizardFormData>): Promise<string[]> => {
|
|
if (data.brandTemplate === "other") {
|
|
return data.customUrl ? [data.customUrl] : [];
|
|
}
|
|
|
|
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
|
if (!brand || !data.host) return [];
|
|
|
|
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
|
|
return generateDynamicStreamUrls(data);
|
|
}
|
|
|
|
return [
|
|
brand.template
|
|
.replace("{username}", data.username || "")
|
|
.replace("{password}", data.password || "")
|
|
.replace("{host}", data.host),
|
|
];
|
|
},
|
|
[generateDynamicStreamUrls],
|
|
);
|
|
|
|
const testConnection = useCallback(
|
|
async (showToast = true) => {
|
|
const streamUrls = await generateStreamUrls(wizardData);
|
|
|
|
if (streamUrls.length === 0) {
|
|
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
|
return;
|
|
}
|
|
|
|
setIsTesting(true);
|
|
setTestStatus("");
|
|
setTestResult(null);
|
|
|
|
try {
|
|
let streamUrl = streamUrls[0];
|
|
let result: TestResult | undefined;
|
|
|
|
for (const url of streamUrls) {
|
|
streamUrl = url;
|
|
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
|
|
result = await probeUri(url, true, setTestStatus);
|
|
|
|
if (result.success && result.resolution) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (result && result.success) {
|
|
setTestResult(result);
|
|
const streamId = `stream_${Date.now()}`;
|
|
onUpdate({
|
|
streams: [
|
|
{
|
|
id: streamId,
|
|
url: streamUrl,
|
|
roles: ["detect"] as StreamRole[],
|
|
testResult: result,
|
|
useFfmpeg:
|
|
wizardData.brandTemplate === "reolink" &&
|
|
streamUrl.startsWith("http://"),
|
|
},
|
|
],
|
|
});
|
|
|
|
if (showToast) {
|
|
toast.success(t("cameraWizard.step2.testSuccess"));
|
|
}
|
|
} else {
|
|
const errMsg = result?.error || "Unable to probe stream";
|
|
setTestResult({
|
|
success: false,
|
|
error: errMsg,
|
|
});
|
|
|
|
if (showToast) {
|
|
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,
|
|
});
|
|
|
|
if (showToast) {
|
|
toast.error(
|
|
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
|
|
{
|
|
duration: 10000,
|
|
},
|
|
);
|
|
}
|
|
} finally {
|
|
setIsTesting(false);
|
|
setTestStatus("");
|
|
}
|
|
},
|
|
[wizardData, generateStreamUrls, 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 {
|
|
// Auto-run the connection test but suppress toasts to avoid duplicates
|
|
testConnection(false);
|
|
}
|
|
}
|
|
}, [hasStarted, probeMode, probeCamera, testConnection]);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{probeMode ? (
|
|
// Probe mode: show probe results directly
|
|
<>
|
|
{probeResult && (
|
|
<div className="space-y-4">
|
|
<OnvifProbeResults
|
|
isLoading={isProbing}
|
|
isError={!!probeError}
|
|
error={probeError || undefined}
|
|
probeResult={probeResult}
|
|
onRetry={probeCamera}
|
|
testCandidate={testCandidate}
|
|
candidateTests={candidateTests}
|
|
testingCandidates={testingCandidates}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<ProbeFooterButtons
|
|
isProbing={isProbing}
|
|
probeError={probeError}
|
|
onBack={onBack}
|
|
onTestAll={testAllSelectedCandidates}
|
|
onRetry={probeCamera}
|
|
// disable next if either the overall testConnection is running or any candidate test is running
|
|
isTesting={
|
|
isTesting || Object.values(testingCandidates).some((v) => v)
|
|
}
|
|
candidateCount={
|
|
(probeResult?.rtsp_candidates || []).filter(
|
|
(c) => c.source === "GetStreamUri",
|
|
).length
|
|
}
|
|
/>
|
|
</>
|
|
) : (
|
|
// Manual mode: show snapshot and stream details
|
|
<>
|
|
{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.step2.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.step2.streamDetails")}
|
|
</CardTitle>
|
|
<CardContent className="p-0 text-sm">
|
|
<StreamDetails testResult={testResult} />
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isTesting && (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<ActivityIndicator className="size-4" />
|
|
{testStatus}
|
|
</div>
|
|
)}
|
|
|
|
{testResult && !testResult.success && (
|
|
<div className="space-y-4">
|
|
<div className="text-sm text-destructive">{testResult.error}</div>
|
|
</div>
|
|
)}
|
|
|
|
<ProbeFooterButtons
|
|
mode="manual"
|
|
isProbing={false}
|
|
probeError={null}
|
|
onBack={onBack}
|
|
onTestAll={testAllSelectedCandidates}
|
|
onRetry={probeCamera}
|
|
isTesting={
|
|
isTesting || Object.values(testingCandidates).some((v) => v)
|
|
}
|
|
candidateCount={
|
|
(probeResult?.rtsp_candidates || []).filter(
|
|
(c) => c.source === "GetStreamUri",
|
|
).length
|
|
}
|
|
manualTestSuccess={!!testResult?.success}
|
|
onContinue={handleContinue}
|
|
onManualTest={testConnection}
|
|
/>
|
|
</>
|
|
)}
|
|
</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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<ActivityIndicator className="size-4" />
|
|
{t("cameraWizard.step2.probing")}
|
|
</div>
|
|
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
|
<Button type="button" onClick={onBack} disabled className="sm:flex-1">
|
|
{t("button.back", { ns: "common" })}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
disabled
|
|
variant="select"
|
|
className="flex items-center justify-center gap-2 sm:flex-1"
|
|
>
|
|
<ActivityIndicator className="size-4" />
|
|
{t("cameraWizard.step2.probing")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Error footer
|
|
if (probeError) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="text-sm text-destructive">{probeError}</div>
|
|
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
|
<Button type="button" onClick={onBack} className="sm:flex-1">
|
|
{t("button.back", { ns: "common" })}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={onRetry}
|
|
variant="select"
|
|
className="flex items-center justify-center gap-2 sm:flex-1"
|
|
>
|
|
{t("cameraWizard.step2.retry")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
|
<Button type="button" onClick={onBack} className="sm:flex-1">
|
|
{t("button.back", { ns: "common" })}
|
|
</Button>
|
|
{manualTestSuccess ? (
|
|
<Button
|
|
type="button"
|
|
onClick={onContinue}
|
|
variant="select"
|
|
className="flex items-center justify-center gap-2 sm:flex-1"
|
|
>
|
|
{t("button.continue", { ns: "common" })}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
onClick={onManualTest}
|
|
disabled={isTesting}
|
|
variant="select"
|
|
className="flex items-center justify-center gap-2 sm:flex-1"
|
|
>
|
|
{isTesting ? (
|
|
<>
|
|
<ActivityIndicator className="size-4" />{" "}
|
|
{t("button.continue", { ns: "common" })}
|
|
</>
|
|
) : (
|
|
t("cameraWizard.step2.retry")
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Default probe footer
|
|
return (
|
|
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
|
<Button type="button" onClick={onBack} className="sm:flex-1">
|
|
{t("button.back", { ns: "common" })}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={onTestAll}
|
|
disabled={isTesting || (candidateCount ?? 0) === 0}
|
|
variant="select"
|
|
className="flex items-center justify-center gap-2 sm:flex-1"
|
|
>
|
|
{t("button.next", { ns: "common" })}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|