mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-29 07:09:03 +03:00
Add Camera Wizard (#20461)
CI / ARM 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 / AMD64 Build (push) Waiting to run
CI / ARM 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 / AMD64 Build (push) Waiting to run
* fetch more from ffprobe * add detailed param to ffprobe endpoint * add dots variant to step indicator * add classname * tweak colors for dark mode to match figma * add step 1 form * add helper function for ffmpeg snapshot * add go2rtc stream add and ffprobe snapshot endpoints * add camera image and stream details on successful test * step 1 tweaks * step 2 and i18n * types * step 1 and 2 tweaks * add wizard to camera settings view * add data unit i18n keys * restream tweak * fix type * implement rough idea for step 3 * add api endpoint to delete stream from go2rtc * add main wizard dialog component * extract logic for friendly_name and use in wizard * add i18n and popover for brand url * add camera name to top * consolidate validation logic * prevent dialog from closing when clicking outside * center camera name on mobile * add help/docs link popovers * keep spaces in friendly name * add stream details to overlay like stats in liveplayer * add validation results pane to step 3 * ensure test is invalidated if stream is changed * only display validation results and enable save button if all streams have been tested * tweaks * normalize camera name to lower case and improve hash generation * move wizard to subfolder * tweaks * match look of camera edit form to wizard * move wizard and edit form to its own component * move enabled/disabled switch to management section * clean up * fixes * fix mobile
This commit is contained in:
@@ -0,0 +1,615 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import {
|
||||
WizardFormData,
|
||||
CameraBrand,
|
||||
CAMERA_BRANDS,
|
||||
CAMERA_BRAND_VALUES,
|
||||
TestResult,
|
||||
FfprobeStream,
|
||||
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";
|
||||
|
||||
type Step1NameCameraProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||
onNext: (data?: Partial<WizardFormData>) => void;
|
||||
onCancel: () => void;
|
||||
canProceed?: boolean;
|
||||
};
|
||||
|
||||
export default function Step1NameCamera({
|
||||
wizardData,
|
||||
onUpdate,
|
||||
onNext,
|
||||
onCancel,
|
||||
}: Step1NameCameraProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
|
||||
const existingCameraNames = useMemo(() => {
|
||||
if (!config?.cameras) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(config.cameras);
|
||||
}, [config]);
|
||||
|
||||
const step1FormData = z
|
||||
.object({
|
||||
cameraName: z
|
||||
.string()
|
||||
.min(1, t("cameraWizard.step1.errors.nameRequired"))
|
||||
.max(64, t("cameraWizard.step1.errors.nameLength"))
|
||||
.regex(
|
||||
/^[a-zA-Z0-9\s_-]+$/,
|
||||
t("cameraWizard.step1.errors.invalidCharacters"),
|
||||
)
|
||||
.refine(
|
||||
(value) => !existingCameraNames.includes(value),
|
||||
t("cameraWizard.step1.errors.nameExists"),
|
||||
),
|
||||
host: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
|
||||
customUrl: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// If brand is "other", customUrl is required
|
||||
if (data.brandTemplate === "other") {
|
||||
return data.customUrl && data.customUrl.trim().length > 0;
|
||||
}
|
||||
// If brand is not "other", host is required
|
||||
return data.host && data.host.trim().length > 0;
|
||||
},
|
||||
{
|
||||
message: t("cameraWizard.step1.errors.brandOrCustomUrlRequired"),
|
||||
path: ["customUrl"],
|
||||
},
|
||||
);
|
||||
|
||||
const form = useForm<z.infer<typeof step1FormData>>({
|
||||
resolver: zodResolver(step1FormData),
|
||||
defaultValues: {
|
||||
cameraName: wizardData.cameraName || "",
|
||||
host: wizardData.host || "",
|
||||
username: wizardData.username || "",
|
||||
password: wizardData.password || "",
|
||||
brandTemplate:
|
||||
wizardData.brandTemplate &&
|
||||
CAMERA_BRAND_VALUES.includes(wizardData.brandTemplate as CameraBrand)
|
||||
? (wizardData.brandTemplate as CameraBrand)
|
||||
: "dahua",
|
||||
customUrl: wizardData.customUrl || "",
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const watchedBrand = form.watch("brandTemplate");
|
||||
const watchedHost = form.watch("host");
|
||||
const watchedCustomUrl = form.watch("customUrl");
|
||||
|
||||
const isTestButtonEnabled =
|
||||
watchedBrand === "other"
|
||||
? !!(watchedCustomUrl && watchedCustomUrl.trim())
|
||||
: !!(watchedHost && watchedHost.trim());
|
||||
|
||||
const generateStreamUrl = useCallback(
|
||||
(data: z.infer<typeof step1FormData>): string => {
|
||||
if (data.brandTemplate === "other") {
|
||||
return data.customUrl || "";
|
||||
}
|
||||
|
||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||
if (!brand || !data.host) return "";
|
||||
|
||||
return brand.template
|
||||
.replace("{username}", data.username || "")
|
||||
.replace("{password}", data.password || "")
|
||||
.replace("{host}", data.host);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const testConnection = useCallback(async () => {
|
||||
const data = form.getValues();
|
||||
const streamUrl = generateStreamUrl(data);
|
||||
|
||||
if (!streamUrl) {
|
||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
// First get probe data for metadata
|
||||
const probePromise = axios.get("ffprobe", {
|
||||
params: { paths: streamUrl, detailed: true },
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Then get snapshot for preview
|
||||
const snapshotPromise = axios.get("ffprobe/snapshot", {
|
||||
params: { url: streamUrl },
|
||||
responseType: "blob",
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
try {
|
||||
// First get probe data for metadata
|
||||
const probeResponse = await probePromise;
|
||||
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) {
|
||||
try {
|
||||
const snapshotResponse = await snapshotPromise;
|
||||
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("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;
|
||||
|
||||
// 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({
|
||||
success: false,
|
||||
error: error,
|
||||
});
|
||||
toast.error(t("cameraWizard.commonErrors.testFailed", { error }));
|
||||
}
|
||||
} catch (error) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const errorMessage =
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.detail ||
|
||||
axiosError.message ||
|
||||
"Connection failed";
|
||||
setTestResult({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
});
|
||||
toast.error(
|
||||
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
|
||||
);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
}, [form, generateStreamUrl, t]);
|
||||
|
||||
const onSubmit = (data: z.infer<typeof step1FormData>) => {
|
||||
onUpdate(data);
|
||||
};
|
||||
|
||||
const handleContinue = useCallback(() => {
|
||||
const data = form.getValues();
|
||||
const streamUrl = 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]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!testResult?.success && (
|
||||
<>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("cameraWizard.step1.description")}
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cameraName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<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 &&
|
||||
selectedBrand.value != "other" ? (
|
||||
<FormDescription className="mt-1 pt-0.5 text-xs text-muted-foreground">
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<div className="flex flex-row items-center gap-0.5 text-xs text-muted-foreground hover:text-primary">
|
||||
<LuInfo className="mr-1 size-3" />
|
||||
{t("cameraWizard.step1.brandInformation")}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">
|
||||
{selectedBrand.label}
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("cameraWizard.step1.brandUrlFormat", {
|
||||
exampleUrl: selectedBrand.exampleUrl,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</FormDescription>
|
||||
) : null;
|
||||
})()}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{watchedBrand !== "other" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("cameraWizard.step1.host")}</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>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBrand == "other" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("cameraWizard.step1.customUrl")}</FormLabel>
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={testResult?.success ? () => setTestResult(null) : onCancel}
|
||||
className="sm:flex-1"
|
||||
>
|
||||
{testResult?.success
|
||||
? t("button.back", { ns: "common" })
|
||||
: t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
{testResult?.success ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
{t("button.continue", { ns: "common" })}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
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?.r_frame_rate
|
||||
? parseFloat(videoStream.r_frame_rate.split("/")[0]) /
|
||||
parseFloat(videoStream.r_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 currentIds = wizardData.restreamIds || [];
|
||||
const isSelected = currentIds.includes(streamId);
|
||||
const newIds = isSelected
|
||||
? currentIds.filter((id) => id !== streamId)
|
||||
: [...currentIds, streamId];
|
||||
onUpdate({
|
||||
restreamIds: newIds,
|
||||
});
|
||||
},
|
||||
[wizardData.restreamIds, onUpdate],
|
||||
);
|
||||
|
||||
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">
|
||||
{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">
|
||||
{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="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">
|
||||
{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="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={(wizardData.restreamIds || []).includes(
|
||||
stream.id,
|
||||
)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuRotateCcw } from "react-icons/lu";
|
||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import MSEPlayer from "@/components/player/MsePlayer";
|
||||
import { WizardFormData, StreamConfig, TestResult } from "@/types/cameraWizard";
|
||||
import { PlayerStatsType } from "@/types/live";
|
||||
import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { Card, CardContent } from "../../ui/card";
|
||||
|
||||
type Step3ValidationProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||
onSave: (config: WizardFormData) => void;
|
||||
onBack?: () => void;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export default function Step3Validation({
|
||||
wizardData,
|
||||
onUpdate,
|
||||
onSave,
|
||||
onBack,
|
||||
isLoading = false,
|
||||
}: Step3ValidationProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
||||
const [measuredBandwidth, setMeasuredBandwidth] = useState<
|
||||
Map<string, number>
|
||||
>(new Map());
|
||||
|
||||
const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
|
||||
|
||||
const handleBandwidthUpdate = useCallback(
|
||||
(streamId: string, bandwidth: number) => {
|
||||
setMeasuredBandwidth((prev) => new Map(prev).set(streamId, bandwidth));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Use test results from Step 2, but allow re-validation in Step 3
|
||||
const validationResults = useMemo(() => {
|
||||
const results = new Map<string, TestResult>();
|
||||
streams.forEach((stream) => {
|
||||
if (stream.testResult) {
|
||||
results.set(stream.id, stream.testResult);
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}, [streams]);
|
||||
|
||||
const performStreamValidation = useCallback(
|
||||
async (stream: StreamConfig): Promise<TestResult> => {
|
||||
try {
|
||||
const response = await axios.get("ffprobe", {
|
||||
params: { paths: stream.url, detailed: true },
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
if (response.data?.[0]?.return_code === 0) {
|
||||
const probeData = response.data[0];
|
||||
const streamData = probeData.stdout.streams || [];
|
||||
|
||||
const videoStream = streamData.find(
|
||||
(s: { codec_type?: string; codec_name?: string }) =>
|
||||
s.codec_type === "video" ||
|
||||
s.codec_name?.includes("h264") ||
|
||||
s.codec_name?.includes("h265"),
|
||||
);
|
||||
|
||||
const audioStream = streamData.find(
|
||||
(s: { codec_type?: string; codec_name?: string }) =>
|
||||
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?.r_frame_rate
|
||||
? parseFloat(videoStream.r_frame_rate.split("/")[0]) /
|
||||
parseFloat(videoStream.r_frame_rate.split("/")[1])
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
resolution,
|
||||
videoCodec: videoStream?.codec_name,
|
||||
audioCodec: audioStream?.codec_name,
|
||||
fps: fps && !isNaN(fps) ? fps : undefined,
|
||||
};
|
||||
} else {
|
||||
const error = response.data?.[0]?.stderr || "Unknown error";
|
||||
return { success: false, error };
|
||||
}
|
||||
} 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";
|
||||
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const validateStream = useCallback(
|
||||
async (stream: StreamConfig) => {
|
||||
if (!stream.url.trim()) {
|
||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
setTestingStreams((prev) => new Set(prev).add(stream.id));
|
||||
|
||||
const testResult = await performStreamValidation(stream);
|
||||
|
||||
onUpdate({
|
||||
streams: streams.map((s) =>
|
||||
s.id === stream.id ? { ...s, testResult } : s,
|
||||
),
|
||||
});
|
||||
|
||||
if (testResult.success) {
|
||||
toast.success(
|
||||
t("cameraWizard.step3.streamValidated", {
|
||||
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t("cameraWizard.step3.streamValidationFailed", {
|
||||
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setTestingStreams((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(stream.id);
|
||||
return newSet;
|
||||
});
|
||||
},
|
||||
[streams, onUpdate, t, performStreamValidation],
|
||||
);
|
||||
|
||||
const validateAllStreams = useCallback(async () => {
|
||||
setIsValidating(true);
|
||||
const results = new Map<string, TestResult>();
|
||||
|
||||
// Only test streams that haven't been tested or failed
|
||||
const streamsToTest = streams.filter(
|
||||
(stream) => !stream.testResult || !stream.testResult.success,
|
||||
);
|
||||
|
||||
for (const stream of streamsToTest) {
|
||||
if (!stream.url.trim()) continue;
|
||||
|
||||
const testResult = await performStreamValidation(stream);
|
||||
results.set(stream.id, testResult);
|
||||
}
|
||||
|
||||
// Update wizard data with new test results
|
||||
if (results.size > 0) {
|
||||
const updatedStreams = streams.map((stream) => {
|
||||
const newResult = results.get(stream.id);
|
||||
if (newResult) {
|
||||
return { ...stream, testResult: newResult };
|
||||
}
|
||||
return stream;
|
||||
});
|
||||
|
||||
onUpdate({ streams: updatedStreams });
|
||||
}
|
||||
|
||||
setIsValidating(false);
|
||||
|
||||
if (results.size > 0) {
|
||||
const successfulTests = Array.from(results.values()).filter(
|
||||
(r) => r.success,
|
||||
).length;
|
||||
if (successfulTests === results.size) {
|
||||
toast.success(t("cameraWizard.step3.reconnectionSuccess"));
|
||||
} else {
|
||||
toast.warning(t("cameraWizard.step3.reconnectionPartial"));
|
||||
}
|
||||
}
|
||||
}, [streams, onUpdate, t, performStreamValidation]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!wizardData.cameraName || !wizardData.streams?.length) {
|
||||
toast.error(t("cameraWizard.step3.saveError"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert wizard data to final config format
|
||||
const configData = {
|
||||
cameraName: wizardData.cameraName,
|
||||
host: wizardData.host,
|
||||
username: wizardData.username,
|
||||
password: wizardData.password,
|
||||
brandTemplate: wizardData.brandTemplate,
|
||||
customUrl: wizardData.customUrl,
|
||||
streams: wizardData.streams,
|
||||
restreamIds: wizardData.restreamIds,
|
||||
};
|
||||
|
||||
onSave(configData);
|
||||
}, [wizardData, onSave, t]);
|
||||
|
||||
const canSave = useMemo(() => {
|
||||
return (
|
||||
wizardData.cameraName &&
|
||||
wizardData.streams?.length &&
|
||||
wizardData.streams.some((s) => s.roles.includes("detect")) &&
|
||||
wizardData.streams.every((s) => s.testResult) // All streams must be tested
|
||||
);
|
||||
}, [wizardData]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("cameraWizard.step3.description")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("cameraWizard.step3.validationTitle")}
|
||||
</h3>
|
||||
<Button
|
||||
onClick={validateAllStreams}
|
||||
disabled={isValidating || streams.length === 0}
|
||||
variant="outline"
|
||||
>
|
||||
{isValidating && <ActivityIndicator className="mr-2 size-4" />}
|
||||
{isValidating
|
||||
? t("cameraWizard.step3.connecting")
|
||||
: t("cameraWizard.step3.connectAllStreams")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{streams.map((stream, index) => {
|
||||
const result = validationResults.get(stream.id);
|
||||
return (
|
||||
<Card key={stream.id} className="bg-secondary text-primary">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-end gap-2">
|
||||
<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", {
|
||||
number: index + 1,
|
||||
})}
|
||||
</h4>
|
||||
{stream.roles.map((role) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
key={role}
|
||||
className="mx-1 text-xs"
|
||||
>
|
||||
{role}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{result && result.success && (
|
||||
<div className="mb-2 text-sm text-muted-foreground">
|
||||
{[
|
||||
result.resolution,
|
||||
result.fps
|
||||
? `${result.fps} ${t("cameraWizard.testResultLabels.fps")}`
|
||||
: null,
|
||||
result.videoCodec,
|
||||
result.audioCodec,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{result?.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>
|
||||
)}
|
||||
{result && !result.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>
|
||||
|
||||
{result?.success && (
|
||||
<div className="mb-3">
|
||||
<StreamPreview
|
||||
stream={stream}
|
||||
onBandwidthUpdate={handleBandwidthUpdate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-2 flex flex-col justify-between gap-1 md:flex-row md:items-center">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{stream.url}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (result?.success) {
|
||||
// Disconnect: clear the test result
|
||||
onUpdate({
|
||||
streams: streams.map((s) =>
|
||||
s.id === stream.id
|
||||
? { ...s, testResult: undefined }
|
||||
: s,
|
||||
),
|
||||
});
|
||||
} else {
|
||||
// Test/Connect: perform validation
|
||||
validateStream(stream);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
testingStreams.has(stream.id) || !stream.url.trim()
|
||||
}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{testingStreams.has(stream.id) && (
|
||||
<ActivityIndicator className="mr-2 size-4" />
|
||||
)}
|
||||
{result?.success
|
||||
? t("cameraWizard.step3.disconnectStream")
|
||||
: testingStreams.has(stream.id)
|
||||
? t("cameraWizard.step3.connectingStream")
|
||||
: t("cameraWizard.step3.connectStream")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs">
|
||||
{t("cameraWizard.step3.issues.title")}
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<StreamIssues
|
||||
stream={stream}
|
||||
measuredBandwidth={measuredBandwidth}
|
||||
wizardData={wizardData}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && !result.success && (
|
||||
<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">{result.error}</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave || isLoading}
|
||||
className="sm:flex-1"
|
||||
variant="select"
|
||||
>
|
||||
{isLoading && <ActivityIndicator className="mr-2 size-4" />}
|
||||
{isLoading
|
||||
? t("button.saving", { ns: "common" })
|
||||
: t("cameraWizard.step3.saveAndApply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StreamIssuesProps = {
|
||||
stream: StreamConfig;
|
||||
measuredBandwidth: Map<string, number>;
|
||||
wizardData: Partial<WizardFormData>;
|
||||
};
|
||||
|
||||
function StreamIssues({
|
||||
stream,
|
||||
measuredBandwidth,
|
||||
wizardData,
|
||||
}: StreamIssuesProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
const issues = useMemo(() => {
|
||||
const result: Array<{
|
||||
type: "good" | "warning" | "error";
|
||||
message: string;
|
||||
}> = [];
|
||||
|
||||
// Video codec check
|
||||
if (stream.testResult?.videoCodec) {
|
||||
const videoCodec = stream.testResult.videoCodec.toLowerCase();
|
||||
if (["h264", "h265", "hevc"].includes(videoCodec)) {
|
||||
result.push({
|
||||
type: "good",
|
||||
message: t("cameraWizard.step3.issues.videoCodecGood", {
|
||||
codec: stream.testResult.videoCodec,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Audio codec check
|
||||
if (stream.roles.includes("record")) {
|
||||
if (stream.testResult?.audioCodec) {
|
||||
const audioCodec = stream.testResult.audioCodec.toLowerCase();
|
||||
if (audioCodec === "aac") {
|
||||
result.push({
|
||||
type: "good",
|
||||
message: t("cameraWizard.step3.issues.audioCodecGood", {
|
||||
codec: stream.testResult.audioCodec,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step3.issues.audioCodecRecordError"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.noAudioWarning"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Audio detection check
|
||||
if (stream.roles.includes("audio")) {
|
||||
if (!stream.testResult?.audioCodec) {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step3.issues.audioCodecRequired"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Restreaming check
|
||||
if (stream.roles.includes("record")) {
|
||||
const restreamIds = wizardData.restreamIds || [];
|
||||
if (restreamIds.includes(stream.id)) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.restreamingWarning"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [stream, wizardData, t]);
|
||||
|
||||
if (issues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<BandwidthDisplay
|
||||
streamId={stream.id}
|
||||
measuredBandwidth={measuredBandwidth}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
{issues.map((issue, index) => (
|
||||
<div key={index} className="flex items-center gap-2 text-sm">
|
||||
{issue.type === "good" && (
|
||||
<FaCircleCheck className="size-4 flex-shrink-0 text-success" />
|
||||
)}
|
||||
{issue.type === "warning" && (
|
||||
<FaTriangleExclamation className="size-4 flex-shrink-0 text-yellow-500" />
|
||||
)}
|
||||
{issue.type === "error" && (
|
||||
<LuX className="size-4 flex-shrink-0 text-danger" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
issue.type === "good"
|
||||
? "text-success"
|
||||
: issue.type === "warning"
|
||||
? "text-yellow-500"
|
||||
: "text-danger"
|
||||
}
|
||||
>
|
||||
{issue.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type BandwidthDisplayProps = {
|
||||
streamId: string;
|
||||
measuredBandwidth: Map<string, number>;
|
||||
};
|
||||
|
||||
function BandwidthDisplay({
|
||||
streamId,
|
||||
measuredBandwidth,
|
||||
}: BandwidthDisplayProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const streamBandwidth = measuredBandwidth.get(streamId);
|
||||
|
||||
if (!streamBandwidth) return null;
|
||||
|
||||
const perHour = streamBandwidth * 3600; // kB/hour
|
||||
const perHourDisplay =
|
||||
perHour >= 1000000
|
||||
? `${(perHour / 1000000).toFixed(1)} ${t("unit.data.gbph", { ns: "common" })}`
|
||||
: perHour >= 1000
|
||||
? `${(perHour / 1000).toFixed(1)} ${t("unit.data.mbph", { ns: "common" })}`
|
||||
: `${perHour.toFixed(0)} ${t("unit.data.kbph", { ns: "common" })}`;
|
||||
|
||||
return (
|
||||
<div className="mb-2 text-sm">
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{t("cameraWizard.step3.estimatedBandwidth")}:
|
||||
</span>{" "}
|
||||
<span className="text-secondary-foreground">
|
||||
{streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })}
|
||||
</span>
|
||||
<span className="ml-2 text-muted-foreground">({perHourDisplay})</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StreamPreviewProps = {
|
||||
stream: StreamConfig;
|
||||
onBandwidthUpdate?: (streamId: string, bandwidth: number) => void;
|
||||
};
|
||||
|
||||
// live stream preview using MSEPlayer with temp go2rtc streams
|
||||
function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [streamId, setStreamId] = useState(`wizard_${stream.id}_${Date.now()}`);
|
||||
const [registered, setRegistered] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const handleStats = useCallback(
|
||||
(stats: PlayerStatsType) => {
|
||||
if (stats.bandwidth > 0) {
|
||||
onBandwidthUpdate?.(stream.id, stats.bandwidth);
|
||||
}
|
||||
},
|
||||
[stream.id, onBandwidthUpdate],
|
||||
);
|
||||
|
||||
const handleReload = useCallback(async () => {
|
||||
// Clean up old stream first
|
||||
if (streamId) {
|
||||
axios.delete(`go2rtc/streams/${streamId}`).catch(() => {
|
||||
// do nothing on cleanup errors - go2rtc won't consume the streams
|
||||
});
|
||||
}
|
||||
|
||||
// Reset state and create new stream ID
|
||||
setError(false);
|
||||
setRegistered(false);
|
||||
setStreamId(`wizard_${stream.id}_${Date.now()}`);
|
||||
}, [stream.id, streamId]);
|
||||
|
||||
useEffect(() => {
|
||||
// Register stream with go2rtc
|
||||
axios
|
||||
.put(`go2rtc/streams/${streamId}`, null, {
|
||||
params: { src: stream.url },
|
||||
})
|
||||
.then(() => {
|
||||
// Add small delay to allow go2rtc api to run and initialize the stream
|
||||
setTimeout(() => {
|
||||
setRegistered(true);
|
||||
}, 500);
|
||||
})
|
||||
.catch(() => {
|
||||
setError(true);
|
||||
});
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
axios.delete(`go2rtc/streams/${streamId}`).catch(() => {
|
||||
// do nothing on cleanup errors - go2rtc won't consume the streams
|
||||
});
|
||||
};
|
||||
}, [stream.url, streamId]);
|
||||
|
||||
const resolution = stream.testResult?.resolution;
|
||||
let aspectRatio = "16/9";
|
||||
if (resolution) {
|
||||
const [width, height] = resolution.split("x").map(Number);
|
||||
if (width && height) {
|
||||
aspectRatio = `${width}/${height}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="flex max-h-[30dvh] flex-col items-center justify-center gap-2 rounded-lg bg-secondary p-4 md:max-h-[20dvh]"
|
||||
style={{ aspectRatio }}
|
||||
>
|
||||
<span className="text-sm text-danger">
|
||||
{t("cameraWizard.step3.streamUnavailable")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReload}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<LuRotateCcw className="size-4" />
|
||||
{t("cameraWizard.step3.reload")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!registered) {
|
||||
return (
|
||||
<div
|
||||
className="flex max-h-[30dvh] items-center justify-center rounded-lg bg-secondary md:max-h-[20dvh]"
|
||||
style={{ aspectRatio }}
|
||||
>
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span className="ml-2 text-sm">
|
||||
{t("cameraWizard.step3.connecting")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative max-h-[30dvh] md:max-h-[20dvh]"
|
||||
style={{ aspectRatio }}
|
||||
>
|
||||
<MSEPlayer
|
||||
camera={streamId}
|
||||
playbackEnabled={true}
|
||||
className="max-h-[30dvh] rounded-lg md:max-h-[20dvh]"
|
||||
getStats={true}
|
||||
setStats={handleStats}
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user