553 lines
21 KiB
TypeScript
553 lines
21 KiB
TypeScript
/* 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";
|
||
import Button from "../ui/Button";
|
||
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";
|
||
|
||
interface MediaDevice {
|
||
deviceId: string;
|
||
label: string;
|
||
}
|
||
|
||
function SettingsModal() {
|
||
const [microphoneVolume, setMicrophoneVolume] = useState(50);
|
||
const [speakerVolume, setSpeakerVolume] = useState(50);
|
||
|
||
// Списки устройств
|
||
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})`,
|
||
};
|
||
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}
|
||
microphoneVolume={microphoneVolume}
|
||
onSelectMicrophone={setSelectedMicrophone}
|
||
/>
|
||
);
|
||
};
|
||
|
||
// Открыть модальное окно проверки динамика
|
||
const openSpeakerCheck = () => {
|
||
setModal(
|
||
<SoundCheckModal
|
||
selectedSpeaker={selectedSpeaker}
|
||
speakers={speakers}
|
||
onSelectSpeaker={setSelectedSpeaker}
|
||
speakerVolume={speakerVolume}
|
||
/>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<ModalWrapper
|
||
title="Настройки"
|
||
className="2xl:max-w-[27.778vw] max-w-[400px]"
|
||
>
|
||
<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"}
|
||
onClick={() => setMediaType("sound")}
|
||
isActive={mediaType === "sound"}
|
||
>
|
||
<div
|
||
className={clsx(
|
||
"2xl:size-[1.111vw] size-4",
|
||
mediaType === "sound" ? "text-[#7B60F3]" : "text-[#7D7D7D]"
|
||
)}
|
||
>
|
||
<SoundIcon />
|
||
</div>
|
||
<p className="font-medium">Звук</p>
|
||
</Button>
|
||
<Button
|
||
variant="menu"
|
||
size="large"
|
||
className="w-full"
|
||
onClick={() => setMediaType("video")}
|
||
isActive={mediaType === "video"}
|
||
>
|
||
<div
|
||
className={clsx(
|
||
"2xl:size-[1.111vw] size-4",
|
||
mediaType === "video" ? "text-[#7B60F3]" : "text-[#7D7D7D]"
|
||
)}
|
||
>
|
||
<VideoFilledIcon />
|
||
</div>
|
||
<p className="font-medium">Видео</p>
|
||
</Button>
|
||
</div>
|
||
{mediaType === "sound" && (
|
||
<div className="2xl:space-y-[1.667vw] space-y-6">
|
||
<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">
|
||
{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] gap-2">
|
||
<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] gap-3">
|
||
<div className="2xl:size-[1.111vw] size-4 text-[#7D7D7D]">
|
||
<MicrophoneFilledIcon />
|
||
</div>
|
||
<RangeInput
|
||
value={microphoneVolume}
|
||
onChange={setMicrophoneVolume}
|
||
/>
|
||
<p className="caption-xs font-medium text-[#7D7D7D] 2xl:w-[1.667vw] w-6">
|
||
{microphoneVolume.toFixed(0)}%
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<hr className="bg-[#F6F6F6] 2xl:h-[0.069vw] h-px" />
|
||
<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">
|
||
{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] gap-2">
|
||
<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] gap-3">
|
||
<div className="2xl:size-[1.111vw] size-4 text-[#7D7D7D]">
|
||
<SoundIcon />
|
||
</div>
|
||
<RangeInput
|
||
value={speakerVolume}
|
||
onChange={setSpeakerVolume}
|
||
/>
|
||
<p className="caption-xs font-medium text-[#7D7D7D] 2xl:w-[1.667vw] w-6">
|
||
{speakerVolume.toFixed(0)}%
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{mediaType === "video" && (
|
||
<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>
|
||
{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 ? (
|
||
<Select
|
||
options={cameras.map((c) => c.label)}
|
||
onSelect={setSelectedCamera}
|
||
defaultOption={selectedCamera}
|
||
/>
|
||
) : null}
|
||
<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"
|
||
/>
|
||
{!isVideoTesting && (
|
||
<div className="bg-[#F3F3F3] 2xl:w-[25vw] w-[360px] aspect-[360/202] flex justify-center items-center 2xl:rounded-[1.111vw] rounded-2xl">
|
||
{cameras.length === 0 && !isLoadingCameras ? (
|
||
<div className="2xl:space-y-[0.556vw] space-y-2 text-center 2xl:px-[1.111vw] px-4">
|
||
<p className="title-s font-medium">Камеры не найдены</p>
|
||
<p className="text-[#7D7D7D] caption-s font-medium">
|
||
Проверьте подключение устройства и разрешения браузера
|
||
</p>
|
||
</div>
|
||
) : 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>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center 2xl:gap-[0.556vw] gap-2">
|
||
<Switch
|
||
enabled={participantsVideosHidden}
|
||
onChange={setParticipantsVideosHidden}
|
||
/>
|
||
<p className="text-m">Скрыть видео участников</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ModalWrapper>
|
||
);
|
||
}
|
||
|
||
export default SettingsModal;
|