mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-20 13:54:36 +01:00
Add Camera Wizard improvements (#20876)
* backend api endpoint * don't add no-credentials version of streams to rtsp_candidates * frontend types * improve types * add optional probe dialog to wizard step 1 * i18n * form description and field change * add onvif form description * match onvif probe pane with other steps in the wizard * refactor to add probe and snapshot as step 2 * consolidate probe dialog * don't change dialog size * radio button style * refactor to select onvif urls via combobox in step 3 * i18n * add scrollbar container * i18n cleanup * fix button activity indicator * match test parsing in step 3 with step 2 * hide resolution if both width and height are zero * use drawer for stream selection on mobile in step 3 * suppress double toasts * api endpoint description
This commit is contained in:
@@ -12,15 +12,15 @@ import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import axios from "axios";
|
||||
import Step1NameCamera from "@/components/settings/wizard/Step1NameCamera";
|
||||
import Step2StreamConfig from "@/components/settings/wizard/Step2StreamConfig";
|
||||
import Step3Validation from "@/components/settings/wizard/Step3Validation";
|
||||
import Step2ProbeOrSnapshot from "@/components/settings/wizard/Step2ProbeOrSnapshot";
|
||||
import Step3StreamConfig from "@/components/settings/wizard/Step3StreamConfig";
|
||||
import Step4Validation from "@/components/settings/wizard/Step4Validation";
|
||||
import type {
|
||||
WizardFormData,
|
||||
CameraConfigData,
|
||||
ConfigSetBody,
|
||||
} from "@/types/cameraWizard";
|
||||
import { processCameraName } from "@/utils/cameraUtil";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type WizardState = {
|
||||
@@ -57,6 +57,7 @@ const wizardReducer = (
|
||||
|
||||
const STEPS = [
|
||||
"cameraWizard.steps.nameAndConnection",
|
||||
"cameraWizard.steps.probeOrSnapshot",
|
||||
"cameraWizard.steps.streamConfiguration",
|
||||
"cameraWizard.steps.validationAndTesting",
|
||||
];
|
||||
@@ -100,20 +101,20 @@ export default function CameraWizardDialog({
|
||||
const canProceedToNext = useCallback((): boolean => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
// Can proceed if camera name is set and at least one stream exists
|
||||
return !!(
|
||||
state.wizardData.cameraName &&
|
||||
(state.wizardData.streams?.length ?? 0) > 0
|
||||
);
|
||||
// Step 1: Can proceed if camera name is set
|
||||
return !!state.wizardData.cameraName;
|
||||
case 1:
|
||||
// Can proceed if at least one stream has 'detect' role
|
||||
// Step 2: Can proceed if at least one stream exists (from probe or manual test)
|
||||
return (state.wizardData.streams?.length ?? 0) > 0;
|
||||
case 2:
|
||||
// Step 3: Can proceed if at least one stream has 'detect' role
|
||||
return !!(
|
||||
state.wizardData.streams?.some((stream) =>
|
||||
stream.roles.includes("detect"),
|
||||
) ?? false
|
||||
);
|
||||
case 2:
|
||||
// Always can proceed from final step (save will be handled there)
|
||||
case 3:
|
||||
// Step 4: Always can proceed from final step (save will be handled there)
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
@@ -340,13 +341,7 @@ export default function CameraWizardDialog({
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"max-h-[90dvh] max-w-xl overflow-y-auto",
|
||||
isDesktop &&
|
||||
currentStep == 0 &&
|
||||
state.wizardData?.streams?.[0]?.testResult?.snapshot &&
|
||||
"max-w-4xl",
|
||||
isDesktop && currentStep == 1 && "max-w-2xl",
|
||||
isDesktop && currentStep > 1 && "max-w-4xl",
|
||||
"scrollbar-container max-h-[90dvh] max-w-3xl overflow-y-auto",
|
||||
)}
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -385,7 +380,16 @@ export default function CameraWizardDialog({
|
||||
/>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<Step2StreamConfig
|
||||
<Step2ProbeOrSnapshot
|
||||
wizardData={state.wizardData}
|
||||
onUpdate={onUpdate}
|
||||
onNext={handleNext}
|
||||
onBack={handleBack}
|
||||
probeMode={state.wizardData.probeMode ?? true}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<Step3StreamConfig
|
||||
wizardData={state.wizardData}
|
||||
onUpdate={onUpdate}
|
||||
onBack={handleBack}
|
||||
@@ -393,8 +397,8 @@ export default function CameraWizardDialog({
|
||||
canProceed={canProceedToNext()}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<Step3Validation
|
||||
{currentStep === 3 && (
|
||||
<Step4Validation
|
||||
wizardData={state.wizardData}
|
||||
onUpdate={onUpdate}
|
||||
onSave={handleSave}
|
||||
|
||||
368
web/src/components/settings/wizard/OnvifProbeResults.tsx
Normal file
368
web/src/components/settings/wizard/OnvifProbeResults.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { FaCopy, FaCheck } from "react-icons/fa";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { CiCircleAlert } from "react-icons/ci";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
OnvifProbeResponse,
|
||||
OnvifRtspCandidate,
|
||||
TestResult,
|
||||
CandidateTestMap,
|
||||
} from "@/types/cameraWizard";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type OnvifProbeResultsProps = {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error?: string;
|
||||
probeResult?: OnvifProbeResponse;
|
||||
onRetry: () => void;
|
||||
selectedUris?: string[];
|
||||
testCandidate?: (uri: string) => void;
|
||||
candidateTests?: CandidateTestMap;
|
||||
testingCandidates?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export default function OnvifProbeResults({
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
probeResult,
|
||||
onRetry,
|
||||
selectedUris,
|
||||
testCandidate,
|
||||
candidateTests,
|
||||
testingCandidates,
|
||||
}: OnvifProbeResultsProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [copiedUri, setCopiedUri] = useState<string | null>(null);
|
||||
|
||||
const handleCopyUri = (uri: string) => {
|
||||
navigator.clipboard.writeText(uri);
|
||||
setCopiedUri(uri);
|
||||
toast.success(t("cameraWizard.step2.uriCopied"));
|
||||
setTimeout(() => setCopiedUri(null), 2000);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||
<ActivityIndicator className="size-6" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("cameraWizard.step2.probingDevice")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<CiCircleAlert className="size-5" />
|
||||
<AlertTitle>{t("cameraWizard.step2.probeError")}</AlertTitle>
|
||||
{error && <AlertDescription>{error}</AlertDescription>}
|
||||
</Alert>
|
||||
<Button onClick={onRetry} variant="outline" className="w-full">
|
||||
{t("button.retry", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!probeResult?.success) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<CiCircleAlert className="size-5" />
|
||||
<AlertTitle>{t("cameraWizard.step2.probeNoSuccess")}</AlertTitle>
|
||||
{probeResult?.message && (
|
||||
<AlertDescription>{probeResult.message}</AlertDescription>
|
||||
)}
|
||||
</Alert>
|
||||
<Button onClick={onRetry} variant="outline" className="w-full">
|
||||
{t("button.retry", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rtspCandidates = (probeResult.rtsp_candidates || []).filter(
|
||||
(c) => c.source === "GetStreamUri",
|
||||
);
|
||||
|
||||
if (probeResult?.success && rtspCandidates.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<CiCircleAlert className="size-5" />
|
||||
<AlertTitle>{t("cameraWizard.step2.noRtspCandidates")}</AlertTitle>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{probeResult?.success && (
|
||||
<div className="mb-3 flex flex-row items-center gap-2 text-sm text-success">
|
||||
<FaCircleCheck className="size-4" />
|
||||
<span>{t("cameraWizard.step2.probeSuccessful")}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm">{t("cameraWizard.step2.deviceInfo")}</div>
|
||||
<Card>
|
||||
<CardContent className="space-y-2 p-4 text-sm">
|
||||
{probeResult.manufacturer && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.manufacturer")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{probeResult.manufacturer}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{probeResult.model && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.model")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{probeResult.model}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{probeResult.firmware_version && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.firmware")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{probeResult.firmware_version}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{probeResult.profiles_count !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.profiles")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{probeResult.profiles_count}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{probeResult.ptz_supported !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.ptzSupport")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{probeResult.ptz_supported
|
||||
? t("yes", { ns: "common" })
|
||||
: t("no", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{probeResult.ptz_supported && probeResult.autotrack_supported && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.autotrackingSupport")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{t("yes", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{probeResult.ptz_supported &&
|
||||
probeResult.presets_count !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.presets")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{probeResult.presets_count}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{rtspCandidates.length > 0 && (
|
||||
<div className="mt-5 space-y-2">
|
||||
<div className="text-sm">
|
||||
{t("cameraWizard.step2.rtspCandidates")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("cameraWizard.step2.rtspCandidatesDescription")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{rtspCandidates.map((candidate, idx) => {
|
||||
const isSelected = !!selectedUris?.includes(candidate.uri);
|
||||
const candidateTest = candidateTests?.[candidate.uri];
|
||||
const isTesting = testingCandidates?.[candidate.uri];
|
||||
|
||||
return (
|
||||
<CandidateItem
|
||||
key={idx}
|
||||
index={idx}
|
||||
candidate={candidate}
|
||||
copiedUri={copiedUri}
|
||||
onCopy={() => handleCopyUri(candidate.uri)}
|
||||
isSelected={isSelected}
|
||||
testCandidate={testCandidate}
|
||||
candidateTest={candidateTest}
|
||||
isTesting={isTesting}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type CandidateItemProps = {
|
||||
candidate: OnvifRtspCandidate;
|
||||
index?: number;
|
||||
copiedUri: string | null;
|
||||
onCopy: () => void;
|
||||
isSelected?: boolean;
|
||||
testCandidate?: (uri: string) => void;
|
||||
candidateTest?: TestResult | { success: false; error: string };
|
||||
isTesting?: boolean;
|
||||
};
|
||||
|
||||
function CandidateItem({
|
||||
index,
|
||||
candidate,
|
||||
copiedUri,
|
||||
onCopy,
|
||||
isSelected,
|
||||
testCandidate,
|
||||
candidateTest,
|
||||
isTesting,
|
||||
}: CandidateItemProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [showFull, setShowFull] = useState(false);
|
||||
|
||||
const maskUri = (uri: string) => {
|
||||
const match = uri.match(/rtsp:\/\/([^:]+):([^@]+)@(.+)/);
|
||||
if (match) return `rtsp://${match[1]}:••••@${match[3]}`;
|
||||
return uri;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
isSelected &&
|
||||
"outline outline-[3px] -outline-offset-[2.8px] outline-selected duration-200",
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
{t("cameraWizard.step2.candidateStreamTitle", {
|
||||
number: (index ?? 0) + 1,
|
||||
})}
|
||||
</h4>
|
||||
{candidateTest?.success && (
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{[
|
||||
candidateTest.resolution,
|
||||
candidateTest.fps
|
||||
? `${candidateTest.fps} ${t(
|
||||
"cameraWizard.testResultLabels.fps",
|
||||
)}`
|
||||
: null,
|
||||
candidateTest.videoCodec,
|
||||
candidateTest.audioCodec,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{candidateTest?.success && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<FaCircleCheck className="size-4 text-success" />
|
||||
<span className="text-success">
|
||||
{t("cameraWizard.step2.connected")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidateTest && !candidateTest.success && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<LuX className="size-4 text-danger" />
|
||||
<span className="text-danger">
|
||||
{t("cameraWizard.step2.notConnected")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-start gap-2">
|
||||
<p
|
||||
className="flex-1 cursor-pointer break-all text-sm text-primary-variant hover:underline"
|
||||
onClick={() => setShowFull((s) => !s)}
|
||||
title={t("cameraWizard.step2.toggleUriView")}
|
||||
>
|
||||
{showFull ? candidate.uri : maskUri(candidate.uri)}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onCopy}
|
||||
className="mr-4 size-8 p-0"
|
||||
title={t("cameraWizard.step2.uriCopy")}
|
||||
>
|
||||
{copiedUri === candidate.uri ? (
|
||||
<FaCheck className="size-3" />
|
||||
) : (
|
||||
<FaCopy className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isTesting}
|
||||
onClick={() => testCandidate?.(candidate.uri)}
|
||||
className="h-8 px-3 text-sm"
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<ActivityIndicator className="mr-2 size-4" />{" "}
|
||||
{t("cameraWizard.step2.testConnection")}
|
||||
</>
|
||||
) : (
|
||||
t("cameraWizard.step2.testConnection")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@@ -15,15 +16,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
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 {
|
||||
@@ -31,20 +30,13 @@ import {
|
||||
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<WizardFormData>;
|
||||
@@ -63,9 +55,9 @@ export default function Step1NameCamera({
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testStatus, setTestStatus] = useState<string>("");
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
const [probeMode, setProbeMode] = useState<boolean>(
|
||||
wizardData.probeMode ?? true,
|
||||
);
|
||||
|
||||
const existingCameraNames = useMemo(() => {
|
||||
if (!config?.cameras) {
|
||||
@@ -88,6 +80,7 @@ export default function Step1NameCamera({
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
|
||||
onvifPort: z.coerce.number().int().min(1).max(65535).optional(),
|
||||
customUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -124,6 +117,7 @@ export default function Step1NameCamera({
|
||||
? (wizardData.brandTemplate as CameraBrand)
|
||||
: "dahua",
|
||||
customUrl: wizardData.customUrl || "",
|
||||
onvifPort: wizardData.onvifPort ?? 80,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
@@ -132,271 +126,212 @@ export default function Step1NameCamera({
|
||||
const watchedHost = form.watch("host");
|
||||
const watchedCustomUrl = form.watch("customUrl");
|
||||
|
||||
const isTestButtonEnabled =
|
||||
watchedBrand === "other"
|
||||
? !!(watchedCustomUrl && watchedCustomUrl.trim())
|
||||
: !!(watchedHost && watchedHost.trim());
|
||||
const hostPresent = !!(watchedHost && watchedHost.trim());
|
||||
const customPresent = !!(watchedCustomUrl && watchedCustomUrl.trim());
|
||||
const cameraNamePresent = !!(form.getValues().cameraName || "").trim();
|
||||
|
||||
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;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const generateStreamUrl = useCallback(
|
||||
async (data: z.infer<typeof step1FormData>): Promise<string> => {
|
||||
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);
|
||||
setTestStatus("");
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
// First get probe data for metadata
|
||||
setTestStatus(t("cameraWizard.step1.testing.probingMetadata"));
|
||||
const probeResponse = await axios.get("ffprobe", {
|
||||
params: { paths: streamUrl, detailed: true },
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
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) {
|
||||
setTestStatus(t("cameraWizard.step1.testing.fetchingSnapshot"));
|
||||
try {
|
||||
const snapshotResponse = await axios.get("ffprobe/snapshot", {
|
||||
params: { url: streamUrl },
|
||||
responseType: "blob",
|
||||
timeout: 10000,
|
||||
});
|
||||
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<string>((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);
|
||||
onUpdate({ streams: [{ id: "", url: "", roles: [], testResult }] });
|
||||
toast.success(t("cameraWizard.step1.testSuccess"));
|
||||
} else {
|
||||
const error =
|
||||
Array.isArray(probeResponse.data?.[0]?.stderr) &&
|
||||
probeResponse.data[0].stderr.length > 0
|
||||
? probeResponse.data[0].stderr.join("\n")
|
||||
: "Unable to probe stream";
|
||||
setTestResult({
|
||||
success: false,
|
||||
error: error,
|
||||
});
|
||||
toast.error(t("cameraWizard.commonErrors.testFailed", { error }), {
|
||||
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,
|
||||
});
|
||||
toast.error(
|
||||
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
|
||||
{
|
||||
duration: 10000,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
setTestStatus("");
|
||||
}
|
||||
}, [form, generateStreamUrl, t, onUpdate]);
|
||||
const isContinueButtonEnabled =
|
||||
cameraNamePresent &&
|
||||
(probeMode
|
||||
? hostPresent
|
||||
: watchedBrand === "other"
|
||||
? customPresent
|
||||
: hostPresent);
|
||||
|
||||
const onSubmit = (data: z.infer<typeof step1FormData>) => {
|
||||
onUpdate(data);
|
||||
onUpdate({ ...data, probeMode });
|
||||
};
|
||||
|
||||
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]);
|
||||
const isValid = await form.trigger();
|
||||
if (isValid) {
|
||||
const data = form.getValues();
|
||||
onNext({ ...data, probeMode });
|
||||
}
|
||||
}, [form, probeMode, onNext]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!testResult?.success && (
|
||||
<>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("cameraWizard.step1.description")}
|
||||
</div>
|
||||
<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>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.cameraName")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cameraName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.cameraName")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
placeholder={t("cameraWizard.step1.cameraNamePlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.host")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
placeholder="192.168.1.100"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
className="text-md h-8 pr-10"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t(
|
||||
"cameraWizard.step1.cameraNamePlaceholder",
|
||||
"cameraWizard.step1.passwordPlaceholder",
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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");
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="probe"
|
||||
id="probe-mode"
|
||||
className={
|
||||
probeMode
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
/>
|
||||
<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"
|
||||
className={
|
||||
!probeMode
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
/>
|
||||
<label htmlFor="manual-mode" className="cursor-pointer text-sm">
|
||||
{t("cameraWizard.step1.manualMode")}
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<FormDescription>
|
||||
{t("cameraWizard.step1.detectionMethodDescription")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
{probeMode && (
|
||||
<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>
|
||||
<FormDescription>
|
||||
{t("cameraWizard.step1.onvifPortDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage>
|
||||
{fieldState.error ? fieldState.error.message : null}
|
||||
</FormMessage>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!probeMode && (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandTemplate"
|
||||
@@ -463,90 +398,6 @@ export default function Step1NameCamera({
|
||||
)}
|
||||
/>
|
||||
|
||||
{watchedBrand !== "other" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.host")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
placeholder="192.168.1.100"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="text-md h-8 pr-10"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t(
|
||||
"cameraWizard.step1.passwordPlaceholder",
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBrand == "other" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -568,124 +419,25 @@ export default function Step1NameCamera({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{isTesting && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ActivityIndicator className="size-4" />
|
||||
{testStatus}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onCancel} className="sm:flex-1">
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={testResult?.success ? () => setTestResult(null) : onCancel}
|
||||
className="sm:flex-1"
|
||||
onClick={handleContinue}
|
||||
disabled={!isContinueButtonEnabled}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
{testResult?.success
|
||||
? t("button.back", { ns: "common" })
|
||||
: t("button.cancel", { ns: "common" })}
|
||||
{t("button.continue", { 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"
|
||||
onClick={testConnection}
|
||||
disabled={isTesting || !isTestButtonEnabled}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
{t("cameraWizard.step1.testConnection")}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
724
web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx
Normal file
724
web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx
Normal file
@@ -0,0 +1,724 @@
|
||||
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 (snapshotError) {
|
||||
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,
|
||||
},
|
||||
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);
|
||||
onUpdate({
|
||||
probeMode: true,
|
||||
probeCandidates: candidateUris,
|
||||
candidateTests: {},
|
||||
});
|
||||
} 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 generateDynamicStreamUrl = useCallback(
|
||||
async (data: Partial<WizardFormData>): 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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const generateStreamUrl = useCallback(
|
||||
async (data: Partial<WizardFormData>): Promise<string> => {
|
||||
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 (showToast = true) => {
|
||||
const streamUrl = await generateStreamUrl(wizardData);
|
||||
|
||||
if (!streamUrl) {
|
||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTesting(true);
|
||||
setTestStatus("");
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
|
||||
const result = await probeUri(streamUrl, true, setTestStatus);
|
||||
|
||||
if (result && result.success) {
|
||||
setTestResult(result);
|
||||
const streamId = `stream_${Date.now()}`;
|
||||
onUpdate({
|
||||
streams: [
|
||||
{
|
||||
id: streamId,
|
||||
url: streamUrl,
|
||||
roles: ["detect"] as StreamRole[],
|
||||
testResult: result,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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, generateStreamUrl, 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 gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<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 gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<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 gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<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 gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,481 +0,0 @@
|
||||
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 Step2StreamConfigProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||
onBack?: () => void;
|
||||
onNext?: () => void;
|
||||
canProceed?: boolean;
|
||||
};
|
||||
|
||||
export default function Step2StreamConfig({
|
||||
wizardData,
|
||||
onUpdate,
|
||||
onBack,
|
||||
onNext,
|
||||
canProceed,
|
||||
}: Step2StreamConfigProps) {
|
||||
const { t } = useTranslation(["views/settings", "components/dialog"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const [testingStreams, setTestingStreams] = useState<Set<string>>(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<StreamConfig>) => {
|
||||
onUpdate({
|
||||
streams: streams.map((s) =>
|
||||
s.id === streamId ? { ...s, ...updates } : s,
|
||||
),
|
||||
});
|
||||
},
|
||||
[streams, onUpdate],
|
||||
);
|
||||
|
||||
const getUsedRolesExcludingStream = useCallback(
|
||||
(excludeStreamId: string) => {
|
||||
const roles = new Set<StreamRole>();
|
||||
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.step2.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 (
|
||||
<div className="space-y-6">
|
||||
<div className="text-sm text-secondary-foreground">
|
||||
{t("cameraWizard.step2.description")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{streams.map((stream, index) => (
|
||||
<Card key={stream.id} className="bg-secondary text-primary">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
{t("cameraWizard.step2.streamTitle", { number: index + 1 })}
|
||||
</h4>
|
||||
{stream.testResult && stream.testResult.success && (
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{[
|
||||
stream.testResult.resolution,
|
||||
stream.testResult.fps
|
||||
? `${stream.testResult.fps} ${t("cameraWizard.testResultLabels.fps")}`
|
||||
: null,
|
||||
stream.testResult.videoCodec,
|
||||
stream.testResult.audioCodec,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{stream.testResult?.success && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<FaCircleCheck className="size-4 text-success" />
|
||||
<span className="text-success">
|
||||
{t("cameraWizard.step2.connected")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{stream.testResult && !stream.testResult.success && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<LuX className="size-4 text-danger" />
|
||||
<span className="text-danger">
|
||||
{t("cameraWizard.step2.notConnected")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{streams.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeStream(stream.id)}
|
||||
className="text-secondary-foreground hover:text-secondary-foreground"
|
||||
>
|
||||
<LuTrash2 className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step2.url")}
|
||||
</label>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Input
|
||||
value={stream.url}
|
||||
onChange={(e) =>
|
||||
updateStream(stream.id, {
|
||||
url: e.target.value,
|
||||
testResult: undefined,
|
||||
})
|
||||
}
|
||||
className="h-8 flex-1"
|
||||
placeholder={t("cameraWizard.step2.streamUrlPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => testStream(stream)}
|
||||
disabled={
|
||||
testingStreams.has(stream.id) || !stream.url.trim()
|
||||
}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{testingStreams.has(stream.id) && (
|
||||
<ActivityIndicator className="mr-2 size-4" />
|
||||
)}
|
||||
{t("cameraWizard.step2.testStream")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stream.testResult &&
|
||||
!stream.testResult.success &&
|
||||
stream.userTested && (
|
||||
<div className="rounded-md border border-danger/20 bg-danger/10 p-3 text-sm text-danger">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step2.testFailedTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-xs">
|
||||
{stream.testResult.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step2.roles")}
|
||||
</Label>
|
||||
<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-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step2.rolesPopover.title")}
|
||||
</div>
|
||||
<div className="space-y-1 text-muted-foreground">
|
||||
<div>
|
||||
<strong>detect</strong> -{" "}
|
||||
{t("cameraWizard.step2.rolesPopover.detect")}
|
||||
</div>
|
||||
<div>
|
||||
<strong>record</strong> -{" "}
|
||||
{t("cameraWizard.step2.rolesPopover.record")}
|
||||
</div>
|
||||
<div>
|
||||
<strong>audio</strong> -{" "}
|
||||
{t("cameraWizard.step2.rolesPopover.audio")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/cameras")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["detect", "record", "audio"] as const).map((role) => {
|
||||
const isUsedElsewhere = getUsedRolesExcludingStream(
|
||||
stream.id,
|
||||
).has(role);
|
||||
const isChecked = stream.roles.includes(role);
|
||||
return (
|
||||
<div
|
||||
key={role}
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<span className="text-sm capitalize">{role}</span>
|
||||
<Switch
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleRole(stream.id, role)}
|
||||
disabled={!isChecked && isUsedElsewhere}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step2.featuresTitle")}
|
||||
</Label>
|
||||
<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-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step2.featuresPopover.title")}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.featuresPopover.description")}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl(
|
||||
"configuration/restream#reduce-connections-to-camera",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">
|
||||
{t("cameraWizard.step2.go2rtc")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={stream.restream || false}
|
||||
onCheckedChange={() => setRestream(stream.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addStream}
|
||||
variant="outline"
|
||||
className=""
|
||||
>
|
||||
<LuPlus className="mr-2 size-4" />
|
||||
{t("cameraWizard.step2.addAnotherStream")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!hasDetectRole && (
|
||||
<div className="rounded-lg border border-danger/50 p-3 text-sm text-danger">
|
||||
{t("cameraWizard.step2.detectRoleWarning")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
{onBack && (
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
{onNext && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onNext?.()}
|
||||
disabled={!canProceed}
|
||||
variant="select"
|
||||
className="sm:flex-1"
|
||||
>
|
||||
{t("button.next", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
757
web/src/components/settings/wizard/Step3StreamConfig.tsx
Normal file
757
web/src/components/settings/wizard/Step3StreamConfig.tsx
Normal file
@@ -0,0 +1,757 @@
|
||||
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<WizardFormData>;
|
||||
onUpdate: (data: Partial<WizardFormData>) => 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<Set<string>>(new Set());
|
||||
const [openCombobox, setOpenCombobox] = useState<string | null>(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<StreamConfig>) => {
|
||||
onUpdate({
|
||||
streams: streams.map((s) =>
|
||||
s.id === streamId ? { ...s, ...updates } : s,
|
||||
),
|
||||
});
|
||||
},
|
||||
[streams, onUpdate],
|
||||
);
|
||||
|
||||
const getUsedRolesExcludingStream = useCallback(
|
||||
(excludeStreamId: string) => {
|
||||
const roles = new Set<StreamRole>();
|
||||
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<string>();
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="text-sm text-secondary-foreground">
|
||||
{t("cameraWizard.step3.description")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{streams.map((stream, index) => (
|
||||
<Card key={stream.id} className="bg-secondary text-primary">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
{t("cameraWizard.step3.streamTitle", { number: index + 1 })}
|
||||
</h4>
|
||||
{stream.testResult && stream.testResult.success && (
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{[
|
||||
stream.testResult.resolution,
|
||||
stream.testResult.fps
|
||||
? `${stream.testResult.fps} ${t("cameraWizard.testResultLabels.fps")}`
|
||||
: null,
|
||||
stream.testResult.videoCodec,
|
||||
stream.testResult.audioCodec,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{stream.testResult?.success && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<FaCircleCheck className="size-4 text-success" />
|
||||
<span className="text-success">
|
||||
{t("cameraWizard.step3.connected")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{stream.testResult && !stream.testResult.success && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<LuX className="size-4 text-danger" />
|
||||
<span className="text-danger">
|
||||
{t("cameraWizard.step3.notConnected")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{streams.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeStream(stream.id)}
|
||||
className="text-secondary-foreground hover:text-secondary-foreground"
|
||||
>
|
||||
<LuTrash2 className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step3.url")}
|
||||
</label>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{isProbeMode && probeCandidates.length > 0 ? (
|
||||
// Responsive: Popover on desktop, Drawer on mobile
|
||||
!isMobile ? (
|
||||
<Popover
|
||||
open={openCombobox === stream.id}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpenCombobox(isOpen ? stream.id : null);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openCombobox === stream.id}
|
||||
className="h-8 w-full justify-between overflow-hidden text-left"
|
||||
>
|
||||
<span className="truncate">
|
||||
{stream.url
|
||||
? stream.url
|
||||
: t("cameraWizard.step3.selectStream")}
|
||||
</span>
|
||||
<LuChevronsUpDown className="ml-2 size-6 opacity-50" />
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[--radix-popover-trigger-width] p-2"
|
||||
disablePortal
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"cameraWizard.step3.searchCandidates",
|
||||
)}
|
||||
className="h-9"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("cameraWizard.step3.noStreamFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{probeCandidates
|
||||
.filter((c) => {
|
||||
const used = getUsedUrlsExcludingStream(
|
||||
stream.id,
|
||||
);
|
||||
return !used.has(c);
|
||||
})
|
||||
.map((candidate) => (
|
||||
<CommandItem
|
||||
key={candidate}
|
||||
value={candidate}
|
||||
onSelect={() => {
|
||||
updateStream(stream.id, {
|
||||
url: candidate,
|
||||
testResult:
|
||||
candidateTests[candidate] ||
|
||||
undefined,
|
||||
userTested:
|
||||
!!candidateTests[candidate],
|
||||
});
|
||||
setOpenCombobox(null);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-3 size-5",
|
||||
stream.url === candidate
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{candidate}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Drawer
|
||||
open={openCombobox === stream.id}
|
||||
onOpenChange={(isOpen) =>
|
||||
setOpenCombobox(isOpen ? stream.id : null)
|
||||
}
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openCombobox === stream.id}
|
||||
className="h-8 w-full justify-between overflow-hidden text-left"
|
||||
>
|
||||
<span className="truncate">
|
||||
{stream.url
|
||||
? stream.url
|
||||
: t("cameraWizard.step3.selectStream")}
|
||||
</span>
|
||||
<LuChevronsUpDown className="ml-2 size-6 opacity-50" />
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="mx-1 max-h-[75dvh] overflow-hidden rounded-t-2xl px-2">
|
||||
<div className="mt-2">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"cameraWizard.step3.searchCandidates",
|
||||
)}
|
||||
className="h-9"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("cameraWizard.step3.noStreamFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{probeCandidates
|
||||
.filter((c) => {
|
||||
const used = getUsedUrlsExcludingStream(
|
||||
stream.id,
|
||||
);
|
||||
return !used.has(c);
|
||||
})
|
||||
.map((candidate) => (
|
||||
<CommandItem
|
||||
key={candidate}
|
||||
value={candidate}
|
||||
onSelect={() => {
|
||||
updateStream(stream.id, {
|
||||
url: candidate,
|
||||
testResult:
|
||||
candidateTests[candidate] ||
|
||||
undefined,
|
||||
userTested:
|
||||
!!candidateTests[candidate],
|
||||
});
|
||||
setOpenCombobox(null);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-3 size-5",
|
||||
stream.url === candidate
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{candidate}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
) : (
|
||||
<Input
|
||||
value={stream.url}
|
||||
onChange={(e) =>
|
||||
updateStream(stream.id, {
|
||||
url: e.target.value,
|
||||
testResult: undefined,
|
||||
})
|
||||
}
|
||||
className="h-8 flex-1"
|
||||
placeholder={t(
|
||||
"cameraWizard.step3.streamUrlPlaceholder",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => testStream(stream)}
|
||||
disabled={
|
||||
testingStreams.has(stream.id) || !stream.url.trim()
|
||||
}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{testingStreams.has(stream.id) && (
|
||||
<ActivityIndicator className="mr-2 size-4" />
|
||||
)}
|
||||
{t("cameraWizard.step3.testStream")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stream.testResult &&
|
||||
!stream.testResult.success &&
|
||||
stream.userTested && (
|
||||
<div className="rounded-md border border-danger/20 bg-danger/10 p-3 text-sm text-danger">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step3.testFailedTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-xs">
|
||||
{stream.testResult.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step3.roles")}
|
||||
</Label>
|
||||
<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-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step3.rolesPopover.title")}
|
||||
</div>
|
||||
<div className="space-y-1 text-muted-foreground">
|
||||
<div>
|
||||
<strong>detect</strong> -{" "}
|
||||
{t("cameraWizard.step3.rolesPopover.detect")}
|
||||
</div>
|
||||
<div>
|
||||
<strong>record</strong> -{" "}
|
||||
{t("cameraWizard.step3.rolesPopover.record")}
|
||||
</div>
|
||||
<div>
|
||||
<strong>audio</strong> -{" "}
|
||||
{t("cameraWizard.step3.rolesPopover.audio")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/cameras")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["detect", "record", "audio"] as const).map((role) => {
|
||||
const isUsedElsewhere = getUsedRolesExcludingStream(
|
||||
stream.id,
|
||||
).has(role);
|
||||
const isChecked = stream.roles.includes(role);
|
||||
return (
|
||||
<div
|
||||
key={role}
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<span className="text-sm capitalize">{role}</span>
|
||||
<Switch
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleRole(stream.id, role)}
|
||||
disabled={!isChecked && isUsedElsewhere}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step3.featuresTitle")}
|
||||
</Label>
|
||||
<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-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step3.featuresPopover.title")}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t("cameraWizard.step3.featuresPopover.description")}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl(
|
||||
"configuration/restream#reduce-connections-to-camera",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">
|
||||
{t("cameraWizard.step3.go2rtc")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={stream.restream || false}
|
||||
onCheckedChange={() => setRestream(stream.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addStream}
|
||||
variant="outline"
|
||||
className=""
|
||||
>
|
||||
<LuPlus className="mr-2 size-4" />
|
||||
{t("cameraWizard.step3.addAnotherStream")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!hasDetectRole && (
|
||||
<div className="rounded-lg border border-danger/50 p-3 text-sm text-danger">
|
||||
{t("cameraWizard.step3.detectRoleWarning")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
{onBack && (
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
{onNext && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onNext?.()}
|
||||
disabled={!canProceed || testingStreams.size > 0}
|
||||
variant="select"
|
||||
className="sm:flex-1"
|
||||
>
|
||||
{t("button.next", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { Card, CardContent } from "../../ui/card";
|
||||
|
||||
type Step3ValidationProps = {
|
||||
type Step4ValidationProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||
onSave: (config: WizardFormData) => void;
|
||||
@@ -27,13 +27,13 @@ type Step3ValidationProps = {
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export default function Step3Validation({
|
||||
export default function Step4Validation({
|
||||
wizardData,
|
||||
onUpdate,
|
||||
onSave,
|
||||
onBack,
|
||||
isLoading = false,
|
||||
}: Step3ValidationProps) {
|
||||
}: Step4ValidationProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
||||
@@ -143,13 +143,13 @@ export default function Step3Validation({
|
||||
|
||||
if (testResult.success) {
|
||||
toast.success(
|
||||
t("cameraWizard.step3.streamValidated", {
|
||||
t("cameraWizard.step4.streamValidated", {
|
||||
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t("cameraWizard.step3.streamValidationFailed", {
|
||||
t("cameraWizard.step4.streamValidationFailed", {
|
||||
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
||||
}),
|
||||
);
|
||||
@@ -200,16 +200,16 @@ export default function Step3Validation({
|
||||
(r) => r.success,
|
||||
).length;
|
||||
if (successfulTests === results.size) {
|
||||
toast.success(t("cameraWizard.step3.reconnectionSuccess"));
|
||||
toast.success(t("cameraWizard.step4.reconnectionSuccess"));
|
||||
} else {
|
||||
toast.warning(t("cameraWizard.step3.reconnectionPartial"));
|
||||
toast.warning(t("cameraWizard.step4.reconnectionPartial"));
|
||||
}
|
||||
}
|
||||
}, [streams, onUpdate, t, performStreamValidation]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!wizardData.cameraName || !wizardData.streams?.length) {
|
||||
toast.error(t("cameraWizard.step3.saveError"));
|
||||
toast.error(t("cameraWizard.step4.saveError"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -239,13 +239,13 @@ export default function Step3Validation({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("cameraWizard.step3.description")}
|
||||
{t("cameraWizard.step4.description")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("cameraWizard.step3.validationTitle")}
|
||||
{t("cameraWizard.step4.validationTitle")}
|
||||
</h3>
|
||||
<Button
|
||||
onClick={validateAllStreams}
|
||||
@@ -254,8 +254,8 @@ export default function Step3Validation({
|
||||
>
|
||||
{isValidating && <ActivityIndicator className="mr-2 size-4" />}
|
||||
{isValidating
|
||||
? t("cameraWizard.step3.connecting")
|
||||
: t("cameraWizard.step3.connectAllStreams")}
|
||||
? t("cameraWizard.step4.connecting")
|
||||
: t("cameraWizard.step4.connectAllStreams")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -270,7 +270,7 @@ export default function Step3Validation({
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="flex flex-row items-center">
|
||||
<h4 className="mr-2 font-medium">
|
||||
{t("cameraWizard.step3.streamTitle", {
|
||||
{t("cameraWizard.step4.streamTitle", {
|
||||
number: index + 1,
|
||||
})}
|
||||
</h4>
|
||||
@@ -331,7 +331,7 @@ export default function Step3Validation({
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">
|
||||
{t("cameraWizard.step3.ffmpegModule")}
|
||||
{t("cameraWizard.step4.ffmpegModule")}
|
||||
</span>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -346,11 +346,11 @@ export default function Step3Validation({
|
||||
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step3.ffmpegModule")}
|
||||
{t("cameraWizard.step4.ffmpegModule")}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t(
|
||||
"cameraWizard.step3.ffmpegModuleDescription",
|
||||
"cameraWizard.step4.ffmpegModuleDescription",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -402,17 +402,17 @@ export default function Step3Validation({
|
||||
<ActivityIndicator className="mr-2 size-4" />
|
||||
)}
|
||||
{result?.success
|
||||
? t("cameraWizard.step3.disconnectStream")
|
||||
? t("cameraWizard.step4.disconnectStream")
|
||||
: testingStreams.has(stream.id)
|
||||
? t("cameraWizard.step3.connectingStream")
|
||||
: t("cameraWizard.step3.connectStream")}
|
||||
? t("cameraWizard.step4.connectingStream")
|
||||
: t("cameraWizard.step4.connectStream")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs">
|
||||
{t("cameraWizard.step3.issues.title")}
|
||||
{t("cameraWizard.step4.issues.title")}
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<StreamIssues
|
||||
@@ -455,7 +455,7 @@ export default function Step3Validation({
|
||||
{isLoading && <ActivityIndicator className="mr-2 size-4" />}
|
||||
{isLoading
|
||||
? t("button.saving", { ns: "common" })
|
||||
: t("cameraWizard.step3.saveAndApply")}
|
||||
: t("cameraWizard.step4.saveAndApply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -486,7 +486,7 @@ function StreamIssues({
|
||||
if (streamUrl.startsWith("rtsp://")) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step1.errors.brands.reolink-rtsp"),
|
||||
message: t("cameraWizard.step4.issues.brands.reolink-rtsp"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -497,7 +497,7 @@ function StreamIssues({
|
||||
if (["h264", "h265", "hevc"].includes(videoCodec)) {
|
||||
result.push({
|
||||
type: "good",
|
||||
message: t("cameraWizard.step3.issues.videoCodecGood", {
|
||||
message: t("cameraWizard.step4.issues.videoCodecGood", {
|
||||
codec: stream.testResult.videoCodec,
|
||||
}),
|
||||
});
|
||||
@@ -511,20 +511,20 @@ function StreamIssues({
|
||||
if (audioCodec === "aac") {
|
||||
result.push({
|
||||
type: "good",
|
||||
message: t("cameraWizard.step3.issues.audioCodecGood", {
|
||||
message: t("cameraWizard.step4.issues.audioCodecGood", {
|
||||
codec: stream.testResult.audioCodec,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step3.issues.audioCodecRecordError"),
|
||||
message: t("cameraWizard.step4.issues.audioCodecRecordError"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.noAudioWarning"),
|
||||
message: t("cameraWizard.step4.issues.noAudioWarning"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -534,7 +534,7 @@ function StreamIssues({
|
||||
if (!stream.testResult?.audioCodec) {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step3.issues.audioCodecRequired"),
|
||||
message: t("cameraWizard.step4.issues.audioCodecRequired"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -544,7 +544,7 @@ function StreamIssues({
|
||||
if (stream.restream) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.restreamingWarning"),
|
||||
message: t("cameraWizard.step4.issues.restreamingWarning"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -557,14 +557,14 @@ function StreamIssues({
|
||||
if (minDimension > 1080) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.resolutionHigh", {
|
||||
message: t("cameraWizard.step4.issues.resolutionHigh", {
|
||||
resolution: stream.resolution,
|
||||
}),
|
||||
});
|
||||
} else if (maxDimension < 640) {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step3.issues.resolutionLow", {
|
||||
message: t("cameraWizard.step4.issues.resolutionLow", {
|
||||
resolution: stream.resolution,
|
||||
}),
|
||||
});
|
||||
@@ -580,7 +580,7 @@ function StreamIssues({
|
||||
) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.dahua.substreamWarning"),
|
||||
message: t("cameraWizard.step4.issues.dahua.substreamWarning"),
|
||||
});
|
||||
}
|
||||
if (
|
||||
@@ -590,7 +590,7 @@ function StreamIssues({
|
||||
) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.hikvision.substreamWarning"),
|
||||
message: t("cameraWizard.step4.issues.hikvision.substreamWarning"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -662,7 +662,7 @@ function BandwidthDisplay({
|
||||
return (
|
||||
<div className="mb-2 text-sm">
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{t("cameraWizard.step3.estimatedBandwidth")}:
|
||||
{t("cameraWizard.step4.estimatedBandwidth")}:
|
||||
</span>{" "}
|
||||
<span className="text-secondary-foreground">
|
||||
{streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })}
|
||||
@@ -748,7 +748,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
||||
style={{ aspectRatio }}
|
||||
>
|
||||
<span className="text-sm text-danger">
|
||||
{t("cameraWizard.step3.streamUnavailable")}
|
||||
{t("cameraWizard.step4.streamUnavailable")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -757,7 +757,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<LuRotateCcw className="size-4" />
|
||||
{t("cameraWizard.step3.reload")}
|
||||
{t("cameraWizard.step4.reload")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -771,7 +771,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
||||
>
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span className="ml-2 text-sm">
|
||||
{t("cameraWizard.step3.connecting")}
|
||||
{t("cameraWizard.step4.connecting")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -99,6 +99,11 @@ export type TestResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type CandidateTestMap = Record<
|
||||
string,
|
||||
TestResult | { success: false; error: string }
|
||||
>;
|
||||
|
||||
export type WizardFormData = {
|
||||
cameraName?: string;
|
||||
host?: string;
|
||||
@@ -107,12 +112,17 @@ export type WizardFormData = {
|
||||
brandTemplate?: CameraBrand;
|
||||
customUrl?: string;
|
||||
streams?: StreamConfig[];
|
||||
probeMode?: boolean; // true for probe, false for manual
|
||||
onvifPort?: number;
|
||||
probeResult?: OnvifProbeResponse;
|
||||
probeCandidates?: string[]; // candidate URLs from probe
|
||||
candidateTests?: CandidateTestMap; // test results for candidates
|
||||
};
|
||||
|
||||
// API Response Types
|
||||
export type FfprobeResponse = {
|
||||
return_code: number;
|
||||
stderr: string;
|
||||
stderr: string | string[];
|
||||
stdout: FfprobeData | string;
|
||||
};
|
||||
|
||||
@@ -167,3 +177,26 @@ export type ConfigSetBody = {
|
||||
config_data: CameraConfigData;
|
||||
update_topic?: string;
|
||||
};
|
||||
|
||||
export type OnvifRtspCandidate = {
|
||||
source: "GetStreamUri" | "pattern";
|
||||
profile_token?: string;
|
||||
uri: string;
|
||||
};
|
||||
|
||||
export type OnvifProbeResponse = {
|
||||
success: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
firmware_version?: string;
|
||||
profiles_count?: number;
|
||||
ptz_supported?: boolean;
|
||||
presets_count?: number;
|
||||
autotrack_supported?: boolean;
|
||||
move_status_supported?: boolean;
|
||||
rtsp_candidates?: OnvifRtspCandidate[];
|
||||
message?: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user