Enhance SettingsModal with dynamic device loading for microphones, speakers, and cameras; implement video testing functionality; improve UI with loading states and error handling; update Button and Select components for better interactivity.

This commit is contained in:
2025-10-10 19:14:01 +05:00
parent f9406cf6fa
commit d7d8f4771f
6 changed files with 911 additions and 80 deletions
@@ -0,0 +1,261 @@
import { useState, useRef, useEffect } from "react";
import ModalWrapper from "../ModalWrapper";
import Button from "../ui/Button";
import Select from "../ui/Select";
import RestartIcon from "../icons/RestartIcon";
interface VoiceCheckModalProps {
selectedMicrophone: string;
microphones: { deviceId: string; label: string }[];
onSelectMicrophone: (label: string) => void;
onClose: () => void;
}
function VoiceCheckModal({
selectedMicrophone,
microphones,
onSelectMicrophone,
onClose,
}: VoiceCheckModalProps) {
const [audioLevel, setAudioLevel] = useState(0);
const [status, setStatus] = useState<"default" | "success" | "error">(
"default"
);
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const animationFrameRef = useRef<number | null>(null);
const testTimeoutRef = useRef<number | null>(null);
const statusRef = useRef<"default" | "success" | "error">("default");
const [isTestRunning, setIsTestRunning] = useState(false);
function detectAudioLevel() {
if (!analyserRef.current) return;
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount);
analyserRef.current.getByteFrequencyData(dataArray);
const average = dataArray.reduce((a, b) => a + b) / dataArray.length;
setAudioLevel(average);
// Определяем статус на основе уровня звука
// Если звук обнаружен и статус ещё не success, устанавливаем success
if (average > 10 && statusRef.current !== "success") {
statusRef.current = "success";
setStatus("success");
}
animationFrameRef.current = requestAnimationFrame(detectAudioLevel);
}
async function startMicrophoneTest() {
try {
const selectedMic = microphones.find(
(mic) => mic.label === selectedMicrophone
);
const constraints: MediaStreamConstraints = {
audio: selectedMic
? { deviceId: { exact: selectedMic.deviceId } }
: true,
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = stream;
const audioContext = new AudioContext();
audioContextRef.current = audioContext;
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
analyserRef.current = analyser;
const source = audioContext.createMediaStreamSource(stream);
sourceRef.current = source;
source.connect(analyser);
// Сбрасываем статус при новом тесте
statusRef.current = "default";
setStatus("default");
setIsTestRunning(true);
// Запускаем проверку звука
detectAudioLevel();
// Останавливаем проверку через 3 секунды и устанавливаем результат
testTimeoutRef.current = setTimeout(() => {
// Останавливаем анимацию
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
// Устанавливаем финальный статус
if (statusRef.current === "default") {
statusRef.current = "error";
setStatus("error");
}
// Если статус уже success, он остаётся success
setIsTestRunning(false);
setAudioLevel(0);
// Очищаем ресурсы после завершения теста
cleanupAudioResources();
}, 3000);
} catch (error) {
console.error("Ошибка доступа к микрофону:", error);
setStatus("error");
setIsTestRunning(false);
}
}
function cleanupAudioResources() {
// Отключаем source от analyser
if (sourceRef.current && analyserRef.current) {
try {
sourceRef.current.disconnect();
} catch {
// Ignore if already disconnected
}
sourceRef.current = null;
}
// Останавливаем все треки медиа-потока
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
// Закрываем AudioContext
if (audioContextRef.current) {
audioContextRef.current.close();
audioContextRef.current = null;
}
analyserRef.current = null;
}
function stopMicrophoneTest() {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (testTimeoutRef.current) {
clearTimeout(testTimeoutRef.current);
testTimeoutRef.current = null;
}
cleanupAudioResources();
setAudioLevel(0);
setIsTestRunning(false);
}
function restartMicrophoneTest() {
stopMicrophoneTest();
// Небольшая задержка перед запуском нового теста
setTimeout(() => {
startMicrophoneTest();
}, 100);
}
useEffect(() => {
startMicrophoneTest();
return () => {
stopMicrophoneTest();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedMicrophone]);
// Генерируем высоты для баров на основе уровня звука
function generateBarHeights() {
const baseHeights = [
3, 12, 16, 20, 44, 28, 34, 60, 8, 40, 18, 36, 24, 13, 10, 6,
];
const multiplier = Math.min(audioLevel / 50, 1.5);
return baseHeights.map((h) => Math.max(3, Math.min(60, h * multiplier)));
}
const barHeights = generateBarHeights();
return (
<ModalWrapper title="" className="max-w-[21.111vw]">
<div className="flex flex-col 2xl:gap-[1.667vw] gap-6 2xl:py-[1.667vw] py-6">
{/* Заголовок и описание */}
<div className="flex flex-col items-center 2xl:gap-[0.556vw] gap-2 text-center">
<h2 className="title-l font-medium">Говорите</h2>
<p className="text-s text-[#7D7D7D]">
Вы должны видеть движение на&nbsp;индикаторе
</p>
</div>
{/* Выбор микрофона */}
<div className="flex flex-col items-center 2xl:gap-[0.278vw] gap-1">
<p className="caption-xs text-[#7D7D7D] font-medium">Микрофон</p>
<Select
options={microphones.map((m) => m.label)}
defaultOption={selectedMicrophone}
onSelect={onSelectMicrophone}
/>
</div>
{/* Визуализация уровня звука */}
<div className="flex flex-col items-center 2xl:gap-[0.556vw] gap-2">
<div className="flex items-end justify-center 2xl:gap-[0.208vw] gap-[3px] 2xl:h-[4.167vw] h-[60px]">
{barHeights.map((height, index) => (
<div
key={index}
className="2xl:w-[0.208vw] w-[3px] bg-[#D6D6D6] 2xl:rounded-[0.556vw] rounded-lg transition-all duration-100"
style={{ height }}
/>
))}
</div>
{!isTestRunning && status === "success" && (
<p className="caption-s text-[#29AF61] font-medium">Звук есть!</p>
)}
{!isTestRunning && status === "error" && (
<p className="caption-s text-[#FF4517] font-medium">
Звук не обнаружен
</p>
)}
{isTestRunning && (
<p className="caption-s text-[#7D7D7D] font-medium">Проверка...</p>
)}
</div>
{/* Кнопки */}
<div className="flex flex-col 2xl:gap-[0.556vw] gap-2">
{/* Кнопка повторной проверки */}
<Button
variant="secondary"
size="large"
onClick={restartMicrophoneTest}
disabled={isTestRunning}
className="w-full"
>
<div className="2xl:size-[1.389vw] size-5">
<RestartIcon />
</div>
Повторить проверку
</Button>
{/* Кнопка завершения */}
<Button
variant="primary"
size="large"
onClick={onClose}
className="w-full"
>
Завершить
</Button>
</div>
</div>
</ModalWrapper>
);
}
export default VoiceCheckModal;