mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-07 05:54:10 +03:00
refactor to add probe and snapshot as step 2
This commit is contained in:
parent
a05c6bd6fe
commit
5c41107fcd
@ -154,6 +154,7 @@
|
|||||||
"description": "Follow the steps below to add a new camera to your Frigate installation.",
|
"description": "Follow the steps below to add a new camera to your Frigate installation.",
|
||||||
"steps": {
|
"steps": {
|
||||||
"nameAndConnection": "Name & Connection",
|
"nameAndConnection": "Name & Connection",
|
||||||
|
"probeOrSnapshot": "Probe or Snapshot",
|
||||||
"streamConfiguration": "Stream Configuration",
|
"streamConfiguration": "Stream Configuration",
|
||||||
"validationAndTesting": "Validation & Testing"
|
"validationAndTesting": "Validation & Testing"
|
||||||
},
|
},
|
||||||
@ -240,6 +241,43 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"step2": {
|
"step2": {
|
||||||
|
"description": "Probe the camera for available streams or configure manual settings based on your selected detection method.",
|
||||||
|
"testSuccess": "Connection test successful!",
|
||||||
|
"testFailed": "Connection test failed. Please check your input and try again.",
|
||||||
|
"streamDetails": "Stream Details",
|
||||||
|
"probing": "Probing camera...",
|
||||||
|
"retry": "Retry",
|
||||||
|
"testing": {
|
||||||
|
"probingMetadata": "Probing camera metadata...",
|
||||||
|
"fetchingSnapshot": "Fetching camera snapshot..."
|
||||||
|
},
|
||||||
|
"probeFailed": "Failed to probe camera: {{error}}",
|
||||||
|
"probingDevice": "Probing device...",
|
||||||
|
"probeSuccessful": "Probe successful",
|
||||||
|
"probeError": "Probe Error",
|
||||||
|
"probeNoSuccess": "Probe unsuccessful",
|
||||||
|
"deviceInfo": "Device Information",
|
||||||
|
"manufacturer": "Manufacturer",
|
||||||
|
"model": "Model",
|
||||||
|
"firmware": "Firmware",
|
||||||
|
"profiles": "Profiles",
|
||||||
|
"ptzSupport": "PTZ Support",
|
||||||
|
"autotrackingSupport": "Autotracking Support",
|
||||||
|
"presets": "Presets",
|
||||||
|
"rtspCandidates": "RTSP Candidates",
|
||||||
|
"candidateStreamTitle": "Candidate {{number}}",
|
||||||
|
"useCandidate": "Use",
|
||||||
|
"uriCopy": "Copy",
|
||||||
|
"uriCopied": "URI copied to clipboard",
|
||||||
|
"testConnection": "Test Connection",
|
||||||
|
"toggleUriView": "Click to toggle full URI view",
|
||||||
|
"connected": "Connected",
|
||||||
|
"notConnected": "Not Connected",
|
||||||
|
"errors": {
|
||||||
|
"hostRequired": "Host/IP address is required"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"step3": {
|
||||||
"description": "Configure stream roles and add additional streams for your camera.",
|
"description": "Configure stream roles and add additional streams for your camera.",
|
||||||
"streamsTitle": "Camera Streams",
|
"streamsTitle": "Camera Streams",
|
||||||
"addStream": "Add Stream",
|
"addStream": "Add Stream",
|
||||||
@ -278,7 +316,7 @@
|
|||||||
"description": "Use go2rtc restreaming to reduce connections to your camera."
|
"description": "Use go2rtc restreaming to reduce connections to your camera."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"step3": {
|
"step4": {
|
||||||
"description": "Final validation and analysis before saving your new camera. Connect each stream before saving.",
|
"description": "Final validation and analysis before saving your new camera. Connect each stream before saving.",
|
||||||
"validationTitle": "Stream Validation",
|
"validationTitle": "Stream Validation",
|
||||||
"connectAllStreams": "Connect All Streams",
|
"connectAllStreams": "Connect All Streams",
|
||||||
@ -314,6 +352,9 @@
|
|||||||
"audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.",
|
"audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.",
|
||||||
"audioCodecRequired": "An audio stream is required to support audio detection.",
|
"audioCodecRequired": "An audio stream is required to support audio detection.",
|
||||||
"restreamingWarning": "Reducing connections to the camera for the record stream may increase CPU usage slightly.",
|
"restreamingWarning": "Reducing connections to the camera for the record stream may increase CPU usage slightly.",
|
||||||
|
"brands": {
|
||||||
|
"reolink-rtsp": "Reolink RTSP is not recommended. Enable HTTP in the camera's firmware settings and restart the wizard."
|
||||||
|
},
|
||||||
"dahua": {
|
"dahua": {
|
||||||
"substreamWarning": "Substream 1 is locked to a low resolution. Many Dahua / Amcrest / EmpireTech cameras support additional substreams that need to be enabled in the camera's settings. It is recommended to check and utilize those streams if available."
|
"substreamWarning": "Substream 1 is locked to a low resolution. Many Dahua / Amcrest / EmpireTech cameras support additional substreams that need to be enabled in the camera's settings. It is recommended to check and utilize those streams if available."
|
||||||
},
|
},
|
||||||
|
|||||||
@ -12,8 +12,9 @@ import { toast } from "sonner";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import Step1NameCamera from "@/components/settings/wizard/Step1NameCamera";
|
import Step1NameCamera from "@/components/settings/wizard/Step1NameCamera";
|
||||||
import Step2StreamConfig from "@/components/settings/wizard/Step2StreamConfig";
|
import Step2ProbeOrSnapshot from "@/components/settings/wizard/Step2ProbeOrSnapshot";
|
||||||
import Step3Validation from "@/components/settings/wizard/Step3Validation";
|
import Step3StreamConfig from "@/components/settings/wizard/Step3StreamConfig";
|
||||||
|
import Step4Validation from "@/components/settings/wizard/Step4Validation";
|
||||||
import type {
|
import type {
|
||||||
WizardFormData,
|
WizardFormData,
|
||||||
CameraConfigData,
|
CameraConfigData,
|
||||||
@ -57,6 +58,7 @@ const wizardReducer = (
|
|||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
"cameraWizard.steps.nameAndConnection",
|
"cameraWizard.steps.nameAndConnection",
|
||||||
|
"cameraWizard.steps.probeOrSnapshot",
|
||||||
"cameraWizard.steps.streamConfiguration",
|
"cameraWizard.steps.streamConfiguration",
|
||||||
"cameraWizard.steps.validationAndTesting",
|
"cameraWizard.steps.validationAndTesting",
|
||||||
];
|
];
|
||||||
@ -100,20 +102,20 @@ export default function CameraWizardDialog({
|
|||||||
const canProceedToNext = useCallback((): boolean => {
|
const canProceedToNext = useCallback((): boolean => {
|
||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
case 0:
|
case 0:
|
||||||
// Can proceed if camera name is set and at least one stream exists
|
// Step 1: Can proceed if camera name is set
|
||||||
return !!(
|
return !!state.wizardData.cameraName;
|
||||||
state.wizardData.cameraName &&
|
|
||||||
(state.wizardData.streams?.length ?? 0) > 0
|
|
||||||
);
|
|
||||||
case 1:
|
case 1:
|
||||||
// Can proceed if at least one stream has 'detect' role
|
// Step 2: Can proceed if at least one stream exists (from probe or manual test)
|
||||||
|
return (state.wizardData.streams?.length ?? 0) > 0;
|
||||||
|
case 2:
|
||||||
|
// Step 3: Can proceed if at least one stream has 'detect' role
|
||||||
return !!(
|
return !!(
|
||||||
state.wizardData.streams?.some((stream) =>
|
state.wizardData.streams?.some((stream) =>
|
||||||
stream.roles.includes("detect"),
|
stream.roles.includes("detect"),
|
||||||
) ?? false
|
) ?? false
|
||||||
);
|
);
|
||||||
case 2:
|
case 3:
|
||||||
// Always can proceed from final step (save will be handled there)
|
// Step 4: Always can proceed from final step (save will be handled there)
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
@ -385,7 +387,16 @@ export default function CameraWizardDialog({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<Step2StreamConfig
|
<Step2ProbeOrSnapshot
|
||||||
|
wizardData={state.wizardData}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onNext={handleNext}
|
||||||
|
onBack={handleBack}
|
||||||
|
probeMode={state.wizardData.probeMode ?? true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<Step3StreamConfig
|
||||||
wizardData={state.wizardData}
|
wizardData={state.wizardData}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
@ -393,8 +404,8 @@ export default function CameraWizardDialog({
|
|||||||
canProceed={canProceedToNext()}
|
canProceed={canProceedToNext()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentStep === 2 && (
|
{currentStep === 3 && (
|
||||||
<Step3Validation
|
<Step4Validation
|
||||||
wizardData={state.wizardData}
|
wizardData={state.wizardData}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
|
|||||||
@ -49,7 +49,7 @@ export default function OnvifProbeResults({
|
|||||||
const handleCopyUri = (uri: string) => {
|
const handleCopyUri = (uri: string) => {
|
||||||
navigator.clipboard.writeText(uri);
|
navigator.clipboard.writeText(uri);
|
||||||
setCopiedUri(uri);
|
setCopiedUri(uri);
|
||||||
toast.success(t("cameraWizard.step1.uriCopied"));
|
toast.success(t("cameraWizard.step2.uriCopied"));
|
||||||
setTimeout(() => setCopiedUri(null), 2000);
|
setTimeout(() => setCopiedUri(null), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -58,7 +58,7 @@ export default function OnvifProbeResults({
|
|||||||
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||||
<ActivityIndicator className="size-6" />
|
<ActivityIndicator className="size-6" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("cameraWizard.step1.probingDevice")}
|
{t("cameraWizard.step2.probingDevice")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -69,7 +69,7 @@ export default function OnvifProbeResults({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<CiCircleAlert className="size-5" />
|
<CiCircleAlert className="size-5" />
|
||||||
<AlertTitle>{t("cameraWizard.step1.probeError")}</AlertTitle>
|
<AlertTitle>{t("cameraWizard.step2.probeError")}</AlertTitle>
|
||||||
{error && <AlertDescription>{error}</AlertDescription>}
|
{error && <AlertDescription>{error}</AlertDescription>}
|
||||||
</Alert>
|
</Alert>
|
||||||
<Button onClick={onRetry} variant="outline" className="w-full">
|
<Button onClick={onRetry} variant="outline" className="w-full">
|
||||||
@ -84,7 +84,7 @@ export default function OnvifProbeResults({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<CiCircleAlert className="size-5" />
|
<CiCircleAlert className="size-5" />
|
||||||
<AlertTitle>{t("cameraWizard.step1.probeNoSuccess")}</AlertTitle>
|
<AlertTitle>{t("cameraWizard.step2.probeNoSuccess")}</AlertTitle>
|
||||||
{probeResult?.message && (
|
{probeResult?.message && (
|
||||||
<AlertDescription>{probeResult.message}</AlertDescription>
|
<AlertDescription>{probeResult.message}</AlertDescription>
|
||||||
)}
|
)}
|
||||||
@ -104,18 +104,18 @@ export default function OnvifProbeResults({
|
|||||||
{probeResult?.success && (
|
{probeResult?.success && (
|
||||||
<div className="mb-3 flex flex-row items-center gap-2 text-sm text-success">
|
<div className="mb-3 flex flex-row items-center gap-2 text-sm text-success">
|
||||||
<FaCircleCheck className="size-4" />
|
<FaCircleCheck className="size-4" />
|
||||||
<span>{t("cameraWizard.step1.probeSuccessful")}</span>
|
<span>{t("cameraWizard.step2.probeSuccessful")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Card>
|
<Card>
|
||||||
<CardTitle className="border-b p-4 text-sm">
|
<CardTitle className="border-b p-4 text-sm">
|
||||||
{t("cameraWizard.step1.deviceInfo")}
|
{t("cameraWizard.step2.deviceInfo")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardContent className="space-y-2 p-4 text-sm">
|
<CardContent className="space-y-2 p-4 text-sm">
|
||||||
{probeResult.manufacturer && (
|
{probeResult.manufacturer && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{t("cameraWizard.step1.manufacturer")}:
|
{t("cameraWizard.step2.manufacturer")}:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span className="text-primary-variant">
|
<span className="text-primary-variant">
|
||||||
{probeResult.manufacturer}
|
{probeResult.manufacturer}
|
||||||
@ -125,7 +125,7 @@ export default function OnvifProbeResults({
|
|||||||
{probeResult.model && (
|
{probeResult.model && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{t("cameraWizard.step1.model")}:
|
{t("cameraWizard.step2.model")}:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span className="text-primary-variant">{probeResult.model}</span>
|
<span className="text-primary-variant">{probeResult.model}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -133,7 +133,7 @@ export default function OnvifProbeResults({
|
|||||||
{probeResult.firmware_version && (
|
{probeResult.firmware_version && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{t("cameraWizard.step1.firmware")}:
|
{t("cameraWizard.step2.firmware")}:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span className="text-primary-variant">
|
<span className="text-primary-variant">
|
||||||
{probeResult.firmware_version}
|
{probeResult.firmware_version}
|
||||||
@ -143,7 +143,7 @@ export default function OnvifProbeResults({
|
|||||||
{probeResult.profiles_count !== undefined && (
|
{probeResult.profiles_count !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{t("cameraWizard.step1.profiles")}:
|
{t("cameraWizard.step2.profiles")}:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span className="text-primary-variant">
|
<span className="text-primary-variant">
|
||||||
{probeResult.profiles_count}
|
{probeResult.profiles_count}
|
||||||
@ -153,7 +153,7 @@ export default function OnvifProbeResults({
|
|||||||
{probeResult.ptz_supported !== undefined && (
|
{probeResult.ptz_supported !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{t("cameraWizard.step1.ptzSupport")}:
|
{t("cameraWizard.step2.ptzSupport")}:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span className="text-primary-variant">
|
<span className="text-primary-variant">
|
||||||
{probeResult.ptz_supported
|
{probeResult.ptz_supported
|
||||||
@ -165,7 +165,7 @@ export default function OnvifProbeResults({
|
|||||||
{probeResult.ptz_supported && probeResult.autotrack_supported && (
|
{probeResult.ptz_supported && probeResult.autotrack_supported && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{t("cameraWizard.step1.autotrackingSupport")}:
|
{t("cameraWizard.step2.autotrackingSupport")}:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span className="text-primary-variant">
|
<span className="text-primary-variant">
|
||||||
{t("yes", { ns: "common" })}
|
{t("yes", { ns: "common" })}
|
||||||
@ -176,7 +176,7 @@ export default function OnvifProbeResults({
|
|||||||
probeResult.presets_count !== undefined && (
|
probeResult.presets_count !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{t("cameraWizard.step1.presets")}:
|
{t("cameraWizard.step2.presets")}:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span className="text-primary-variant">
|
<span className="text-primary-variant">
|
||||||
{probeResult.presets_count}
|
{probeResult.presets_count}
|
||||||
@ -188,7 +188,7 @@ export default function OnvifProbeResults({
|
|||||||
|
|
||||||
{rtspCandidates.length > 0 && (
|
{rtspCandidates.length > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm">{t("cameraWizard.step1.rtspCandidates")}</h3>
|
<h3 className="text-sm">{t("cameraWizard.step2.rtspCandidates")}</h3>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{rtspCandidates.map((candidate, idx) => {
|
{rtspCandidates.map((candidate, idx) => {
|
||||||
@ -264,7 +264,7 @@ function CandidateItem({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium">
|
<h4 className="font-medium">
|
||||||
{t("cameraWizard.step1.candidateStreamTitle", {
|
{t("cameraWizard.step2.candidateStreamTitle", {
|
||||||
number: (index ?? 0) + 1,
|
number: (index ?? 0) + 1,
|
||||||
})}
|
})}
|
||||||
</h4>
|
</h4>
|
||||||
@ -311,7 +311,7 @@ function CandidateItem({
|
|||||||
<p
|
<p
|
||||||
className="flex-1 cursor-pointer break-all text-sm text-primary-variant hover:underline"
|
className="flex-1 cursor-pointer break-all text-sm text-primary-variant hover:underline"
|
||||||
onClick={() => setShowFull((s) => !s)}
|
onClick={() => setShowFull((s) => !s)}
|
||||||
title={t("cameraWizard.step1.toggleUriView")}
|
title={t("cameraWizard.step2.toggleUriView")}
|
||||||
>
|
>
|
||||||
{showFull ? candidate.uri : maskUri(candidate.uri)}
|
{showFull ? candidate.uri : maskUri(candidate.uri)}
|
||||||
</p>
|
</p>
|
||||||
@ -322,7 +322,7 @@ function CandidateItem({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={onCopy}
|
onClick={onCopy}
|
||||||
className="mr-4 size-8 p-0"
|
className="mr-4 size-8 p-0"
|
||||||
title={t("cameraWizard.step1.uriCopy")}
|
title={t("cameraWizard.step2.uriCopy")}
|
||||||
>
|
>
|
||||||
{copiedUri === candidate.uri ? (
|
{copiedUri === candidate.uri ? (
|
||||||
<FaCheck className="size-3" />
|
<FaCheck className="size-3" />
|
||||||
@ -340,7 +340,7 @@ function CandidateItem({
|
|||||||
{isTesting ? (
|
{isTesting ? (
|
||||||
<ActivityIndicator className="size-3" />
|
<ActivityIndicator className="size-3" />
|
||||||
) : (
|
) : (
|
||||||
t("cameraWizard.step1.testConnection")
|
t("cameraWizard.step2.testConnection")
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -353,7 +353,7 @@ function CandidateItem({
|
|||||||
variant="select"
|
variant="select"
|
||||||
className="h-8 px-3 text-sm"
|
className="h-8 px-3 text-sm"
|
||||||
>
|
>
|
||||||
{t("cameraWizard.step1.useCandidate")}
|
{t("cameraWizard.step2.useCandidate")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -61,7 +61,7 @@ export default function ProbeDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-3xl">
|
<DialogContent className="max-h-[90%] max-w-3xl overflow-y-auto xl:max-h-[80%]">
|
||||||
<StepIndicator
|
<StepIndicator
|
||||||
steps={STEPS}
|
steps={STEPS}
|
||||||
currentStep={0}
|
currentStep={0}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
640
web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx
Normal file
640
web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx
Normal file
@ -0,0 +1,640 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import axios from "axios";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type {
|
||||||
|
WizardFormData,
|
||||||
|
TestResult,
|
||||||
|
StreamConfig,
|
||||||
|
StreamRole,
|
||||||
|
OnvifProbeResponse,
|
||||||
|
CandidateTestMap,
|
||||||
|
FfprobeStream,
|
||||||
|
FfprobeData,
|
||||||
|
FfprobeResponse,
|
||||||
|
} from "@/types/cameraWizard";
|
||||||
|
import { FaCircleCheck } from "react-icons/fa6";
|
||||||
|
import { Card, CardContent, CardTitle } from "../../ui/card";
|
||||||
|
import ProbeDialog from "./ProbeDialog";
|
||||||
|
import { CAMERA_BRANDS } from "@/types/cameraWizard";
|
||||||
|
import { detectReolinkCamera } from "@/utils/cameraUtil";
|
||||||
|
|
||||||
|
type Step2ProbeOrSnapshotProps = {
|
||||||
|
wizardData: Partial<WizardFormData>;
|
||||||
|
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||||
|
onNext: (data?: Partial<WizardFormData>) => void;
|
||||||
|
onBack: () => void;
|
||||||
|
probeMode: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Step2ProbeOrSnapshot({
|
||||||
|
wizardData,
|
||||||
|
onUpdate,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
probeMode,
|
||||||
|
}: Step2ProbeOrSnapshotProps) {
|
||||||
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
|
const [testStatus, setTestStatus] = useState<string>("");
|
||||||
|
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||||
|
const [isProbing, setIsProbing] = useState(false);
|
||||||
|
const [probeError, setProbeError] = useState<string | null>(null);
|
||||||
|
const [probeResult, setProbeResult] = useState<OnvifProbeResponse | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [probeDialogOpen, setProbeDialogOpen] = useState(false);
|
||||||
|
const [selectedCandidateUris, setSelectedCandidateUris] = useState<string[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [candidateTests, setCandidateTests] = useState<CandidateTestMap>(
|
||||||
|
{} as CandidateTestMap,
|
||||||
|
);
|
||||||
|
const [testingCandidates, setTestingCandidates] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({} as Record<string, boolean>);
|
||||||
|
|
||||||
|
const handleSelectCandidate = useCallback((uri: string) => {
|
||||||
|
setSelectedCandidateUris((s) => {
|
||||||
|
if (s.includes(uri)) {
|
||||||
|
return s.filter((u) => u !== uri);
|
||||||
|
}
|
||||||
|
return [...s, uri];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const probeUri = useCallback(
|
||||||
|
async (
|
||||||
|
uri: string,
|
||||||
|
fetchSnapshot = false,
|
||||||
|
setStatus?: (s: string) => void,
|
||||||
|
): Promise<TestResult> => {
|
||||||
|
try {
|
||||||
|
const probeResponse = await axios.get("ffprobe", {
|
||||||
|
params: { paths: uri, detailed: true },
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
let probeData: FfprobeResponse | null = null;
|
||||||
|
if (
|
||||||
|
probeResponse.data &&
|
||||||
|
probeResponse.data.length > 0 &&
|
||||||
|
probeResponse.data[0].return_code === 0
|
||||||
|
) {
|
||||||
|
probeData = probeResponse.data[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!probeData) {
|
||||||
|
const error =
|
||||||
|
Array.isArray(probeResponse.data?.[0]?.stderr) &&
|
||||||
|
probeResponse.data[0].stderr.length > 0
|
||||||
|
? probeResponse.data[0].stderr.join("\n")
|
||||||
|
: "Unable to probe stream";
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
let ffprobeData: FfprobeData;
|
||||||
|
if (typeof probeData.stdout === "string") {
|
||||||
|
try {
|
||||||
|
ffprobeData = JSON.parse(probeData.stdout as string) as FfprobeData;
|
||||||
|
} catch {
|
||||||
|
ffprobeData = { streams: [] };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ffprobeData = probeData.stdout as FfprobeData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const streams = ffprobeData.streams || [];
|
||||||
|
|
||||||
|
const videoStream = streams.find(
|
||||||
|
(s: FfprobeStream) =>
|
||||||
|
s.codec_type === "video" ||
|
||||||
|
s.codec_name?.includes("h264") ||
|
||||||
|
s.codec_name?.includes("hevc"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const audioStream = streams.find(
|
||||||
|
(s: FfprobeStream) =>
|
||||||
|
s.codec_type === "audio" ||
|
||||||
|
s.codec_name?.includes("aac") ||
|
||||||
|
s.codec_name?.includes("mp3") ||
|
||||||
|
s.codec_name?.includes("pcm_mulaw") ||
|
||||||
|
s.codec_name?.includes("pcm_alaw"),
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
let snapshotBase64: string | undefined = undefined;
|
||||||
|
if (fetchSnapshot) {
|
||||||
|
if (setStatus) {
|
||||||
|
setStatus(t("cameraWizard.step2.testing.fetchingSnapshot"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const snapshotResponse = await axios.get("ffprobe/snapshot", {
|
||||||
|
params: { url: uri },
|
||||||
|
responseType: "blob",
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
const snapshotBlob = snapshotResponse.data;
|
||||||
|
snapshotBase64 = await new Promise<string>((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.readAsDataURL(snapshotBlob);
|
||||||
|
});
|
||||||
|
} catch (snapshotError) {
|
||||||
|
snapshotBase64 = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamTestResult: TestResult = {
|
||||||
|
success: true,
|
||||||
|
snapshot: snapshotBase64,
|
||||||
|
resolution,
|
||||||
|
videoCodec: videoStream?.codec_name,
|
||||||
|
audioCodec: audioStream?.codec_name,
|
||||||
|
fps: fps && !isNaN(fps) ? fps : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return streamTestResult;
|
||||||
|
} catch (err) {
|
||||||
|
const axiosError = err as {
|
||||||
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
const errorMessage =
|
||||||
|
axiosError.response?.data?.message ||
|
||||||
|
axiosError.response?.data?.detail ||
|
||||||
|
axiosError.message ||
|
||||||
|
"Connection failed";
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const probeCamera = useCallback(async () => {
|
||||||
|
if (!wizardData.host) {
|
||||||
|
toast.error(t("cameraWizard.step2.errors.hostRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProbing(true);
|
||||||
|
setProbeError(null);
|
||||||
|
setProbeResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get("/onvif/probe", {
|
||||||
|
params: {
|
||||||
|
host: wizardData.host,
|
||||||
|
port: wizardData.onvifPort ?? 80,
|
||||||
|
username: wizardData.username || "",
|
||||||
|
password: wizardData.password || "",
|
||||||
|
test: false,
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.success) {
|
||||||
|
setProbeResult(response.data);
|
||||||
|
setProbeDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
setProbeError(response.data?.message || "Probe failed");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const axiosError = error as {
|
||||||
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
const errorMessage =
|
||||||
|
axiosError.response?.data?.message ||
|
||||||
|
axiosError.response?.data?.detail ||
|
||||||
|
axiosError.message ||
|
||||||
|
"Failed to probe camera";
|
||||||
|
setProbeError(errorMessage);
|
||||||
|
toast.error(t("cameraWizard.step2.probeFailed", { error: errorMessage }));
|
||||||
|
} finally {
|
||||||
|
setIsProbing(false);
|
||||||
|
}
|
||||||
|
}, [wizardData, t]);
|
||||||
|
|
||||||
|
const testAllSelectedCandidates = useCallback(async () => {
|
||||||
|
const uris = selectedCandidateUris;
|
||||||
|
if (!uris || uris.length === 0) {
|
||||||
|
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsTesting(true);
|
||||||
|
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
|
||||||
|
|
||||||
|
const streamConfigs: StreamConfig[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < uris.length; i++) {
|
||||||
|
const uri = uris[i];
|
||||||
|
const streamTestResult = await probeUri(uri, false);
|
||||||
|
|
||||||
|
if (streamTestResult && streamTestResult.success) {
|
||||||
|
const streamId = `stream_${Date.now()}_${i}`;
|
||||||
|
streamConfigs.push({
|
||||||
|
id: streamId,
|
||||||
|
url: uri,
|
||||||
|
roles:
|
||||||
|
streamConfigs.length === 0
|
||||||
|
? (["detect"] as StreamRole[])
|
||||||
|
: ([] as StreamRole[]),
|
||||||
|
testResult: streamTestResult,
|
||||||
|
});
|
||||||
|
setCandidateTests((s) => ({ ...s, [uri]: streamTestResult }));
|
||||||
|
} else {
|
||||||
|
setCandidateTests((s) => ({
|
||||||
|
...s,
|
||||||
|
[uri]: streamTestResult,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamConfigs.length > 0) {
|
||||||
|
onNext({ streams: streamConfigs });
|
||||||
|
toast.success(t("cameraWizard.step2.testSuccess"));
|
||||||
|
setProbeDialogOpen(false);
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
t("cameraWizard.commonErrors.testFailed", {
|
||||||
|
error: "No streams succeeded",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} 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";
|
||||||
|
toast.error(
|
||||||
|
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsTesting(false);
|
||||||
|
setTestStatus("");
|
||||||
|
}
|
||||||
|
}, [selectedCandidateUris, t, onNext, probeUri]);
|
||||||
|
|
||||||
|
const testCandidate = useCallback(
|
||||||
|
async (uri: string) => {
|
||||||
|
if (!uri) return;
|
||||||
|
setTestingCandidates((s) => ({ ...s, [uri]: true }));
|
||||||
|
try {
|
||||||
|
const result = await probeUri(uri, false);
|
||||||
|
setCandidateTests((s) => ({ ...s, [uri]: result }));
|
||||||
|
} finally {
|
||||||
|
setTestingCandidates((s) => ({ ...s, [uri]: false }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[probeUri],
|
||||||
|
);
|
||||||
|
|
||||||
|
const generateDynamicStreamUrl = useCallback(
|
||||||
|
async (data: Partial<WizardFormData>): Promise<string | null> => {
|
||||||
|
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||||
|
if (!brand || !data.host) return null;
|
||||||
|
|
||||||
|
let protocol = undefined;
|
||||||
|
if (data.brandTemplate === "reolink" && data.username && data.password) {
|
||||||
|
try {
|
||||||
|
protocol = await detectReolinkCamera(
|
||||||
|
data.host,
|
||||||
|
data.username,
|
||||||
|
data.password,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocolKey = protocol || "rtsp";
|
||||||
|
const templates: Record<string, string> = brand.dynamicTemplates || {};
|
||||||
|
|
||||||
|
if (Object.keys(templates).includes(protocolKey)) {
|
||||||
|
const template =
|
||||||
|
templates[protocolKey as keyof typeof brand.dynamicTemplates];
|
||||||
|
return template
|
||||||
|
.replace("{username}", data.username || "")
|
||||||
|
.replace("{password}", data.password || "")
|
||||||
|
.replace("{host}", data.host);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const generateStreamUrl = useCallback(
|
||||||
|
async (data: Partial<WizardFormData>): Promise<string> => {
|
||||||
|
if (data.brandTemplate === "other") {
|
||||||
|
return data.customUrl || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||||
|
if (!brand || !data.host) return "";
|
||||||
|
|
||||||
|
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
|
||||||
|
const dynamicUrl = await generateDynamicStreamUrl(data);
|
||||||
|
|
||||||
|
if (dynamicUrl) {
|
||||||
|
return dynamicUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return brand.template
|
||||||
|
.replace("{username}", data.username || "")
|
||||||
|
.replace("{password}", data.password || "")
|
||||||
|
.replace("{host}", data.host);
|
||||||
|
},
|
||||||
|
[generateDynamicStreamUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const testConnection = useCallback(async () => {
|
||||||
|
const streamUrl = await generateStreamUrl(wizardData);
|
||||||
|
|
||||||
|
if (!streamUrl) {
|
||||||
|
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsTesting(true);
|
||||||
|
setTestStatus("");
|
||||||
|
setTestResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
|
||||||
|
const result = await probeUri(streamUrl, true, setTestStatus);
|
||||||
|
|
||||||
|
if (result && result.success) {
|
||||||
|
setTestResult(result);
|
||||||
|
const streamId = `stream_${Date.now()}`;
|
||||||
|
onUpdate({
|
||||||
|
streams: [
|
||||||
|
{
|
||||||
|
id: streamId,
|
||||||
|
url: streamUrl,
|
||||||
|
roles: ["detect"] as StreamRole[],
|
||||||
|
testResult: result,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
toast.success(t("cameraWizard.step2.testSuccess"));
|
||||||
|
} else {
|
||||||
|
const errMsg = result?.error || "Unable to probe stream";
|
||||||
|
setTestResult({
|
||||||
|
success: false,
|
||||||
|
error: errMsg,
|
||||||
|
});
|
||||||
|
toast.error(
|
||||||
|
t("cameraWizard.commonErrors.testFailed", { error: errMsg }),
|
||||||
|
{
|
||||||
|
duration: 6000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const axiosError = error as {
|
||||||
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
const errorMessage =
|
||||||
|
axiosError.response?.data?.message ||
|
||||||
|
axiosError.response?.data?.detail ||
|
||||||
|
axiosError.message ||
|
||||||
|
"Connection failed";
|
||||||
|
setTestResult({
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
toast.error(
|
||||||
|
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
|
||||||
|
{
|
||||||
|
duration: 10000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsTesting(false);
|
||||||
|
setTestStatus("");
|
||||||
|
}
|
||||||
|
}, [wizardData, generateStreamUrl, t, onUpdate, probeUri]);
|
||||||
|
|
||||||
|
const handleContinue = useCallback(() => {
|
||||||
|
onNext();
|
||||||
|
}, [onNext]);
|
||||||
|
|
||||||
|
// Auto-start probe or test when step loads
|
||||||
|
const [hasStarted, setHasStarted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasStarted) {
|
||||||
|
setHasStarted(true);
|
||||||
|
if (probeMode) {
|
||||||
|
probeCamera();
|
||||||
|
} else {
|
||||||
|
testConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [hasStarted, probeMode, probeCamera, testConnection]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{probeMode ? (
|
||||||
|
// Probe mode: show probe dialog
|
||||||
|
<>
|
||||||
|
{probeResult && (
|
||||||
|
<div className="p-4">
|
||||||
|
<ProbeDialog
|
||||||
|
open={probeDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setProbeDialogOpen(open);
|
||||||
|
// If dialog is being closed (open=false), go back
|
||||||
|
if (!open) {
|
||||||
|
onBack();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isLoading={isProbing}
|
||||||
|
isError={!!probeError}
|
||||||
|
error={probeError || undefined}
|
||||||
|
probeResult={probeResult}
|
||||||
|
onSelectCandidate={handleSelectCandidate}
|
||||||
|
onRetry={probeCamera}
|
||||||
|
selectedCandidateUris={selectedCandidateUris}
|
||||||
|
testAllSelectedCandidates={testAllSelectedCandidates}
|
||||||
|
isTesting={isTesting}
|
||||||
|
testStatus={testStatus}
|
||||||
|
testCandidate={testCandidate}
|
||||||
|
candidateTests={candidateTests}
|
||||||
|
testingCandidates={testingCandidates}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isProbing && !probeResult && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<ActivityIndicator className="size-4" />
|
||||||
|
{t("cameraWizard.step2.probing")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{probeError && !probeResult && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-sm text-destructive">{probeError}</div>
|
||||||
|
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||||
|
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||||
|
{t("button.back", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={probeCamera}
|
||||||
|
variant="select"
|
||||||
|
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||||
|
>
|
||||||
|
{t("cameraWizard.step2.retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Manual mode: show snapshot and stream details
|
||||||
|
<>
|
||||||
|
{testResult?.success && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="mb-3 flex flex-row items-center gap-2 text-sm font-medium text-success">
|
||||||
|
<FaCircleCheck className="size-4" />
|
||||||
|
{t("cameraWizard.step2.testSuccess")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{testResult.snapshot ? (
|
||||||
|
<div className="relative flex justify-center">
|
||||||
|
<img
|
||||||
|
src={testResult.snapshot}
|
||||||
|
alt="Camera snapshot"
|
||||||
|
className="max-h-[50dvh] max-w-full rounded-lg object-contain"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-2 right-2 rounded-md bg-black/70 p-3 text-sm backdrop-blur-sm">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<StreamDetails testResult={testResult} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="p-4">
|
||||||
|
<CardTitle className="mb-2 text-sm">
|
||||||
|
{t("cameraWizard.step2.streamDetails")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardContent className="p-0 text-sm">
|
||||||
|
<StreamDetails testResult={testResult} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isTesting && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<ActivityIndicator className="size-4" />
|
||||||
|
{testStatus}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{testResult && !testResult.success && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-sm text-destructive">{testResult.error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||||
|
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||||
|
{t("button.back", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
{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}
|
||||||
|
variant="select"
|
||||||
|
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||||
|
>
|
||||||
|
{isTesting && <ActivityIndicator className="size-4" />}
|
||||||
|
{t("cameraWizard.step2.retry")}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -26,7 +26,7 @@ import { LuInfo, LuExternalLink } from "react-icons/lu";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
|
|
||||||
type Step2StreamConfigProps = {
|
type Step3StreamConfigProps = {
|
||||||
wizardData: Partial<WizardFormData>;
|
wizardData: Partial<WizardFormData>;
|
||||||
onUpdate: (data: Partial<WizardFormData>) => void;
|
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
@ -34,13 +34,13 @@ type Step2StreamConfigProps = {
|
|||||||
canProceed?: boolean;
|
canProceed?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Step2StreamConfig({
|
export default function Step3StreamConfig({
|
||||||
wizardData,
|
wizardData,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onBack,
|
onBack,
|
||||||
onNext,
|
onNext,
|
||||||
canProceed,
|
canProceed,
|
||||||
}: Step2StreamConfigProps) {
|
}: Step3StreamConfigProps) {
|
||||||
const { t } = useTranslation(["views/settings", "components/dialog"]);
|
const { t } = useTranslation(["views/settings", "components/dialog"]);
|
||||||
const { getLocaleDocUrl } = useDocDomain();
|
const { getLocaleDocUrl } = useDocDomain();
|
||||||
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
||||||
@ -165,7 +165,7 @@ export default function Step2StreamConfig({
|
|||||||
};
|
};
|
||||||
|
|
||||||
updateStream(stream.id, { testResult, userTested: true });
|
updateStream(stream.id, { testResult, userTested: true });
|
||||||
toast.success(t("cameraWizard.step2.testSuccess"));
|
toast.success(t("cameraWizard.step3.testSuccess"));
|
||||||
} else {
|
} else {
|
||||||
const error = response.data?.[0]?.stderr || "Unknown error";
|
const error = response.data?.[0]?.stderr || "Unknown error";
|
||||||
updateStream(stream.id, {
|
updateStream(stream.id, {
|
||||||
@ -214,7 +214,7 @@ export default function Step2StreamConfig({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-sm text-secondary-foreground">
|
<div className="text-sm text-secondary-foreground">
|
||||||
{t("cameraWizard.step2.description")}
|
{t("cameraWizard.step3.description")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -224,7 +224,7 @@ export default function Step2StreamConfig({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium">
|
<h4 className="font-medium">
|
||||||
{t("cameraWizard.step2.streamTitle", { number: index + 1 })}
|
{t("cameraWizard.step3.streamTitle", { number: index + 1 })}
|
||||||
</h4>
|
</h4>
|
||||||
{stream.testResult && stream.testResult.success && (
|
{stream.testResult && stream.testResult.success && (
|
||||||
<div className="mt-1 text-sm text-muted-foreground">
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
@ -246,7 +246,7 @@ export default function Step2StreamConfig({
|
|||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<FaCircleCheck className="size-4 text-success" />
|
<FaCircleCheck className="size-4 text-success" />
|
||||||
<span className="text-success">
|
<span className="text-success">
|
||||||
{t("cameraWizard.step2.connected")}
|
{t("cameraWizard.step3.connected")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -254,7 +254,7 @@ export default function Step2StreamConfig({
|
|||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<LuX className="size-4 text-danger" />
|
<LuX className="size-4 text-danger" />
|
||||||
<span className="text-danger">
|
<span className="text-danger">
|
||||||
{t("cameraWizard.step2.notConnected")}
|
{t("cameraWizard.step3.notConnected")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -274,7 +274,7 @@ export default function Step2StreamConfig({
|
|||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-primary-variant">
|
<label className="text-sm font-medium text-primary-variant">
|
||||||
{t("cameraWizard.step2.url")}
|
{t("cameraWizard.step3.url")}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
@ -286,7 +286,7 @@ export default function Step2StreamConfig({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="h-8 flex-1"
|
className="h-8 flex-1"
|
||||||
placeholder={t("cameraWizard.step2.streamUrlPlaceholder")}
|
placeholder={t("cameraWizard.step3.streamUrlPlaceholder")}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -300,7 +300,7 @@ export default function Step2StreamConfig({
|
|||||||
{testingStreams.has(stream.id) && (
|
{testingStreams.has(stream.id) && (
|
||||||
<ActivityIndicator className="mr-2 size-4" />
|
<ActivityIndicator className="mr-2 size-4" />
|
||||||
)}
|
)}
|
||||||
{t("cameraWizard.step2.testStream")}
|
{t("cameraWizard.step3.testStream")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -311,7 +311,7 @@ export default function Step2StreamConfig({
|
|||||||
stream.userTested && (
|
stream.userTested && (
|
||||||
<div className="rounded-md border border-danger/20 bg-danger/10 p-3 text-sm text-danger">
|
<div className="rounded-md border border-danger/20 bg-danger/10 p-3 text-sm text-danger">
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{t("cameraWizard.step2.testFailedTitle")}
|
{t("cameraWizard.step3.testFailedTitle")}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs">
|
<div className="mt-1 text-xs">
|
||||||
{stream.testResult.error}
|
{stream.testResult.error}
|
||||||
@ -322,7 +322,7 @@ export default function Step2StreamConfig({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Label className="text-sm font-medium text-primary-variant">
|
<Label className="text-sm font-medium text-primary-variant">
|
||||||
{t("cameraWizard.step2.roles")}
|
{t("cameraWizard.step3.roles")}
|
||||||
</Label>
|
</Label>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@ -333,20 +333,20 @@ export default function Step2StreamConfig({
|
|||||||
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{t("cameraWizard.step2.rolesPopover.title")}
|
{t("cameraWizard.step3.rolesPopover.title")}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-muted-foreground">
|
<div className="space-y-1 text-muted-foreground">
|
||||||
<div>
|
<div>
|
||||||
<strong>detect</strong> -{" "}
|
<strong>detect</strong> -{" "}
|
||||||
{t("cameraWizard.step2.rolesPopover.detect")}
|
{t("cameraWizard.step3.rolesPopover.detect")}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>record</strong> -{" "}
|
<strong>record</strong> -{" "}
|
||||||
{t("cameraWizard.step2.rolesPopover.record")}
|
{t("cameraWizard.step3.rolesPopover.record")}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>audio</strong> -{" "}
|
<strong>audio</strong> -{" "}
|
||||||
{t("cameraWizard.step2.rolesPopover.audio")}
|
{t("cameraWizard.step3.rolesPopover.audio")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-center text-primary">
|
<div className="mt-3 flex items-center text-primary">
|
||||||
@ -392,7 +392,7 @@ export default function Step2StreamConfig({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Label className="text-sm font-medium text-primary-variant">
|
<Label className="text-sm font-medium text-primary-variant">
|
||||||
{t("cameraWizard.step2.featuresTitle")}
|
{t("cameraWizard.step3.featuresTitle")}
|
||||||
</Label>
|
</Label>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@ -403,10 +403,10 @@ export default function Step2StreamConfig({
|
|||||||
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{t("cameraWizard.step2.featuresPopover.title")}
|
{t("cameraWizard.step3.featuresPopover.title")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
{t("cameraWizard.step2.featuresPopover.description")}
|
{t("cameraWizard.step3.featuresPopover.description")}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-center text-primary">
|
<div className="mt-3 flex items-center text-primary">
|
||||||
<Link
|
<Link
|
||||||
@ -428,7 +428,7 @@ export default function Step2StreamConfig({
|
|||||||
<div className="rounded-lg bg-background p-3">
|
<div className="rounded-lg bg-background p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{t("cameraWizard.step2.go2rtc")}
|
{t("cameraWizard.step3.go2rtc")}
|
||||||
</span>
|
</span>
|
||||||
<Switch
|
<Switch
|
||||||
checked={stream.restream || false}
|
checked={stream.restream || false}
|
||||||
@ -448,13 +448,13 @@ export default function Step2StreamConfig({
|
|||||||
className=""
|
className=""
|
||||||
>
|
>
|
||||||
<LuPlus className="mr-2 size-4" />
|
<LuPlus className="mr-2 size-4" />
|
||||||
{t("cameraWizard.step2.addAnotherStream")}
|
{t("cameraWizard.step3.addAnotherStream")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasDetectRole && (
|
{!hasDetectRole && (
|
||||||
<div className="rounded-lg border border-danger/50 p-3 text-sm text-danger">
|
<div className="rounded-lg border border-danger/50 p-3 text-sm text-danger">
|
||||||
{t("cameraWizard.step2.detectRoleWarning")}
|
{t("cameraWizard.step3.detectRoleWarning")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
|
|||||||
import { LuX } from "react-icons/lu";
|
import { LuX } from "react-icons/lu";
|
||||||
import { Card, CardContent } from "../../ui/card";
|
import { Card, CardContent } from "../../ui/card";
|
||||||
|
|
||||||
type Step3ValidationProps = {
|
type Step4ValidationProps = {
|
||||||
wizardData: Partial<WizardFormData>;
|
wizardData: Partial<WizardFormData>;
|
||||||
onUpdate: (data: Partial<WizardFormData>) => void;
|
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||||
onSave: (config: WizardFormData) => void;
|
onSave: (config: WizardFormData) => void;
|
||||||
@ -27,13 +27,13 @@ type Step3ValidationProps = {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Step3Validation({
|
export default function Step4Validation({
|
||||||
wizardData,
|
wizardData,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onSave,
|
onSave,
|
||||||
onBack,
|
onBack,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: Step3ValidationProps) {
|
}: Step4ValidationProps) {
|
||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation(["views/settings"]);
|
||||||
const [isValidating, setIsValidating] = useState(false);
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
||||||
@ -143,13 +143,13 @@ export default function Step3Validation({
|
|||||||
|
|
||||||
if (testResult.success) {
|
if (testResult.success) {
|
||||||
toast.success(
|
toast.success(
|
||||||
t("cameraWizard.step3.streamValidated", {
|
t("cameraWizard.step4.streamValidated", {
|
||||||
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("cameraWizard.step3.streamValidationFailed", {
|
t("cameraWizard.step4.streamValidationFailed", {
|
||||||
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -200,16 +200,16 @@ export default function Step3Validation({
|
|||||||
(r) => r.success,
|
(r) => r.success,
|
||||||
).length;
|
).length;
|
||||||
if (successfulTests === results.size) {
|
if (successfulTests === results.size) {
|
||||||
toast.success(t("cameraWizard.step3.reconnectionSuccess"));
|
toast.success(t("cameraWizard.step4.reconnectionSuccess"));
|
||||||
} else {
|
} else {
|
||||||
toast.warning(t("cameraWizard.step3.reconnectionPartial"));
|
toast.warning(t("cameraWizard.step4.reconnectionPartial"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [streams, onUpdate, t, performStreamValidation]);
|
}, [streams, onUpdate, t, performStreamValidation]);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
if (!wizardData.cameraName || !wizardData.streams?.length) {
|
if (!wizardData.cameraName || !wizardData.streams?.length) {
|
||||||
toast.error(t("cameraWizard.step3.saveError"));
|
toast.error(t("cameraWizard.step4.saveError"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -239,13 +239,13 @@ export default function Step3Validation({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t("cameraWizard.step3.description")}
|
{t("cameraWizard.step4.description")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-lg font-medium">
|
<h3 className="text-lg font-medium">
|
||||||
{t("cameraWizard.step3.validationTitle")}
|
{t("cameraWizard.step4.validationTitle")}
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
<Button
|
||||||
onClick={validateAllStreams}
|
onClick={validateAllStreams}
|
||||||
@ -254,8 +254,8 @@ export default function Step3Validation({
|
|||||||
>
|
>
|
||||||
{isValidating && <ActivityIndicator className="mr-2 size-4" />}
|
{isValidating && <ActivityIndicator className="mr-2 size-4" />}
|
||||||
{isValidating
|
{isValidating
|
||||||
? t("cameraWizard.step3.connecting")
|
? t("cameraWizard.step4.connecting")
|
||||||
: t("cameraWizard.step3.connectAllStreams")}
|
: t("cameraWizard.step4.connectAllStreams")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -270,7 +270,7 @@ export default function Step3Validation({
|
|||||||
<div className="flex flex-col space-y-1">
|
<div className="flex flex-col space-y-1">
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
<h4 className="mr-2 font-medium">
|
<h4 className="mr-2 font-medium">
|
||||||
{t("cameraWizard.step3.streamTitle", {
|
{t("cameraWizard.step4.streamTitle", {
|
||||||
number: index + 1,
|
number: index + 1,
|
||||||
})}
|
})}
|
||||||
</h4>
|
</h4>
|
||||||
@ -331,7 +331,7 @@ export default function Step3Validation({
|
|||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{t("cameraWizard.step3.ffmpegModule")}
|
{t("cameraWizard.step4.ffmpegModule")}
|
||||||
</span>
|
</span>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@ -346,11 +346,11 @@ export default function Step3Validation({
|
|||||||
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{t("cameraWizard.step3.ffmpegModule")}
|
{t("cameraWizard.step4.ffmpegModule")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"cameraWizard.step3.ffmpegModuleDescription",
|
"cameraWizard.step4.ffmpegModuleDescription",
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -402,17 +402,17 @@ export default function Step3Validation({
|
|||||||
<ActivityIndicator className="mr-2 size-4" />
|
<ActivityIndicator className="mr-2 size-4" />
|
||||||
)}
|
)}
|
||||||
{result?.success
|
{result?.success
|
||||||
? t("cameraWizard.step3.disconnectStream")
|
? t("cameraWizard.step4.disconnectStream")
|
||||||
: testingStreams.has(stream.id)
|
: testingStreams.has(stream.id)
|
||||||
? t("cameraWizard.step3.connectingStream")
|
? t("cameraWizard.step4.connectingStream")
|
||||||
: t("cameraWizard.step3.connectStream")}
|
: t("cameraWizard.step4.connectStream")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-xs">
|
<div className="text-xs">
|
||||||
{t("cameraWizard.step3.issues.title")}
|
{t("cameraWizard.step4.issues.title")}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-background p-3">
|
<div className="rounded-lg bg-background p-3">
|
||||||
<StreamIssues
|
<StreamIssues
|
||||||
@ -455,7 +455,7 @@ export default function Step3Validation({
|
|||||||
{isLoading && <ActivityIndicator className="mr-2 size-4" />}
|
{isLoading && <ActivityIndicator className="mr-2 size-4" />}
|
||||||
{isLoading
|
{isLoading
|
||||||
? t("button.saving", { ns: "common" })
|
? t("button.saving", { ns: "common" })
|
||||||
: t("cameraWizard.step3.saveAndApply")}
|
: t("cameraWizard.step4.saveAndApply")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -486,7 +486,7 @@ function StreamIssues({
|
|||||||
if (streamUrl.startsWith("rtsp://")) {
|
if (streamUrl.startsWith("rtsp://")) {
|
||||||
result.push({
|
result.push({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
message: t("cameraWizard.step1.errors.brands.reolink-rtsp"),
|
message: t("cameraWizard.step4.issues.brands.reolink-rtsp"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -497,7 +497,7 @@ function StreamIssues({
|
|||||||
if (["h264", "h265", "hevc"].includes(videoCodec)) {
|
if (["h264", "h265", "hevc"].includes(videoCodec)) {
|
||||||
result.push({
|
result.push({
|
||||||
type: "good",
|
type: "good",
|
||||||
message: t("cameraWizard.step3.issues.videoCodecGood", {
|
message: t("cameraWizard.step4.issues.videoCodecGood", {
|
||||||
codec: stream.testResult.videoCodec,
|
codec: stream.testResult.videoCodec,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@ -511,20 +511,20 @@ function StreamIssues({
|
|||||||
if (audioCodec === "aac") {
|
if (audioCodec === "aac") {
|
||||||
result.push({
|
result.push({
|
||||||
type: "good",
|
type: "good",
|
||||||
message: t("cameraWizard.step3.issues.audioCodecGood", {
|
message: t("cameraWizard.step4.issues.audioCodecGood", {
|
||||||
codec: stream.testResult.audioCodec,
|
codec: stream.testResult.audioCodec,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
result.push({
|
result.push({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: t("cameraWizard.step3.issues.audioCodecRecordError"),
|
message: t("cameraWizard.step4.issues.audioCodecRecordError"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result.push({
|
result.push({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
message: t("cameraWizard.step3.issues.noAudioWarning"),
|
message: t("cameraWizard.step4.issues.noAudioWarning"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -534,7 +534,7 @@ function StreamIssues({
|
|||||||
if (!stream.testResult?.audioCodec) {
|
if (!stream.testResult?.audioCodec) {
|
||||||
result.push({
|
result.push({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: t("cameraWizard.step3.issues.audioCodecRequired"),
|
message: t("cameraWizard.step4.issues.audioCodecRequired"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -544,7 +544,7 @@ function StreamIssues({
|
|||||||
if (stream.restream) {
|
if (stream.restream) {
|
||||||
result.push({
|
result.push({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
message: t("cameraWizard.step3.issues.restreamingWarning"),
|
message: t("cameraWizard.step4.issues.restreamingWarning"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -557,14 +557,14 @@ function StreamIssues({
|
|||||||
if (minDimension > 1080) {
|
if (minDimension > 1080) {
|
||||||
result.push({
|
result.push({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
message: t("cameraWizard.step3.issues.resolutionHigh", {
|
message: t("cameraWizard.step4.issues.resolutionHigh", {
|
||||||
resolution: stream.resolution,
|
resolution: stream.resolution,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} else if (maxDimension < 640) {
|
} else if (maxDimension < 640) {
|
||||||
result.push({
|
result.push({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: t("cameraWizard.step3.issues.resolutionLow", {
|
message: t("cameraWizard.step4.issues.resolutionLow", {
|
||||||
resolution: stream.resolution,
|
resolution: stream.resolution,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@ -580,7 +580,7 @@ function StreamIssues({
|
|||||||
) {
|
) {
|
||||||
result.push({
|
result.push({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
message: t("cameraWizard.step3.issues.dahua.substreamWarning"),
|
message: t("cameraWizard.step4.issues.dahua.substreamWarning"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@ -590,7 +590,7 @@ function StreamIssues({
|
|||||||
) {
|
) {
|
||||||
result.push({
|
result.push({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
message: t("cameraWizard.step3.issues.hikvision.substreamWarning"),
|
message: t("cameraWizard.step4.issues.hikvision.substreamWarning"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -662,7 +662,7 @@ function BandwidthDisplay({
|
|||||||
return (
|
return (
|
||||||
<div className="mb-2 text-sm">
|
<div className="mb-2 text-sm">
|
||||||
<span className="font-medium text-muted-foreground">
|
<span className="font-medium text-muted-foreground">
|
||||||
{t("cameraWizard.step3.estimatedBandwidth")}:
|
{t("cameraWizard.step4.estimatedBandwidth")}:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span className="text-secondary-foreground">
|
<span className="text-secondary-foreground">
|
||||||
{streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })}
|
{streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })}
|
||||||
@ -748,7 +748,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
|||||||
style={{ aspectRatio }}
|
style={{ aspectRatio }}
|
||||||
>
|
>
|
||||||
<span className="text-sm text-danger">
|
<span className="text-sm text-danger">
|
||||||
{t("cameraWizard.step3.streamUnavailable")}
|
{t("cameraWizard.step4.streamUnavailable")}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -757,7 +757,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
|||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<LuRotateCcw className="size-4" />
|
<LuRotateCcw className="size-4" />
|
||||||
{t("cameraWizard.step3.reload")}
|
{t("cameraWizard.step4.reload")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -771,7 +771,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
|||||||
>
|
>
|
||||||
<ActivityIndicator className="size-4" />
|
<ActivityIndicator className="size-4" />
|
||||||
<span className="ml-2 text-sm">
|
<span className="ml-2 text-sm">
|
||||||
{t("cameraWizard.step3.connecting")}
|
{t("cameraWizard.step4.connecting")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
Loading…
Reference in New Issue
Block a user