mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-19 03:26:41 +03:00
796 lines
26 KiB
TypeScript
796 lines
26 KiB
TypeScript
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { useTranslation } from "react-i18next";
|
|
import { LuRotateCcw, LuInfo } 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?.avg_frame_rate
|
|
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
|
|
parseFloat(videoStream.avg_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,
|
|
};
|
|
|
|
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>
|
|
)}
|
|
|
|
{result?.success && (
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm">
|
|
{t("cameraWizard.step3.ffmpegModule")}
|
|
</span>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-4 w-4 p-0"
|
|
>
|
|
<LuInfo className="size-3" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
|
<div className="space-y-2">
|
|
<div className="font-medium">
|
|
{t("cameraWizard.step3.ffmpegModule")}
|
|
</div>
|
|
<div className="text-muted-foreground">
|
|
{t(
|
|
"cameraWizard.step3.ffmpegModuleDescription",
|
|
)}
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
<Switch
|
|
checked={stream.useFfmpeg || false}
|
|
onCheckedChange={(checked) => {
|
|
onUpdate({
|
|
streams: streams.map((s) =>
|
|
s.id === stream.id
|
|
? { ...s, useFfmpeg: checked }
|
|
: s,
|
|
),
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mb-2 flex flex-col justify-between gap-1 md:flex-row md:items-center">
|
|
<span className="break-all 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;
|
|
}> = [];
|
|
|
|
if (wizardData.brandTemplate === "reolink") {
|
|
const streamUrl = stream.url.toLowerCase();
|
|
if (streamUrl.startsWith("rtsp://")) {
|
|
result.push({
|
|
type: "warning",
|
|
message: t("cameraWizard.step1.errors.brands.reolink-rtsp"),
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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")) {
|
|
if (stream.restream) {
|
|
result.push({
|
|
type: "warning",
|
|
message: t("cameraWizard.step3.issues.restreamingWarning"),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (stream.roles.includes("detect") && stream.resolution) {
|
|
const [width, height] = stream.resolution.split("x").map(Number);
|
|
if (!isNaN(width) && !isNaN(height) && width > 0 && height > 0) {
|
|
const minDimension = Math.min(width, height);
|
|
const maxDimension = Math.max(width, height);
|
|
if (minDimension > 1080) {
|
|
result.push({
|
|
type: "warning",
|
|
message: t("cameraWizard.step3.issues.resolutionHigh", {
|
|
resolution: stream.resolution,
|
|
}),
|
|
});
|
|
} else if (maxDimension < 640) {
|
|
result.push({
|
|
type: "error",
|
|
message: t("cameraWizard.step3.issues.resolutionLow", {
|
|
resolution: stream.resolution,
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Substream Check
|
|
if (
|
|
wizardData.brandTemplate == "dahua" &&
|
|
stream.roles.includes("detect") &&
|
|
stream.url.includes("subtype=1")
|
|
) {
|
|
result.push({
|
|
type: "warning",
|
|
message: t("cameraWizard.step3.issues.dahua.substreamWarning"),
|
|
});
|
|
}
|
|
if (
|
|
wizardData.brandTemplate == "hikvision" &&
|
|
stream.roles.includes("detect") &&
|
|
stream.url.includes("/102")
|
|
) {
|
|
result.push({
|
|
type: "warning",
|
|
message: t("cameraWizard.step3.issues.hikvision.substreamWarning"),
|
|
});
|
|
}
|
|
|
|
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
|
|
const streamUrl = stream.useFfmpeg ? `ffmpeg:${stream.url}` : stream.url;
|
|
axios
|
|
.put(`go2rtc/streams/${streamId}`, null, {
|
|
params: { src: streamUrl },
|
|
})
|
|
.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, stream.useFfmpeg, 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>
|
|
);
|
|
}
|