From be896789c7a49321d0988f0ad2004bc60b42a1f3 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 10 Oct 2025 15:08:13 -0500 Subject: [PATCH] add camera image and stream details on successful test --- .../components/settings/Step1NameCamera.tsx | 624 ++++++++++-------- 1 file changed, 363 insertions(+), 261 deletions(-) diff --git a/web/src/components/settings/Step1NameCamera.tsx b/web/src/components/settings/Step1NameCamera.tsx index af0d6d8f0..e699129b2 100644 --- a/web/src/components/settings/Step1NameCamera.tsx +++ b/web/src/components/settings/Step1NameCamera.tsx @@ -21,7 +21,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { useTranslation } from "react-i18next"; import { useState, useCallback, useMemo } from "react"; -import { LuEye, LuEyeOff } from "react-icons/lu"; +import { LuCircleCheck, LuEye, LuEyeOff } from "react-icons/lu"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import axios from "axios"; import { toast } from "sonner"; @@ -40,12 +40,15 @@ type Step1NameCameraProps = { wizardData: Partial; onUpdate: (data: Partial) => void; onCancel: () => void; + onNext?: () => void; + canProceed?: boolean; }; export default function Step1NameCamera({ wizardData, onUpdate, onCancel, + onNext, }: Step1NameCameraProps) { const { t } = useTranslation(["views/settings"]); const { data: config } = useSWR("config"); @@ -147,97 +150,120 @@ export default function Step1NameCamera({ setIsTesting(true); setTestResult(null); - await axios - .get("ffprobe", { - params: { paths: streamUrl, detailed: true }, - timeout: 10000, - }) - .then((response) => { - if ( - response.data && - response.data.length > 0 && - response.data[0].return_code === 0 - ) { - const probeData = response.data[0]; - const ffprobeData = probeData.stdout; - const streams = ffprobeData.streams || []; + // First get probe data for metadata + const probePromise = axios.get("ffprobe", { + params: { paths: streamUrl, detailed: true }, + timeout: 10000, + }); - // Extract video stream info - const videoStream = streams.find( - (s: FfprobeStream) => - s.codec_type === "video" || - s.codec_name?.includes("h264") || - s.codec_name?.includes("h265"), - ); - const audioStream = streams.find( - (s: FfprobeStream) => - s.codec_type === "audio" || - s.codec_name?.includes("aac") || - s.codec_name?.includes("mp3"), - ); + // Then get snapshot for preview + const snapshotPromise = axios.get("ffprobe/snapshot", { + params: { url: streamUrl }, + responseType: "blob", + timeout: 10000, + }); - // Calculate resolution - const resolution = videoStream - ? `${videoStream.width}x${videoStream.height}` - : undefined; + try { + // First get probe data for metadata + const probeResponse = await probePromise; + let probeData = null; + if ( + probeResponse.data && + probeResponse.data.length > 0 && + probeResponse.data[0].return_code === 0 + ) { + probeData = probeResponse.data[0]; + } - // Extract FPS from rational (e.g., "15/1" -> 15) - const fps = videoStream?.r_frame_rate - ? parseFloat(videoStream.r_frame_rate.split("/")[0]) / - parseFloat(videoStream.r_frame_rate.split("/")[1]) - : undefined; - - const testResult: TestResult = { - success: true, - resolution, - videoCodec: videoStream?.codec_name, - audioCodec: audioStream?.codec_name, - fps: fps && !isNaN(fps) ? fps : undefined, - }; - - setTestResult(testResult); - toast.success(t("cameraWizard.step1.testSuccess")); - - // Auto-populate stream if successful - const streamId = `stream_${Date.now()}`; - onUpdate({ - ...data, - streams: [ - { - id: streamId, - url: streamUrl, - roles: ["detect"], - resolution: testResult.resolution, - testResult, - }, - ], - }); - } else { - const error = response.data?.[0]?.stderr || "Unknown error"; - setTestResult({ - success: false, - error: error, - }); - toast.error(t("cameraWizard.step1.testFailed", { error })); + // Then get snapshot for preview (only if probe succeeded) + let snapshotBlob = null; + if (probeData) { + try { + const snapshotResponse = await snapshotPromise; + snapshotBlob = snapshotResponse.data; + } catch (snapshotError) { + // Snapshot is optional, don't fail if it doesn't work + toast.warning(t("cameraWizard.step1.warnings.noSnapshot")); } - }) - .catch((error) => { - const errorMessage = - error.response?.data?.message || - error.response?.data?.detail || - "Connection failed"; + } + + if (probeData) { + const ffprobeData = probeData.stdout; + const streams = ffprobeData.streams || []; + + // Extract video stream info + const videoStream = streams.find( + (s: FfprobeStream) => + s.codec_type === "video" || + s.codec_name?.includes("h264") || + s.codec_name?.includes("h265"), + ); + const audioStream = streams.find( + (s: FfprobeStream) => + s.codec_type === "audio" || + s.codec_name?.includes("aac") || + s.codec_name?.includes("mp3"), + ); + + // Calculate resolution + const resolution = videoStream + ? `${videoStream.width}x${videoStream.height}` + : undefined; + + // Extract FPS from rational (e.g., "15/1" -> 15) + const fps = videoStream?.r_frame_rate + ? parseFloat(videoStream.r_frame_rate.split("/")[0]) / + parseFloat(videoStream.r_frame_rate.split("/")[1]) + : undefined; + + // Convert snapshot blob to base64 if available + let snapshotBase64 = undefined; + if (snapshotBlob) { + snapshotBase64 = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.readAsDataURL(snapshotBlob); + }); + } + + const testResult: TestResult = { + success: true, + snapshot: snapshotBase64, + resolution, + videoCodec: videoStream?.codec_name, + audioCodec: audioStream?.codec_name, + fps: fps && !isNaN(fps) ? fps : undefined, + }; + + setTestResult(testResult); + toast.success(t("cameraWizard.step1.testSuccess")); + } else { + const error = probeData?.stderr || "Unknown error"; setTestResult({ success: false, - error: errorMessage, + error: error, }); - toast.error( - t("cameraWizard.step1.testFailed", { error: errorMessage }), - ); - }) - .finally(() => { - setIsTesting(false); + toast.error(t("cameraWizard.step1.testFailed", { error })); + } + } catch (error) { + const axiosError = error as { + response?: { data?: { message?: string; detail?: string } }; + message?: string; + }; + const errorMessage = + axiosError.response?.data?.message || + axiosError.response?.data?.detail || + axiosError.message || + "Connection failed"; + setTestResult({ + success: false, + error: errorMessage, }); - }, [form, generateStreamUrl, onUpdate, t]); + toast.error(t("cameraWizard.step1.testFailed", { error: errorMessage })); + } finally { + setIsTesting(false); + } + }, [form, generateStreamUrl, t]); const onSubmit = (data: z.infer) => { onUpdate(data); @@ -245,104 +271,25 @@ export default function Step1NameCamera({ return (
-
- {t("cameraWizard.step1.description")} -
+ {!testResult?.success && ( + <> +
+ {t("cameraWizard.step1.description")} +
-
- - ( - - {t("cameraWizard.step1.cameraName")} - - - - - - )} - /> - - ( - - {t("cameraWizard.step1.cameraBrand")} - - - {field.value && - (() => { - const selectedBrand = CAMERA_BRANDS.find( - (brand) => brand.value === field.value, - ); - return selectedBrand ? ( - -
- {selectedBrand.exampleUrl} -
-
- ) : null; - })()} -
- )} - /> - - {watchedBrand !== "other" && ( - <> + + ( - {t("cameraWizard.step1.host")} - - - - - - )} - /> - - ( - - {t("cameraWizard.step1.username")} + {t("cameraWizard.step1.cameraName")} @@ -354,109 +301,264 @@ export default function Step1NameCamera({ ( - {t("cameraWizard.step1.password")} - -
- - -
-
+ {t("cameraWizard.step1.cameraBrand")} + + {field.value && + (() => { + const selectedBrand = CAMERA_BRANDS.find( + (brand) => brand.value === field.value, + ); + return selectedBrand ? ( + +
+ {selectedBrand.exampleUrl} +
+
+ ) : null; + })()}
)} /> - - )} - {watchedBrand == "other" && ( - ( - - {t("cameraWizard.step1.customUrl")} - - - - - + {watchedBrand !== "other" && ( + <> + ( + + {t("cameraWizard.step1.host")} + + + + + + )} + /> + + ( + + + {t("cameraWizard.step1.username")} + + + + + + + )} + /> + + ( + + + {t("cameraWizard.step1.password")} + + +
+ + +
+
+ +
+ )} + /> + )} - /> - )} - {testResult && ( -
-
- {testResult.success - ? t("cameraWizard.step1.testSuccess") - : t("cameraWizard.step1.testFailed")} + {watchedBrand == "other" && ( + ( + + {t("cameraWizard.step1.customUrl")} + + + + + + )} + /> + )} + + + + )} + + {testResult?.success && ( +
+
+ + {t("cameraWizard.step1.testSuccess")} +
+ +
+ {testResult.snapshot && ( +
+ Camera snapshot
- {testResult.success ? ( -
- {testResult.resolution && ( -
Resolution: {testResult.resolution}
- )} - {testResult.videoCodec && ( -
Video: {testResult.videoCodec}
- )} - {testResult.audioCodec && ( -
Audio: {testResult.audioCodec}
- )} - {testResult.fps &&
FPS: {testResult.fps}
} + )} + +
+ {t("cameraWizard.step1.streamDetails")} +
+
+ {testResult.resolution && ( +
+ + {t("cameraWizard.step1.testResultLabels.resolution")}: + {" "} + {testResult.resolution}
- ) : ( -
- {testResult.error} + )} + {testResult.videoCodec && ( +
+ + {t("cameraWizard.step1.testResultLabels.video")}: + {" "} + {testResult.videoCodec} +
+ )} + {testResult.audioCodec && ( +
+ + {t("cameraWizard.step1.testResultLabels.audio")}: + {" "} + {testResult.audioCodec} +
+ )} + {testResult.fps && ( +
+ + {t("cameraWizard.step1.testResultLabels.fps")}: + {" "} + {testResult.fps}
)}
- )} - -
- -
- - +
+ )} + +
+ + {testResult?.success ? ( + + ) : ( + + )} +
); }