Files
stream.graff.tech-new/client/src/components/modals/VoiceCheckModal.tsx
T

321 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable react-hooks/exhaustive-deps */
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";
import useModalStore from "../../store/modalStore";
import SettingsModal from "./SettingsModal";
import clsx from "clsx";
import { isMediaDevicesSupported } from "../../lib/mediaDevices";
interface VoiceCheckModalProps {
selectedMicrophone: string;
microphones: { deviceId: string; label: string }[];
microphoneVolume: number;
onSelectMicrophone: (label: string) => void;
}
function VoiceCheckModal({
selectedMicrophone,
microphones,
microphoneVolume,
onSelectMicrophone,
}: VoiceCheckModalProps) {
const { setModal } = useModalStore();
const [audioLevel, setAudioLevel] = useState(0);
const [status, setStatus] = useState<"default" | "success" | "error">(
"default"
);
const [isTestRunning, setIsTestRunning] = useState(false);
const [soundDetected, setSoundDetected] = useState(false);
const [maxAudioLevel, setMaxAudioLevel] = useState(0);
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 gainNodeRef = useRef<GainNode | null>(null);
const animationFrameRef = useRef<number | null>(null);
const testTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const statusRef = useRef<"default" | "success" | "error">("default");
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);
// Обновляем максимальный уровень звука
setMaxAudioLevel((prev) => Math.max(prev, average));
// Если звук обнаружен и статус ещё не success, устанавливаем success
if (average > 5) {
if (statusRef.current !== "success") {
statusRef.current = "success";
setStatus("success");
}
setSoundDetected(true);
}
animationFrameRef.current = requestAnimationFrame(detectAudioLevel);
}
async function startMicrophoneTest() {
// Проверяем доступность API
if (!isMediaDevicesSupported()) {
console.error(
"navigator.mediaDevices недоступен. Возможно, требуется HTTPS или поддержка браузера."
);
setStatus("error");
setIsTestRunning(false);
return;
}
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 gainNode = audioContext.createGain();
gainNode.gain.value = microphoneVolume / 100;
gainNodeRef.current = gainNode;
const source = audioContext.createMediaStreamSource(stream);
sourceRef.current = source;
// Подключаем: source -> gainNode -> analyser -> destination
source.connect(gainNode);
gainNode.connect(analyser);
analyser.connect(audioContext.destination);
// Сбрасываем статус при новом тесте
statusRef.current = "default";
setStatus("default");
setSoundDetected(false);
setAudioLevel(0);
setMaxAudioLevel(0);
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);
// Очищаем ресурсы после завершения теста
cleanupAudioResources();
}, 3000);
} catch (error) {
console.error("Ошибка доступа к микрофону:", error);
setStatus("error");
setIsTestRunning(false);
}
}
function cleanupAudioResources() {
// Отключаем source
if (sourceRef.current) {
try {
sourceRef.current.disconnect();
} catch {
// Ignore if already disconnected
}
sourceRef.current = null;
}
// Отключаем gainNode
if (gainNodeRef.current) {
try {
gainNodeRef.current.disconnect();
} catch {
// Ignore if already disconnected
}
gainNodeRef.current = null;
}
// Отключаем analyser
if (analyserRef.current) {
try {
analyserRef.current.disconnect();
} catch {
// Ignore if already disconnected
}
analyserRef.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;
}
}
function stopMicrophoneTest() {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (testTimeoutRef.current) {
clearTimeout(testTimeoutRef.current);
testTimeoutRef.current = null;
}
cleanupAudioResources();
// НЕ сбрасываем audioLevel и maxAudioLevel - они сохраняются до нового теста
setSoundDetected(false);
setIsTestRunning(false);
}
function restartMicrophoneTest() {
stopMicrophoneTest();
// Небольшая задержка перед запуском нового теста
setTimeout(startMicrophoneTest, 100);
}
useEffect(() => {
startMicrophoneTest();
return stopMicrophoneTest;
}, [selectedMicrophone]);
// Обновляем громкость микрофона при изменении слайдера
useEffect(() => {
if (gainNodeRef.current) {
gainNodeRef.current.gain.value = microphoneVolume / 100;
}
}, [microphoneVolume]);
// Генерируем высоты для баров на основе уровня звука
function generateBarHeights() {
const baseHeights = [
3, 12, 16, 20, 44, 28, 34, 60, 8, 40, 18, 36, 24, 13, 10, 6,
];
// Используем maxAudioLevel для сохранения максимальной высоты
const levelToUse = isTestRunning ? audioLevel : maxAudioLevel;
const multiplier = Math.min(levelToUse / 5);
return baseHeights.map((h) => Math.max(3, Math.min(60, h * multiplier)));
}
const barHeights = generateBarHeights();
console.log(barHeights);
return (
<ModalWrapper className="2xl:max-w-[21.111vw] max-w-[304px]">
<div className="flex flex-col 2xl:gap-[1.667vw] gap-6">
{/* Выбор микрофона */}
<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-center justify-center 2xl:gap-[0.208vw] gap-[3px] 2xl:h-[4.167vw] h-[60px]">
{barHeights.map((height, index) => (
<div
key={index}
className={clsx(
`2xl:rounded-[0.556vw] rounded-lg transition-all duration-150 2xl:w-[0.208vw] w-[3px]`,
soundDetected ? "bg-[#7B60F3]" : "bg-[#D6D6D6]"
)}
style={{ height: `${(height / 1440) * innerWidth}px` }}
/>
))}
</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 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 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={() => setModal(<SettingsModal />)}
className="w-full"
>
Завершить
</Button>
</div>
</div>
</ModalWrapper>
);
}
export default VoiceCheckModal;