diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 9db71f2b5..12f563e3c 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -236,7 +236,7 @@ "description": "Final validation and bandwidth analysis before saving your camera configuration.", "validationTitle": "Stream Validation", "validating": "Validating...", - "revalidateStreams": "Re-validate Streams", + "testAllStreams": "Test All Streams", "validationSuccess": "Validation completed successfully!", "validationPartial": "Some streams failed validation.", "streamUnavailable": "Stream unavailable", @@ -247,14 +247,22 @@ "failed": "Failed", "notTested": "Not tested", "testStream": "Test Stream", - "estimatedBandwidth": "Estimated Bandwidth:", + "estimatedBandwidth": "Estimated Bandwidth", "roles": "Roles", "none": "None", "error": "Error", "streamValidated": "Stream {{number}} validated successfully", "streamValidationFailed": "Stream {{number}} validation failed", "saveAndApply": "Save New Camera Configuration", - "saveError": "Invalid configuration. Please check your settings." + "saveError": "Invalid configuration. Please check your settings.", + "issues": { + "title": "Stream Validation", + "videoCodecGood": "Video codec is {{codec}}.", + "audioCodecGood": "Audio codec is AAC.", + "noAudioWarning": "No audio detected for this stream, recordings will not have audio.", + "audioCodecError": "The AAC audio codec is required to support audio in recordings.", + "restreamingWarning": "Reducing connections to the camera for the record stream may increase CPU usage slightly." + } } }, "camera": { diff --git a/web/src/components/settings/Step3Validation.tsx b/web/src/components/settings/Step3Validation.tsx index e45bef57f..50f7e8628 100644 --- a/web/src/components/settings/Step3Validation.tsx +++ b/web/src/components/settings/Step3Validation.tsx @@ -1,4 +1,5 @@ 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"; @@ -8,6 +9,9 @@ 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; @@ -17,107 +21,6 @@ type Step3ValidationProps = { isLoading?: boolean; }; -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]); - - if (error) { - return ( -
- - {t("cameraWizard.step3.streamUnavailable")} - - -
- ); - } - - if (!registered) { - return ( -
- - - {t("cameraWizard.step3.connecting")} - -
- ); - } - - return ( - setError(true)} - /> - ); -} - export default function Step3Validation({ wizardData, onUpdate, @@ -127,6 +30,7 @@ export default function Step3Validation({ }: Step3ValidationProps) { const { t } = useTranslation(["views/settings"]); const [isValidating, setIsValidating] = useState(false); + const [testingStreams, setTestingStreams] = useState>(new Set()); const [measuredBandwidth, setMeasuredBandwidth] = useState< Map >(new Map()); @@ -216,6 +120,13 @@ export default function Step3Validation({ 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({ @@ -237,6 +148,12 @@ export default function Step3Validation({ }), ); } + + setTestingStreams((prev) => { + const newSet = new Set(prev); + newSet.delete(stream.id); + return newSet; + }); }, [streams, onUpdate, t, performStreamValidation], ); @@ -332,7 +249,7 @@ export default function Step3Validation({ {isValidating && } {isValidating ? t("cameraWizard.step3.validating") - : t("cameraWizard.step3.revalidateStreams")} + : t("cameraWizard.step3.testAllStreams")} @@ -340,122 +257,98 @@ export default function Step3Validation({ {streams.map((stream, index) => { const result = validationResults.get(stream.id); return ( -
-
-

- {t("cameraWizard.step3.streamTitle", { number: index + 1 })} -

- {result ? ( - - {result.success - ? t("cameraWizard.step3.valid") - : t("cameraWizard.step3.failed")} - - ) : ( - - {t("cameraWizard.step3.notTested")} - - )} -
- -
- -
- -
- - {stream.url} - - -
- - {(() => { - const streamBandwidth = measuredBandwidth.get(stream.id); - 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 ( -
- - {t("cameraWizard.step3.estimatedBandwidth")}: - {" "} - - {streamBandwidth.toFixed(1)}{" "} - {t("unit.data.kbps", { ns: "common" })} - - - ({perHourDisplay}) - + + +
+
+

+ {t("cameraWizard.step3.streamTitle", { + number: index + 1, + })} +

+ {stream.roles.map((role) => ( + + {role} + + ))}
- ); - })()} - -
- - {t("cameraWizard.step3.roles")}: - {" "} - {stream.roles.join(", ") || t("cameraWizard.step3.none")} -
- - {result && ( -
- {result.success ? ( -
- {result.resolution && ( -
- {t("cameraWizard.testResultLabels.resolution")}:{" "} - {result.resolution} -
- )} - {result.videoCodec && ( -
- {t("cameraWizard.testResultLabels.video")}:{" "} - {result.videoCodec} -
- )} - {result.audioCodec && ( -
- {t("cameraWizard.testResultLabels.audio")}:{" "} - {result.audioCodec} -
- )} - {result.fps && ( -
- {t("cameraWizard.testResultLabels.fps")}:{" "} - {result.fps} -
- )} + {result?.success && ( +
+ + + {t("cameraWizard.step2.connected")} +
- ) : ( -
- {t("cameraWizard.step3.error")}: {result.error} + )} + {result && !result.success && ( +
+ + + {t("cameraWizard.step2.notConnected")} +
)}
- )} -
+ + {result && result.success && ( +
+ {[ + result.resolution, + result.fps + ? `${result.fps} ${t("cameraWizard.testResultLabels.fps")}` + : null, + result.videoCodec, + result.audioCodec, + ] + .filter(Boolean) + .join(" ยท ")} +
+ )} + +
+ +
+ +
+ + {stream.url} + + +
+ +
+ +
+ + {result && !result.success && ( +
+
+ {t("cameraWizard.step2.testFailedTitle")} +
+
{result.error}
+
+ )} + + ); })}
@@ -463,12 +356,7 @@ export default function Step3Validation({
{onBack && ( - )} @@ -488,3 +376,249 @@ export default function Step3Validation({
); } + +type StreamIssuesProps = { + stream: StreamConfig; + measuredBandwidth: Map; + wizardData: Partial; +}; + +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"), + }); + } else { + result.push({ + type: "error", + message: t("cameraWizard.step3.issues.audioCodecError"), + }); + } + } else { + result.push({ + type: "warning", + message: t("cameraWizard.step3.issues.noAudioWarning"), + }); + } + } + + // 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 ( +
+
{t("cameraWizard.step3.issues.title")}
+ +
+ {issues.map((issue, index) => ( +
+ {issue.type === "good" && ( + + )} + {issue.type === "warning" && ( + + )} + {issue.type === "error" && ( + + )} + + {issue.message} + +
+ ))} +
+
+ ); +} + +type BandwidthDisplayProps = { + streamId: string; + measuredBandwidth: Map; +}; + +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 ( +
+ + {t("cameraWizard.step3.estimatedBandwidth")}: + {" "} + + {streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })} + + ({perHourDisplay}) +
+ ); +} + +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]); + + if (error) { + return ( +
+ + {t("cameraWizard.step3.streamUnavailable")} + + +
+ ); + } + + if (!registered) { + return ( +
+ + + {t("cameraWizard.step3.connecting")} + +
+ ); + } + + return ( + setError(true)} + /> + ); +}