mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-12 16:16:42 +03:00
add camera image and stream details on successful test
This commit is contained in:
parent
617d2a239e
commit
be896789c7
@ -21,7 +21,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
import { LuCircleCheck, LuEye, LuEyeOff } from "react-icons/lu";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@ -40,12 +40,15 @@ type Step1NameCameraProps = {
|
|||||||
wizardData: Partial<WizardFormData>;
|
wizardData: Partial<WizardFormData>;
|
||||||
onUpdate: (data: Partial<WizardFormData>) => void;
|
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
onNext?: () => void;
|
||||||
|
canProceed?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Step1NameCamera({
|
export default function Step1NameCamera({
|
||||||
wizardData,
|
wizardData,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
onNext,
|
||||||
}: Step1NameCameraProps) {
|
}: Step1NameCameraProps) {
|
||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation(["views/settings"]);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
@ -147,97 +150,120 @@ export default function Step1NameCamera({
|
|||||||
setIsTesting(true);
|
setIsTesting(true);
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
|
|
||||||
await axios
|
// First get probe data for metadata
|
||||||
.get("ffprobe", {
|
const probePromise = axios.get("ffprobe", {
|
||||||
params: { paths: streamUrl, detailed: true },
|
params: { paths: streamUrl, detailed: true },
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
})
|
});
|
||||||
.then((response) => {
|
|
||||||
if (
|
|
||||||
response.data &&
|
|
||||||
response.data.length > 0 &&
|
|
||||||
response.data[0].return_code === 0
|
|
||||||
) {
|
|
||||||
const probeData = response.data[0];
|
|
||||||
const ffprobeData = probeData.stdout;
|
|
||||||
const streams = ffprobeData.streams || [];
|
|
||||||
|
|
||||||
// Extract video stream info
|
// Then get snapshot for preview
|
||||||
const videoStream = streams.find(
|
const snapshotPromise = axios.get("ffprobe/snapshot", {
|
||||||
(s: FfprobeStream) =>
|
params: { url: streamUrl },
|
||||||
s.codec_type === "video" ||
|
responseType: "blob",
|
||||||
s.codec_name?.includes("h264") ||
|
timeout: 10000,
|
||||||
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"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate resolution
|
try {
|
||||||
const resolution = videoStream
|
// First get probe data for metadata
|
||||||
? `${videoStream.width}x${videoStream.height}`
|
const probeResponse = await probePromise;
|
||||||
: undefined;
|
let probeData = null;
|
||||||
|
if (
|
||||||
|
probeResponse.data &&
|
||||||
|
probeResponse.data.length > 0 &&
|
||||||
|
probeResponse.data[0].return_code === 0
|
||||||
|
) {
|
||||||
|
probeData = probeResponse.data[0];
|
||||||
|
}
|
||||||
|
|
||||||
// Extract FPS from rational (e.g., "15/1" -> 15)
|
// Then get snapshot for preview (only if probe succeeded)
|
||||||
const fps = videoStream?.r_frame_rate
|
let snapshotBlob = null;
|
||||||
? parseFloat(videoStream.r_frame_rate.split("/")[0]) /
|
if (probeData) {
|
||||||
parseFloat(videoStream.r_frame_rate.split("/")[1])
|
try {
|
||||||
: undefined;
|
const snapshotResponse = await snapshotPromise;
|
||||||
|
snapshotBlob = snapshotResponse.data;
|
||||||
const testResult: TestResult = {
|
} catch (snapshotError) {
|
||||||
success: true,
|
// Snapshot is optional, don't fail if it doesn't work
|
||||||
resolution,
|
toast.warning(t("cameraWizard.step1.warnings.noSnapshot"));
|
||||||
videoCodec: videoStream?.codec_name,
|
|
||||||
audioCodec: audioStream?.codec_name,
|
|
||||||
fps: fps && !isNaN(fps) ? fps : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
setTestResult(testResult);
|
|
||||||
toast.success(t("cameraWizard.step1.testSuccess"));
|
|
||||||
|
|
||||||
// Auto-populate stream if successful
|
|
||||||
const streamId = `stream_${Date.now()}`;
|
|
||||||
onUpdate({
|
|
||||||
...data,
|
|
||||||
streams: [
|
|
||||||
{
|
|
||||||
id: streamId,
|
|
||||||
url: streamUrl,
|
|
||||||
roles: ["detect"],
|
|
||||||
resolution: testResult.resolution,
|
|
||||||
testResult,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const error = response.data?.[0]?.stderr || "Unknown error";
|
|
||||||
setTestResult({
|
|
||||||
success: false,
|
|
||||||
error: error,
|
|
||||||
});
|
|
||||||
toast.error(t("cameraWizard.step1.testFailed", { error }));
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
.catch((error) => {
|
|
||||||
const errorMessage =
|
if (probeData) {
|
||||||
error.response?.data?.message ||
|
const ffprobeData = probeData.stdout;
|
||||||
error.response?.data?.detail ||
|
const streams = ffprobeData.streams || [];
|
||||||
"Connection failed";
|
|
||||||
|
// Extract video stream info
|
||||||
|
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"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate resolution
|
||||||
|
const resolution = videoStream
|
||||||
|
? `${videoStream.width}x${videoStream.height}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Extract FPS from rational (e.g., "15/1" -> 15)
|
||||||
|
const fps = videoStream?.r_frame_rate
|
||||||
|
? parseFloat(videoStream.r_frame_rate.split("/")[0]) /
|
||||||
|
parseFloat(videoStream.r_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);
|
||||||
|
toast.success(t("cameraWizard.step1.testSuccess"));
|
||||||
|
} else {
|
||||||
|
const error = probeData?.stderr || "Unknown error";
|
||||||
setTestResult({
|
setTestResult({
|
||||||
success: false,
|
success: false,
|
||||||
error: errorMessage,
|
error: error,
|
||||||
});
|
});
|
||||||
toast.error(
|
toast.error(t("cameraWizard.step1.testFailed", { error }));
|
||||||
t("cameraWizard.step1.testFailed", { error: errorMessage }),
|
}
|
||||||
);
|
} catch (error) {
|
||||||
})
|
const axiosError = error as {
|
||||||
.finally(() => {
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
setIsTesting(false);
|
message?: string;
|
||||||
|
};
|
||||||
|
const errorMessage =
|
||||||
|
axiosError.response?.data?.message ||
|
||||||
|
axiosError.response?.data?.detail ||
|
||||||
|
axiosError.message ||
|
||||||
|
"Connection failed";
|
||||||
|
setTestResult({
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
});
|
});
|
||||||
}, [form, generateStreamUrl, onUpdate, t]);
|
toast.error(t("cameraWizard.step1.testFailed", { error: errorMessage }));
|
||||||
|
} finally {
|
||||||
|
setIsTesting(false);
|
||||||
|
}
|
||||||
|
}, [form, generateStreamUrl, t]);
|
||||||
|
|
||||||
const onSubmit = (data: z.infer<typeof step1FormData>) => {
|
const onSubmit = (data: z.infer<typeof step1FormData>) => {
|
||||||
onUpdate(data);
|
onUpdate(data);
|
||||||
@ -245,104 +271,25 @@ export default function Step1NameCamera({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-sm text-muted-foreground">
|
{!testResult?.success && (
|
||||||
{t("cameraWizard.step1.description")}
|
<>
|
||||||
</div>
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("cameraWizard.step1.description")}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="cameraName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t("cameraWizard.step1.cameraName")}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
className="h-8"
|
|
||||||
placeholder={t("cameraWizard.step1.cameraNamePlaceholder")}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="brandTemplate"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t("cameraWizard.step1.cameraBrand")}</FormLabel>
|
|
||||||
<Select
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger className="h-8">
|
|
||||||
<SelectValue
|
|
||||||
placeholder={t("cameraWizard.step1.selectBrand")}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
{CAMERA_BRANDS.map((brand) => (
|
|
||||||
<SelectItem key={brand.value} value={brand.value}>
|
|
||||||
{brand.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
{field.value &&
|
|
||||||
(() => {
|
|
||||||
const selectedBrand = CAMERA_BRANDS.find(
|
|
||||||
(brand) => brand.value === field.value,
|
|
||||||
);
|
|
||||||
return selectedBrand ? (
|
|
||||||
<FormDescription className="pt-0.5">
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{selectedBrand.exampleUrl}
|
|
||||||
</div>
|
|
||||||
</FormDescription>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{watchedBrand !== "other" && (
|
|
||||||
<>
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="host"
|
name="cameraName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("cameraWizard.step1.host")}</FormLabel>
|
<FormLabel>{t("cameraWizard.step1.cameraName")}</FormLabel>
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
className="h-8"
|
|
||||||
placeholder="192.168.1.100"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="username"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t("cameraWizard.step1.username")}</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="h-8"
|
className="h-8"
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"cameraWizard.step1.usernamePlaceholder",
|
"cameraWizard.step1.cameraNamePlaceholder",
|
||||||
)}
|
)}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
@ -354,109 +301,264 @@ export default function Step1NameCamera({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="password"
|
name="brandTemplate"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("cameraWizard.step1.password")}</FormLabel>
|
<FormLabel>{t("cameraWizard.step1.cameraBrand")}</FormLabel>
|
||||||
<FormControl>
|
<Select
|
||||||
<div className="relative">
|
onValueChange={field.onChange}
|
||||||
<Input
|
defaultValue={field.value}
|
||||||
className="h-8 pr-10"
|
>
|
||||||
type={showPassword ? "text" : "password"}
|
<FormControl>
|
||||||
placeholder={t(
|
<SelectTrigger className="h-8">
|
||||||
"cameraWizard.step1.passwordPlaceholder",
|
<SelectValue
|
||||||
)}
|
placeholder={t("cameraWizard.step1.selectBrand")}
|
||||||
{...field}
|
/>
|
||||||
/>
|
</SelectTrigger>
|
||||||
<Button
|
</FormControl>
|
||||||
type="button"
|
<SelectContent>
|
||||||
variant="ghost"
|
{CAMERA_BRANDS.map((brand) => (
|
||||||
size="sm"
|
<SelectItem key={brand.value} value={brand.value}>
|
||||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
{brand.label}
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
</SelectItem>
|
||||||
>
|
))}
|
||||||
{showPassword ? (
|
</SelectContent>
|
||||||
<LuEyeOff className="h-4 w-4" />
|
</Select>
|
||||||
) : (
|
|
||||||
<LuEye className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
{field.value &&
|
||||||
|
(() => {
|
||||||
|
const selectedBrand = CAMERA_BRANDS.find(
|
||||||
|
(brand) => brand.value === field.value,
|
||||||
|
);
|
||||||
|
return selectedBrand ? (
|
||||||
|
<FormDescription className="pt-0.5">
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{selectedBrand.exampleUrl}
|
||||||
|
</div>
|
||||||
|
</FormDescription>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{watchedBrand == "other" && (
|
{watchedBrand !== "other" && (
|
||||||
<FormField
|
<>
|
||||||
control={form.control}
|
<FormField
|
||||||
name="customUrl"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="host"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>{t("cameraWizard.step1.customUrl")}</FormLabel>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>{t("cameraWizard.step1.host")}</FormLabel>
|
||||||
<Input
|
<FormControl>
|
||||||
className="h-8"
|
<Input
|
||||||
placeholder="rtsp://username:password@host:port/path"
|
className="h-8"
|
||||||
{...field}
|
placeholder="192.168.1.100"
|
||||||
/>
|
{...field}
|
||||||
</FormControl>
|
/>
|
||||||
<FormMessage />
|
</FormControl>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("cameraWizard.step1.username")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="h-8"
|
||||||
|
placeholder={t(
|
||||||
|
"cameraWizard.step1.usernamePlaceholder",
|
||||||
|
)}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("cameraWizard.step1.password")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
className="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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{testResult && (
|
{watchedBrand == "other" && (
|
||||||
<div className="mt-4">
|
<FormField
|
||||||
<div
|
control={form.control}
|
||||||
className={`text-sm font-medium ${testResult.success ? "text-success" : "text-danger"}`}
|
name="customUrl"
|
||||||
>
|
render={({ field }) => (
|
||||||
{testResult.success
|
<FormItem>
|
||||||
? t("cameraWizard.step1.testSuccess")
|
<FormLabel>{t("cameraWizard.step1.customUrl")}</FormLabel>
|
||||||
: t("cameraWizard.step1.testFailed")}
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="h-8"
|
||||||
|
placeholder="rtsp://username:password@host:port/path"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
|
<LuCircleCheck />
|
||||||
|
{t("cameraWizard.step1.testSuccess")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{testResult.snapshot && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<img
|
||||||
|
src={testResult.snapshot}
|
||||||
|
alt="Camera snapshot"
|
||||||
|
className="max-h-[50dvh] max-w-full rounded-lg object-contain"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{testResult.success ? (
|
)}
|
||||||
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
|
|
||||||
{testResult.resolution && (
|
<div className="text-sm font-semibold text-primary-variant">
|
||||||
<div>Resolution: {testResult.resolution}</div>
|
{t("cameraWizard.step1.streamDetails")}
|
||||||
)}
|
</div>
|
||||||
{testResult.videoCodec && (
|
<div className="text-sm">
|
||||||
<div>Video: {testResult.videoCodec}</div>
|
{testResult.resolution && (
|
||||||
)}
|
<div>
|
||||||
{testResult.audioCodec && (
|
<span className="text-secondary-foreground">
|
||||||
<div>Audio: {testResult.audioCodec}</div>
|
{t("cameraWizard.step1.testResultLabels.resolution")}:
|
||||||
)}
|
</span>{" "}
|
||||||
{testResult.fps && <div>FPS: {testResult.fps}</div>}
|
<span className="text-primary">{testResult.resolution}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<div className="mt-2 text-xs text-danger">
|
{testResult.videoCodec && (
|
||||||
{testResult.error}
|
<div>
|
||||||
|
<span className="text-secondary-foreground">
|
||||||
|
{t("cameraWizard.step1.testResultLabels.video")}:
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-primary">{testResult.videoCodec}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{testResult.audioCodec && (
|
||||||
|
<div>
|
||||||
|
<span className="text-secondary-foreground">
|
||||||
|
{t("cameraWizard.step1.testResultLabels.audio")}:
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-primary">{testResult.audioCodec}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{testResult.fps && (
|
||||||
|
<div>
|
||||||
|
<span className="text-secondary-foreground">
|
||||||
|
{t("cameraWizard.step1.testResultLabels.fps")}:
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-primary">{testResult.fps}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 pt-6 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={testConnection}
|
|
||||||
disabled={isTesting || !isTestButtonEnabled}
|
|
||||||
variant="select"
|
|
||||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
|
||||||
>
|
|
||||||
{isTesting && <ActivityIndicator className="size-4" />}
|
|
||||||
{t("cameraWizard.step1.testConnection")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</Form>
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={testResult?.success ? () => setTestResult(null) : onCancel}
|
||||||
|
className="sm:flex-1"
|
||||||
|
>
|
||||||
|
{testResult?.success
|
||||||
|
? t("button.back", { ns: "common" })
|
||||||
|
: t("button.cancel", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
{testResult?.success ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
// Auto-populate stream and proceed to next step
|
||||||
|
const data = form.getValues();
|
||||||
|
const streamUrl = generateStreamUrl(data);
|
||||||
|
|
||||||
|
const streamId = `stream_${Date.now()}`;
|
||||||
|
onUpdate({
|
||||||
|
...data,
|
||||||
|
streams: [
|
||||||
|
{
|
||||||
|
id: streamId,
|
||||||
|
url: streamUrl,
|
||||||
|
roles: ["detect"],
|
||||||
|
resolution: testResult.resolution,
|
||||||
|
testResult: testResult || undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
onNext?.();
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{isTesting && <ActivityIndicator className="size-4" />}
|
||||||
|
{t("cameraWizard.step1.testConnection")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user