Add Camera Wizard improvements (#20876)
CI / AMD64 Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run

* 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:
Josh Hawkins
2025-11-10 15:49:52 -06:00
committed by GitHub
parent c371fc0c87
commit e4eac4ac81
10 changed files with 2683 additions and 1017 deletions
@@ -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>
)}
</>
);
}