From c3e57285fd624ab8ebaafefed5a94e99dbdf9433 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 11 Oct 2025 15:44:24 -0500 Subject: [PATCH] implement rough idea for step 3 --- web/public/locales/en/views/settings.json | 31 +- .../components/settings/Step3Validation.tsx | 553 ++++++++++++++++++ 2 files changed, 578 insertions(+), 6 deletions(-) create mode 100644 web/src/components/settings/Step3Validation.tsx diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index f04f827b4..cb3cb1f9e 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -212,14 +212,33 @@ "testFailedTitle": "Test Failed", "connected": "Connected", "notConnected": "Not Connected", - "liveViewTitle": "Live View", - "liveViewStream": "Live View Stream", - "go2rtc": "Use enhanced live view", + "featuresTitle": "Features", + "go2rtc": "Reduce connections to camera", "detectRoleWarning": "At least one stream must have the \"detect\" role to proceed." }, - "save": { - "successWithLive": "Camera {{cameraName}} saved successfully with live streaming configured.", - "successWithoutLive": "Camera {{cameraName}} saved successfully, but live streaming configuration failed." + "step3": { + "description": "Final validation and bandwidth analysis before saving your camera configuration.", + "validationTitle": "Stream Validation", + "validating": "Validating...", + "revalidateStreams": "Re-validate Streams", + "validationSuccess": "Validation completed successfully!", + "validationPartial": "Some streams failed validation.", + "streamUnavailable": "Stream unavailable", + "reload": "Reload", + "connecting": "Connecting...", + "streamTitle": "Stream {{number}}", + "valid": "Valid", + "failed": "Failed", + "notTested": "Not tested", + "testStream": "Test Stream", + "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." } }, "camera": { diff --git a/web/src/components/settings/Step3Validation.tsx b/web/src/components/settings/Step3Validation.tsx new file mode 100644 index 000000000..405d3aebd --- /dev/null +++ b/web/src/components/settings/Step3Validation.tsx @@ -0,0 +1,553 @@ +import { Button } from "@/components/ui/button"; +import { useTranslation } from "react-i18next"; +import { LuRotateCcw } from "react-icons/lu"; +import { useState, useCallback, useMemo, useEffect } from "react"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import axios from "axios"; +import { toast } from "sonner"; +import MSEPlayer from "@/components/player/MsePlayer"; +import { WizardFormData, StreamConfig, TestResult } from "@/types/cameraWizard"; +import { PlayerStatsType } from "@/types/live"; + +type Step3ValidationProps = { + wizardData: Partial; + onUpdate: (data: Partial) => void; + onSave: (config: WizardFormData) => void; + onBack?: () => void; + 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, + onSave, + onBack, + isLoading = false, +}: Step3ValidationProps) { + const { t } = useTranslation(["views/settings"]); + const [isValidating, setIsValidating] = useState(false); + const [measuredBandwidth, setMeasuredBandwidth] = useState< + Map + >(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(); + streams.forEach((stream) => { + if (stream.testResult) { + results.set(stream.id, stream.testResult); + } + }); + return results; + }, [streams]); + + const validateStream = useCallback( + async (stream: StreamConfig) => { + try { + const response = await axios.get("ffprobe", { + params: { paths: stream.url, detailed: true }, + timeout: 10000, + }); + + if (response.data?.[0]?.return_code === 0) { + const probeData = response.data[0]; + const streamData = probeData.stdout.streams || []; + + const videoStream = streamData.find( + (s: { codec_type?: string; codec_name?: string }) => + s.codec_type === "video" || + s.codec_name?.includes("h264") || + s.codec_name?.includes("h265"), + ); + + const audioStream = streamData.find( + (s: { codec_type?: string; codec_name?: string }) => + s.codec_type === "audio" || + s.codec_name?.includes("aac") || + s.codec_name?.includes("mp3"), + ); + + const resolution = videoStream + ? `${videoStream.width}x${videoStream.height}` + : undefined; + + const fps = videoStream?.r_frame_rate + ? parseFloat(videoStream.r_frame_rate.split("/")[0]) / + parseFloat(videoStream.r_frame_rate.split("/")[1]) + : undefined; + + const testResult: TestResult = { + success: true, + resolution, + videoCodec: videoStream?.codec_name, + audioCodec: audioStream?.codec_name, + fps: fps && !isNaN(fps) ? fps : undefined, + }; + + onUpdate({ + streams: streams.map((s) => + s.id === stream.id ? { ...s, testResult } : s, + ), + }); + + toast.success( + t("cameraWizard.step3.streamValidated", { + number: streams.findIndex((s) => s.id === stream.id) + 1, + }), + ); + } else { + const error = response.data?.[0]?.stderr || "Unknown error"; + const testResult: TestResult = { success: false, error }; + + onUpdate({ + streams: streams.map((s) => + s.id === stream.id ? { ...s, testResult } : s, + ), + }); + + toast.error( + `Stream ${streams.findIndex((s) => s.id === stream.id) + 1} validation 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 || + "Connection failed"; + + const testResult: TestResult = { success: false, error: errorMessage }; + + onUpdate({ + streams: streams.map((s) => + s.id === stream.id ? { ...s, testResult } : s, + ), + }); + + toast.error( + t("cameraWizard.step3.streamValidationFailed", { + number: streams.findIndex((s) => s.id === stream.id) + 1, + }), + ); + } + }, + [streams, onUpdate, t], + ); + + const validateAllStreams = useCallback(async () => { + setIsValidating(true); + const results = new Map(); + + // 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; + + try { + const response = await axios.get("ffprobe", { + params: { paths: stream.url, detailed: true }, + timeout: 10000, + }); + + if (response.data?.[0]?.return_code === 0) { + const probeData = response.data[0]; + const streamData = probeData.stdout.streams || []; + + const videoStream = streamData.find( + (s: { codec_type?: string; codec_name?: string }) => + s.codec_type === "video" || + s.codec_name?.includes("h264") || + s.codec_name?.includes("h265"), + ); + + const audioStream = streamData.find( + (s: { codec_type?: string; codec_name?: string }) => + s.codec_type === "audio" || + s.codec_name?.includes("aac") || + s.codec_name?.includes("mp3"), + ); + + const resolution = videoStream + ? `${videoStream.width}x${videoStream.height}` + : undefined; + + const fps = videoStream?.r_frame_rate + ? parseFloat(videoStream.r_frame_rate.split("/")[0]) / + parseFloat(videoStream.r_frame_rate.split("/")[1]) + : undefined; + + const testResult: TestResult = { + success: true, + resolution, + videoCodec: videoStream?.codec_name, + audioCodec: audioStream?.codec_name, + fps: fps && !isNaN(fps) ? fps : undefined, + }; + + results.set(stream.id, testResult); + } else { + const error = response.data?.[0]?.stderr || "Unknown error"; + results.set(stream.id, { 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"; + results.set(stream.id, { success: false, error: errorMessage }); + } + } + + // 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.validationSuccess")); + } else { + toast.warning(t("cameraWizard.step3.validationPartial")); + } + } + }, [streams, onUpdate, t]); + + const handleSave = useCallback(() => { + if (!wizardData.cameraName || !wizardData.streams?.length) { + toast.error(t("cameraWizard.step3.saveError")); + return; + } + + // Convert wizard data to final config format + const configData = { + cameraName: wizardData.cameraName, + host: wizardData.host, + username: wizardData.username, + password: wizardData.password, + brandTemplate: wizardData.brandTemplate, + customUrl: wizardData.customUrl, + streams: wizardData.streams, + restreamIds: wizardData.restreamIds, + }; + + onSave(configData); + }, [wizardData, onSave, t]); + + const canSave = useMemo(() => { + return ( + wizardData.cameraName && + wizardData.streams?.length && + wizardData.streams.some((s) => s.roles.includes("detect")) + ); + }, [wizardData]); + + return ( +
+
+ {t("cameraWizard.step3.description")} +
+ +
+
+

+ {t("cameraWizard.step3.validationTitle")} +

+ +
+ +
+ {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.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} +
+ )} +
+ ) : ( +
+ {t("cameraWizard.step3.error")}: {result.error} +
+ )} +
+ )} +
+ ); + })} +
+
+ +
+ {onBack && ( + + )} + +
+
+ ); +}