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
+458 -47
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
/* eslint-disable react-hooks/exhaustive-deps */
import { useState, useRef, useEffect } from "react";
import SoundIcon from "../icons/SoundIcon";
import VideoFilledIcon from "../icons/VideoFilledIcon";
import ModalWrapper from "../ModalWrapper";
@@ -7,38 +8,333 @@ import RangeInput from "../ui/RangeInput";
import Select from "../ui/Select";
import MicrophoneFilledIcon from "../icons/MicrophoneFilledIcon";
import Switch from "../ui/Switch";
import clsx from "clsx";
import useModalStore from "../../store/modalStore";
import SoundCheckModal from "./SoundCheckModal";
import VoiceCheckModal from "./VoiceCheckModal";
import LoaderIcon from "../icons/LoaderIcon";
const microphoneOptions = ["Realtek HD Microphone"];
const speakerOptions = ["Realtek HD Audio"];
const cameraOptions = ["Realtek HD Camera"];
interface MediaDevice {
deviceId: string;
label: string;
}
function SettingsModal() {
const [microphoneVolume, setMicrophoneVolume] = useState(50);
const [speakerVolume, setSpeakerVolume] = useState(50);
const [selectedMicrophone, setSelectedMicrophone] = useState(
microphoneOptions[0]
);
const [selectedSpeaker, setSelectedSpeaker] = useState(speakerOptions[0]);
const [camera, setCamera] = useState(cameraOptions[0]);
// Списки устройств
const [microphones, setMicrophones] = useState<MediaDevice[]>([]);
const [speakers, setSpeakers] = useState<MediaDevice[]>([]);
const [cameras, setCameras] = useState<MediaDevice[]>([]);
// Выбранные устройства
const [selectedMicrophone, setSelectedMicrophone] = useState<string>("");
const [selectedSpeaker, setSelectedSpeaker] = useState<string>("");
const [selectedCamera, setSelectedCamera] = useState<string>("");
const [mediaType, setMediaType] = useState<"sound" | "video">("sound");
const [participantsVideosHidden, setParticipantsVideosHidden] =
useState(false);
const [isVideoTestingError, setIsVideoTestingError] = useState(false);
const [isVideoTestingLoading, setIsVideoTestingLoading] = useState(false);
const [isVideoTesting, setIsVideoTesting] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const [isLoadingMicrophones, setIsLoadingMicrophones] = useState(false);
const [isLoadingSpeakers, setIsLoadingSpeakers] = useState(false);
const [isLoadingCameras, setIsLoadingCameras] = useState(false);
const { setModal } = useModalStore();
// Остановка видео
function stopVideoTest() {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
if (videoRef.current) videoRef.current.srcObject = null;
setIsVideoTesting(false);
}
// Загрузка аудио устройств
async function loadAudioDevices() {
setIsLoadingMicrophones(true);
setIsLoadingSpeakers(true);
try {
// Запрашиваем разрешения на аудио
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
// Сразу останавливаем стрим после получения разрешений
stream.getTracks().forEach((track) => track.stop());
const devices = await navigator.mediaDevices.enumerateDevices();
console.log("Найдено аудио устройств:", devices);
const audioInputs: MediaDevice[] = [];
const audioOutputs: MediaDevice[] = [];
devices.forEach((device) => {
const deviceInfo: MediaDevice = {
deviceId: device.deviceId,
label:
device.label || `${device.kind} (${device.deviceId.slice(0, 8)})`,
};
if (device.kind === "audioinput") {
audioInputs.push(deviceInfo);
} else if (device.kind === "audiooutput") {
audioOutputs.push(deviceInfo);
}
});
console.log("Микрофоны:", audioInputs);
console.log("Динамики:", audioOutputs);
setMicrophones(audioInputs);
setIsLoadingMicrophones(false);
setSpeakers(audioOutputs);
setIsLoadingSpeakers(false);
// Устанавливаем первое устройство по умолчанию
if (audioInputs.length > 0 && !selectedMicrophone) {
setSelectedMicrophone(audioInputs[0].label);
}
if (audioOutputs.length > 0 && !selectedSpeaker) {
setSelectedSpeaker(audioOutputs[0].label);
}
} catch (error) {
console.error("Ошибка при загрузке аудио устройств:", error);
// Пробуем загрузить устройства без разрешений
try {
const devices = await navigator.mediaDevices.enumerateDevices();
console.log("Аудио устройства без разрешений:", devices);
const audioInputs: MediaDevice[] = [];
const audioOutputs: MediaDevice[] = [];
devices.forEach((device) => {
const deviceInfo: MediaDevice = {
deviceId: device.deviceId,
label: device.label || `${device.kind} (требуется разрешение)`,
};
if (device.kind === "audioinput") {
audioInputs.push(deviceInfo);
} else if (device.kind === "audiooutput") {
audioOutputs.push(deviceInfo);
}
});
setMicrophones(audioInputs);
setSpeakers(audioOutputs);
} catch (err) {
console.error("Не удалось загрузить аудио устройства:", err);
} finally {
setIsLoadingMicrophones(false);
setIsLoadingSpeakers(false);
}
}
}
// Загрузка видео устройств
async function loadVideoDevices() {
setIsLoadingCameras(true);
try {
// Запрашиваем разрешения на видео
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
});
// Сразу останавливаем стрим после получения разрешений
stream.getTracks().forEach((track) => track.stop());
const devices = await navigator.mediaDevices.enumerateDevices();
console.log("Найдено видео устройств:", devices);
const videoInputs: MediaDevice[] = [];
devices.forEach((device) => {
if (device.kind === "videoinput") {
const deviceInfo: MediaDevice = {
deviceId: device.deviceId,
label:
device.label || `${device.kind} (${device.deviceId.slice(0, 8)})`,
};
videoInputs.push(deviceInfo);
}
});
console.log("Камеры:", videoInputs);
setCameras(videoInputs);
setIsLoadingCameras(false);
// Устанавливаем первое устройство по умолчанию
if (videoInputs.length > 0 && !selectedCamera) {
setSelectedCamera(videoInputs[0].label);
}
} catch (error) {
console.error("Ошибка при загрузке видео устройств:", error);
// Пробуем загрузить устройства без разрешений
try {
const devices = await navigator.mediaDevices.enumerateDevices();
console.log("Видео устройства без разрешений:", devices);
const videoInputs: MediaDevice[] = [];
devices.forEach((device) => {
if (device.kind === "videoinput") {
const deviceInfo: MediaDevice = {
deviceId: device.deviceId,
label: device.label || `${device.kind} (требуется разрешение)`,
};
videoInputs.push(deviceInfo);
}
});
setCameras(videoInputs);
} catch (err) {
console.error("Не удалось загрузить видео устройства:", err);
} finally {
setIsLoadingCameras(false);
}
}
}
// Запуск видео
async function startVideoTest() {
try {
setIsVideoTestingLoading(true);
setIsVideoTestingError(false);
// Находим deviceId выбранной камеры
const selectedCameraDevice = cameras.find(
(cam) => cam.label === selectedCamera
);
const constraints: MediaStreamConstraints = {
video: selectedCameraDevice
? { deviceId: { exact: selectedCameraDevice.deviceId } }
: true,
audio: false,
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = stream;
if (videoRef.current) videoRef.current.srcObject = stream;
setIsVideoTesting(true);
} catch (error) {
console.error("Ошибка при доступе к камере:", error);
setIsVideoTestingError(true);
} finally {
setIsVideoTestingLoading(false);
}
}
// Загружаем только аудио устройства при монтировании
useEffect(() => {
loadAudioDevices();
// Слушаем изменения устройств (подключение/отключение)
const handleDeviceChange = () => {
// Загружаем аудио всегда
loadAudioDevices();
// Загружаем видео только если находимся на вкладке "Видео"
if (mediaType === "video") {
loadVideoDevices();
}
};
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
return () => {
navigator.mediaDevices.removeEventListener(
"devicechange",
handleDeviceChange
);
};
}, [mediaType]);
// Загружаем видео устройства и запускаем видео при переключении на вкладку "Видео"
useEffect(() => {
if (mediaType === "video") {
// Всегда загружаем камеры при переходе на вкладку, если их еще нет
if (cameras.length === 0 && !isLoadingCameras) {
loadVideoDevices();
}
// Запускаем видео только если камеры уже загружены
if (cameras.length > 0) {
startVideoTest();
}
return stopVideoTest;
}
}, [mediaType, cameras.length]);
// Перезапускаем видео при смене камеры
useEffect(() => {
if (isVideoTesting && selectedCamera) {
stopVideoTest();
startVideoTest();
}
}, [selectedCamera]);
// Открыть модальное окно проверки микрофона
const openMicrophoneCheck = () => {
setModal(
<VoiceCheckModal
selectedMicrophone={selectedMicrophone}
microphones={microphones}
onSelectMicrophone={setSelectedMicrophone}
onClose={() => setModal(null)}
/>
);
};
// Открыть модальное окно проверки динамика
const openSpeakerCheck = () => {
setModal(
<SoundCheckModal
selectedSpeaker={selectedSpeaker}
speakers={speakers}
onSelectSpeaker={setSelectedSpeaker}
onClose={() => setModal(null)}
/>
);
};
return (
<ModalWrapper title="Настройки">
<ModalWrapper title="Настройки" className="max-w-[27.778vw]">
<div className="2xl:space-y-[1.389vw] space-y-5 2xl:-mt-[1.389vw] -mt-5">
<div className="flex">
<Button
variant="menu"
size="large"
className="w-full"
className={"w-full"}
onClick={() => setMediaType("sound")}
isActive={mediaType === "sound"}
>
<div className="flex 2xl:gap-[0.556vw] items-center">
<div className="2xl:size-[1.111vw] size-4 text-[#7D7D7D]">
<div
className={clsx(
"2xl:size-[1.111vw] size-4",
mediaType === "sound" ? "text-[#7B60F3]" : "text-[#7D7D7D]"
)}
>
<SoundIcon />
</div>
<p className="font-medium">Звук</p>
@@ -49,9 +345,15 @@ function SettingsModal() {
size="large"
className="w-full"
onClick={() => setMediaType("video")}
isActive={mediaType === "video"}
>
<div className="flex 2xl:gap-[0.556vw] items-center">
<div className="2xl:size-[1.111vw] size-4 text-[#7D7D7D]">
<div
className={clsx(
"2xl:size-[1.111vw] size-4",
mediaType === "video" ? "text-[#7B60F3]" : "text-[#7D7D7D]"
)}
>
<VideoFilledIcon />
</div>
<p className="font-medium">Видео</p>
@@ -63,17 +365,48 @@ function SettingsModal() {
<div className="2xl:space-y-[0.833vw] space-y-3">
<p className="title-s font-medium">Микрофон</p>
<div className="2xl:space-y-[1.111vw] space-y-4">
<div className="flex 2xl:gap-[0.556vw]">
<Select
className="flex-1"
options={microphoneOptions}
defaultOption={selectedMicrophone}
onSelect={setSelectedMicrophone}
/>
<Button variant="cta" size="large">
Проверить
</Button>
</div>
{isLoadingMicrophones ? (
<div className="bg-[#F3F3F3] 2xl:p-[1.667vw] p-6 2xl:rounded-[1.111vw] rounded-2xl flex flex-col items-center 2xl:gap-[0.833vw] gap-3">
<div className="2xl:size-[2.222vw] size-8 text-[#7B60F3] animate-spin">
<LoaderIcon />
</div>
<p className="text-s text-[#7D7D7D]">
Загрузка микрофонов...
</p>
</div>
) : microphones.length === 0 ? (
<div className="bg-[#FEF3F2] 2xl:p-[1.111vw] p-4 2xl:rounded-[1.111vw] rounded-2xl 2xl:space-y-[0.556vw] space-y-2">
<p className="text-s text-[#FF4517]">
Микрофоны не найдены. Проверьте подключение устройства и
разрешения браузера.
</p>
<Button
variant="secondary"
size="medium"
onClick={loadAudioDevices}
className="w-full"
>
Обновить устройства
</Button>
</div>
) : (
<div className="flex 2xl:gap-[0.556vw]">
<Select
className="flex-1"
options={microphones.map((m) => m.label)}
defaultOption={selectedMicrophone}
onSelect={setSelectedMicrophone}
/>
<Button
variant="cta"
size="large"
onClick={openMicrophoneCheck}
disabled={!selectedMicrophone}
>
Проверить
</Button>
</div>
)}
<div className="flex items-center 2xl:gap-[0.833vw]">
<div className="2xl:size-[1.111vw] size-4 text-[#7D7D7D]">
<MicrophoneFilledIcon />
@@ -92,17 +425,48 @@ function SettingsModal() {
<div className="2xl:space-y-[0.833vw] space-y-3">
<p className="title-s font-medium">Динамик</p>
<div className="2xl:space-y-[1.111vw] space-y-4">
<div className="flex 2xl:gap-[0.556vw]">
<Select
className="flex-1"
options={speakerOptions}
defaultOption={selectedSpeaker}
onSelect={setSelectedSpeaker}
/>
<Button variant="cta" size="large">
Проверить
</Button>
</div>
{isLoadingSpeakers ? (
<div className="bg-[#F3F3F3] 2xl:p-[1.667vw] p-6 2xl:rounded-[1.111vw] rounded-2xl flex flex-col items-center 2xl:gap-[0.833vw] gap-3">
<div className="2xl:size-[2.222vw] size-8 text-[#7B60F3] animate-spin">
<LoaderIcon />
</div>
<p className="text-s text-[#7D7D7D]">
Загрузка динамиков...
</p>
</div>
) : speakers.length === 0 ? (
<div className="bg-[#FEF3F2] 2xl:p-[1.111vw] p-4 2xl:rounded-[1.111vw] rounded-2xl 2xl:space-y-[0.556vw] space-y-2">
<p className="text-s text-[#FF4517]">
Динамики не найдены. Проверьте подключение устройства и
разрешения браузера.
</p>
<Button
variant="secondary"
size="medium"
onClick={loadAudioDevices}
className="w-full"
>
Обновить устройства
</Button>
</div>
) : (
<div className="flex 2xl:gap-[0.556vw]">
<Select
className="flex-1"
options={speakers.map((s) => s.label)}
defaultOption={selectedSpeaker}
onSelect={setSelectedSpeaker}
/>
<Button
variant="cta"
size="large"
onClick={openSpeakerCheck}
disabled={!selectedSpeaker}
>
Проверить
</Button>
</div>
)}
<div className="flex items-center 2xl:gap-[0.833vw]">
<div className="2xl:size-[1.111vw] size-4 text-[#7D7D7D]">
<SoundIcon />
@@ -123,24 +487,71 @@ function SettingsModal() {
<div className="2xl:space-y-[1.667vw] space-y-6">
<div className="2xl:space-y-[0.556vw] space-y-2">
<p className="title-s font-medium">Камера</p>
<Select
options={cameraOptions}
onSelect={setCamera}
defaultOption={camera}
{isLoadingCameras ? (
<div className="bg-[#F3F3F3] 2xl:p-[1.667vw] p-6 2xl:rounded-[1.111vw] rounded-2xl flex flex-col items-center 2xl:gap-[0.833vw] gap-3">
<div className="2xl:size-[2.222vw] size-8 text-[#7B60F3] animate-spin">
<LoaderIcon />
</div>
<p className="text-s text-[#7D7D7D]">Загрузка камер...</p>
</div>
) : cameras.length === 0 ? (
<div className="bg-[#FEF3F2] 2xl:p-[1.111vw] p-4 2xl:rounded-[1.111vw] rounded-2xl 2xl:space-y-[0.556vw] space-y-2">
<p className="text-s text-[#FF4517]">
Камеры не найдены. Проверьте подключение устройства и
разрешения браузера.
</p>
<Button
variant="secondary"
size="medium"
onClick={loadVideoDevices}
className="w-full"
>
Обновить устройства
</Button>
</div>
) : (
<Select
options={cameras.map((c) => c.label)}
onSelect={setSelectedCamera}
defaultOption={selectedCamera}
/>
)}
<video
ref={videoRef}
muted
autoPlay
playsInline
hidden={!isVideoTesting}
className="2xl:rounded-[1.111vw] rounded-2xl 2xl:w-[25vw] w-[360px] aspect-[360/202] object-cover"
/>
<div className="2xl:w-[25vw] w-[360px] aspect-[360/202] bg-[#F6F6F6] 2xl:rounded-[1.111vw] rounded-2xl" />
{!isVideoTesting && (
<div className="bg-[#F3F3F3] 2xl:w-[25vw] w-[360px] aspect-[360/202] flex justify-center items-center 2xl:rounded-[1.111vw] rounded-2xl">
{isVideoTestingError && (
<div className="2xl:space-y-[0.556vw] space-y-2">
<p className="title-s font-medium text-center">
Включить видео не удалось
</p>
<p className="text-center text-[#7D7D7D] caption-s font-medium">
Проверьте подключение камеры и разрешения
</p>
</div>
)}
{isVideoTestingLoading && (
<div className="2xl:space-y-[0.556vw] space-y-2">
<p className="title-s font-medium text-center">
Проверка видео...
</p>
</div>
)}
</div>
)}
</div>
<div className="flex items-start 2xl:gap-[0.556vw] gap-2">
<div className="flex items-center 2xl:gap-[0.556vw] gap-2">
<Switch
enabled={participantsVideosHidden}
onChange={setParticipantsVideosHidden}
/>
<div className="2xl:space-y-[0.278vw] space-y-1">
<p className="text-m">Скрыть видео участников</p>
<p className="caption-s text-[#7D7D7D]">
Это снизит нагрузку на сеть
</p>
</div>
<p className="text-m">Скрыть видео участников</p>
</div>
</div>
)}