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 && ( )}
); }