From 71a7a9472ea8c2bafb7fe897c83d9d59edce2c2b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 11 Oct 2025 06:00:02 -0500 Subject: [PATCH] step 2 and i18n --- web/public/locales/en/views/settings.json | 46 +- .../components/settings/Step2StreamConfig.tsx | 400 ++++++++++++++++++ 2 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 web/src/components/settings/Step2StreamConfig.tsx diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index fe797ad28..f04f827b4 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -151,6 +151,16 @@ "streamConfiguration": "Stream Configuration", "validationAndTesting": "Validation & Testing" }, + "testResultLabels": { + "resolution": "Resolution", + "video": "Video", + "audio": "Audio", + "fps": "FPS" + }, + "commonErrors": { + "noUrl": "Please provide a valid stream URL", + "testFailed": "Stream test failed: {{error}}" + }, "step1": { "description": "Enter your camera details and test the connection.", "cameraName": "Camera Name", @@ -169,12 +179,44 @@ "testConnection": "Test Connection", "testSuccess": "Connection test successful!", "testFailed": "Connection test failed. Please check your input and try again.", + "streamDetails": "Stream Details", + "warnings": { + "noSnapshot": "Unable to fetch a snapshot from the configured stream." + }, "errors": { - "noUrl": "Please provide a valid stream URL", - "testFailed": "Connection test failed: {{error}}", "brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL" } }, + "step2": { + "description": "Configure stream roles and add additional streams for your camera.", + "streamsTitle": "Camera Streams", + "addStream": "Add Stream", + "addAnotherStream": "Add Another Stream", + "streamTitle": "Stream {{number}}", + "streamUrl": "Stream URL", + "streamUrlPlaceholder": "rtsp://username:password@host:port/path", + "url": "URL", + "resolution": "Resolution", + "selectResolution": "Select resolution", + "quality": "Quality", + "selectQuality": "Select quality", + "roles": "Roles", + "roleLabels": { + "detect": "Object Detection", + "record": "Recording", + "audio": "Audio" + }, + "testStream": "Test Stream", + "testSuccess": "Stream test successful!", + "testFailed": "Stream test failed", + "testFailedTitle": "Test Failed", + "connected": "Connected", + "notConnected": "Not Connected", + "liveViewTitle": "Live View", + "liveViewStream": "Live View Stream", + "go2rtc": "Use enhanced live view", + "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." diff --git a/web/src/components/settings/Step2StreamConfig.tsx b/web/src/components/settings/Step2StreamConfig.tsx new file mode 100644 index 000000000..2bb88bb78 --- /dev/null +++ b/web/src/components/settings/Step2StreamConfig.tsx @@ -0,0 +1,400 @@ +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { useTranslation } from "react-i18next"; +import { useState, useCallback, useMemo } from "react"; +import { LuPlus, LuTrash2, LuX } from "react-icons/lu"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import axios from "axios"; +import { toast } from "sonner"; +import { + WizardFormData, + StreamConfig, + StreamRole, + TestResult, + FfprobeStream, +} from "@/types/cameraWizard"; +import { Label } from "../ui/label"; +import { FaCircleCheck } from "react-icons/fa6"; + +type Step2StreamConfigProps = { + wizardData: Partial; + onUpdate: (data: Partial) => void; + onBack?: () => void; + onNext?: () => void; + canProceed?: boolean; +}; + +export default function Step2StreamConfig({ + wizardData, + onUpdate, + onBack, + onNext, + canProceed, +}: Step2StreamConfigProps) { + const { t } = useTranslation(["views/settings"]); + const [testingStreams, setTestingStreams] = useState>(new Set()); + + const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]); + + const addStream = useCallback(() => { + const newStream: StreamConfig = { + id: `stream_${Date.now()}`, + url: "", + roles: [], + }; + onUpdate({ + streams: [...streams, newStream], + }); + }, [streams, onUpdate]); + + const removeStream = useCallback( + (streamId: string) => { + onUpdate({ + streams: streams.filter((s) => s.id !== streamId), + }); + }, + [streams, onUpdate], + ); + + const updateStream = useCallback( + (streamId: string, updates: Partial) => { + onUpdate({ + streams: streams.map((s) => + s.id === streamId ? { ...s, ...updates } : s, + ), + }); + }, + [streams, onUpdate], + ); + + const getUsedRolesExcludingStream = useCallback( + (excludeStreamId: string) => { + const roles = new Set(); + streams.forEach((stream) => { + if (stream.id !== excludeStreamId) { + stream.roles.forEach((role) => roles.add(role)); + } + }); + return roles; + }, + [streams], + ); + + const toggleRole = useCallback( + (streamId: string, role: StreamRole) => { + const stream = streams.find((s) => s.id === streamId); + if (!stream) return; + + const hasRole = stream.roles.includes(role); + if (hasRole) { + // Allow removing the role + const newRoles = stream.roles.filter((r) => r !== role); + updateStream(streamId, { roles: newRoles }); + } else { + // Check if role is already used in another stream + const usedRoles = getUsedRolesExcludingStream(streamId); + if (!usedRoles.has(role)) { + // Allow adding the role + const newRoles = [...stream.roles, role]; + updateStream(streamId, { roles: newRoles }); + } + } + }, + [streams, updateStream, getUsedRolesExcludingStream], + ); + + const testStream = useCallback( + (stream: StreamConfig) => { + if (!stream.url.trim()) { + toast.error(t("cameraWizard.commonErrors.noUrl")); + return; + } + + setTestingStreams((prev) => new Set(prev).add(stream.id)); + + axios + .get("ffprobe", { + params: { paths: stream.url, detailed: true }, + timeout: 10000, + }) + .then((response) => { + if (response.data?.[0]?.return_code === 0) { + const probeData = response.data[0]; + const streams = probeData.stdout.streams || []; + + 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"), + ); + + 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, + }; + + updateStream(stream.id, { testResult, userTested: true }); + toast.success(t("cameraWizard.step2.testSuccess")); + } else { + const error = response.data?.[0]?.stderr || "Unknown error"; + updateStream(stream.id, { + testResult: { success: false, error }, + userTested: true, + }); + toast.error(t("cameraWizard.commonErrors.testFailed", { error })); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Connection failed"; + updateStream(stream.id, { + testResult: { success: false, error: errorMessage }, + userTested: true, + }); + toast.error( + t("cameraWizard.commonErrors.testFailed", { error: errorMessage }), + ); + }) + .finally(() => { + setTestingStreams((prev) => { + const newSet = new Set(prev); + newSet.delete(stream.id); + return newSet; + }); + }); + }, + [updateStream, t], + ); + + const setLiveViewStream = useCallback( + (streamId: string) => { + const currentIds = wizardData.liveViewStreamIds || []; + const isSelected = currentIds.includes(streamId); + const newIds = isSelected + ? currentIds.filter((id) => id !== streamId) + : [...currentIds, streamId]; + onUpdate({ + liveViewStreamIds: newIds, + }); + }, + [wizardData.liveViewStreamIds, onUpdate], + ); + + const hasDetectRole = streams.some((s) => s.roles.includes("detect")); + + return ( +
+
+ {t("cameraWizard.step2.description")} +
+ +
+ {streams.map((stream, index) => ( + + +
+
+

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

+ {stream.testResult && stream.testResult.success && ( +
+ {[ + stream.testResult.resolution, + stream.testResult.fps + ? `${stream.testResult.fps} fps` + : null, + stream.testResult.videoCodec, + stream.testResult.audioCodec, + ] + .filter(Boolean) + .join(" ยท ")} +
+ )} +
+
+ {stream.testResult?.success && ( +
+ + + {t("cameraWizard.step2.connected")} + +
+ )} + {stream.testResult && !stream.testResult.success && ( +
+ + + {t("cameraWizard.step2.notConnected")} + +
+ )} + {streams.length > 1 && ( + + )} +
+
+ +
+
+ +
+ + updateStream(stream.id, { url: e.target.value }) + } + className="h-8 flex-1" + placeholder={t("cameraWizard.step2.streamUrlPlaceholder")} + /> + +
+
+
+ + {stream.testResult && + !stream.testResult.success && + stream.userTested && ( +
+
+ {t("cameraWizard.step2.testFailedTitle")} +
+
+ {stream.testResult.error} +
+
+ )} + +
+ +
+
+ {(["detect", "record", "audio"] as const).map((role) => { + const isUsedElsewhere = getUsedRolesExcludingStream( + stream.id, + ).has(role); + const isChecked = stream.roles.includes(role); + return ( +
+ {role} + toggleRole(stream.id, role)} + disabled={!isChecked && isUsedElsewhere} + /> +
+ ); + })} +
+
+
+ +
+ +
+
+ + {t("cameraWizard.step2.go2rtc")} + + setLiveViewStream(stream.id)} + /> +
+
+
+
+
+ ))} + + +
+ + {!hasDetectRole && ( +
+ {t("cameraWizard.step2.detectRoleWarning")} +
+ )} + +
+ {onBack && ( + + )} + {onNext && ( + + )} +
+
+ ); +}