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:
@@ -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 SoundIcon from "../icons/SoundIcon";
|
||||||
import VideoFilledIcon from "../icons/VideoFilledIcon";
|
import VideoFilledIcon from "../icons/VideoFilledIcon";
|
||||||
import ModalWrapper from "../ModalWrapper";
|
import ModalWrapper from "../ModalWrapper";
|
||||||
@@ -7,38 +8,333 @@ import RangeInput from "../ui/RangeInput";
|
|||||||
import Select from "../ui/Select";
|
import Select from "../ui/Select";
|
||||||
import MicrophoneFilledIcon from "../icons/MicrophoneFilledIcon";
|
import MicrophoneFilledIcon from "../icons/MicrophoneFilledIcon";
|
||||||
import Switch from "../ui/Switch";
|
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"];
|
interface MediaDevice {
|
||||||
const speakerOptions = ["Realtek HD Audio"];
|
deviceId: string;
|
||||||
const cameraOptions = ["Realtek HD Camera"];
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
function SettingsModal() {
|
function SettingsModal() {
|
||||||
const [microphoneVolume, setMicrophoneVolume] = useState(50);
|
const [microphoneVolume, setMicrophoneVolume] = useState(50);
|
||||||
const [speakerVolume, setSpeakerVolume] = useState(50);
|
const [speakerVolume, setSpeakerVolume] = useState(50);
|
||||||
|
|
||||||
const [selectedMicrophone, setSelectedMicrophone] = useState(
|
// Списки устройств
|
||||||
microphoneOptions[0]
|
const [microphones, setMicrophones] = useState<MediaDevice[]>([]);
|
||||||
);
|
const [speakers, setSpeakers] = useState<MediaDevice[]>([]);
|
||||||
const [selectedSpeaker, setSelectedSpeaker] = useState(speakerOptions[0]);
|
const [cameras, setCameras] = useState<MediaDevice[]>([]);
|
||||||
const [camera, setCamera] = useState(cameraOptions[0]);
|
|
||||||
|
// Выбранные устройства
|
||||||
|
const [selectedMicrophone, setSelectedMicrophone] = useState<string>("");
|
||||||
|
const [selectedSpeaker, setSelectedSpeaker] = useState<string>("");
|
||||||
|
const [selectedCamera, setSelectedCamera] = useState<string>("");
|
||||||
|
|
||||||
const [mediaType, setMediaType] = useState<"sound" | "video">("sound");
|
const [mediaType, setMediaType] = useState<"sound" | "video">("sound");
|
||||||
|
|
||||||
const [participantsVideosHidden, setParticipantsVideosHidden] =
|
const [participantsVideosHidden, setParticipantsVideosHidden] =
|
||||||
useState(false);
|
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 (
|
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="2xl:space-y-[1.389vw] space-y-5 2xl:-mt-[1.389vw] -mt-5">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<Button
|
<Button
|
||||||
variant="menu"
|
variant="menu"
|
||||||
size="large"
|
size="large"
|
||||||
className="w-full"
|
className={"w-full"}
|
||||||
onClick={() => setMediaType("sound")}
|
onClick={() => setMediaType("sound")}
|
||||||
|
isActive={mediaType === "sound"}
|
||||||
>
|
>
|
||||||
<div className="flex 2xl:gap-[0.556vw] items-center">
|
<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 />
|
<SoundIcon />
|
||||||
</div>
|
</div>
|
||||||
<p className="font-medium">Звук</p>
|
<p className="font-medium">Звук</p>
|
||||||
@@ -49,9 +345,15 @@ function SettingsModal() {
|
|||||||
size="large"
|
size="large"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => setMediaType("video")}
|
onClick={() => setMediaType("video")}
|
||||||
|
isActive={mediaType === "video"}
|
||||||
>
|
>
|
||||||
<div className="flex 2xl:gap-[0.556vw] items-center">
|
<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 />
|
<VideoFilledIcon />
|
||||||
</div>
|
</div>
|
||||||
<p className="font-medium">Видео</p>
|
<p className="font-medium">Видео</p>
|
||||||
@@ -63,17 +365,48 @@ function SettingsModal() {
|
|||||||
<div className="2xl:space-y-[0.833vw] space-y-3">
|
<div className="2xl:space-y-[0.833vw] space-y-3">
|
||||||
<p className="title-s font-medium">Микрофон</p>
|
<p className="title-s font-medium">Микрофон</p>
|
||||||
<div className="2xl:space-y-[1.111vw] space-y-4">
|
<div className="2xl:space-y-[1.111vw] space-y-4">
|
||||||
<div className="flex 2xl:gap-[0.556vw]">
|
{isLoadingMicrophones ? (
|
||||||
<Select
|
<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">
|
||||||
className="flex-1"
|
<div className="2xl:size-[2.222vw] size-8 text-[#7B60F3] animate-spin">
|
||||||
options={microphoneOptions}
|
<LoaderIcon />
|
||||||
defaultOption={selectedMicrophone}
|
</div>
|
||||||
onSelect={setSelectedMicrophone}
|
<p className="text-s text-[#7D7D7D]">
|
||||||
/>
|
Загрузка микрофонов...
|
||||||
<Button variant="cta" size="large">
|
</p>
|
||||||
Проверить
|
</div>
|
||||||
</Button>
|
) : microphones.length === 0 ? (
|
||||||
</div>
|
<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="flex items-center 2xl:gap-[0.833vw]">
|
||||||
<div className="2xl:size-[1.111vw] size-4 text-[#7D7D7D]">
|
<div className="2xl:size-[1.111vw] size-4 text-[#7D7D7D]">
|
||||||
<MicrophoneFilledIcon />
|
<MicrophoneFilledIcon />
|
||||||
@@ -92,17 +425,48 @@ function SettingsModal() {
|
|||||||
<div className="2xl:space-y-[0.833vw] space-y-3">
|
<div className="2xl:space-y-[0.833vw] space-y-3">
|
||||||
<p className="title-s font-medium">Динамик</p>
|
<p className="title-s font-medium">Динамик</p>
|
||||||
<div className="2xl:space-y-[1.111vw] space-y-4">
|
<div className="2xl:space-y-[1.111vw] space-y-4">
|
||||||
<div className="flex 2xl:gap-[0.556vw]">
|
{isLoadingSpeakers ? (
|
||||||
<Select
|
<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">
|
||||||
className="flex-1"
|
<div className="2xl:size-[2.222vw] size-8 text-[#7B60F3] animate-spin">
|
||||||
options={speakerOptions}
|
<LoaderIcon />
|
||||||
defaultOption={selectedSpeaker}
|
</div>
|
||||||
onSelect={setSelectedSpeaker}
|
<p className="text-s text-[#7D7D7D]">
|
||||||
/>
|
Загрузка динамиков...
|
||||||
<Button variant="cta" size="large">
|
</p>
|
||||||
Проверить
|
</div>
|
||||||
</Button>
|
) : speakers.length === 0 ? (
|
||||||
</div>
|
<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="flex items-center 2xl:gap-[0.833vw]">
|
||||||
<div className="2xl:size-[1.111vw] size-4 text-[#7D7D7D]">
|
<div className="2xl:size-[1.111vw] size-4 text-[#7D7D7D]">
|
||||||
<SoundIcon />
|
<SoundIcon />
|
||||||
@@ -123,24 +487,71 @@ function SettingsModal() {
|
|||||||
<div className="2xl:space-y-[1.667vw] space-y-6">
|
<div className="2xl:space-y-[1.667vw] space-y-6">
|
||||||
<div className="2xl:space-y-[0.556vw] space-y-2">
|
<div className="2xl:space-y-[0.556vw] space-y-2">
|
||||||
<p className="title-s font-medium">Камера</p>
|
<p className="title-s font-medium">Камера</p>
|
||||||
<Select
|
{isLoadingCameras ? (
|
||||||
options={cameraOptions}
|
<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">
|
||||||
onSelect={setCamera}
|
<div className="2xl:size-[2.222vw] size-8 text-[#7B60F3] animate-spin">
|
||||||
defaultOption={camera}
|
<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>
|
||||||
<div className="flex items-start 2xl:gap-[0.556vw] gap-2">
|
<div className="flex items-center 2xl:gap-[0.556vw] gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
enabled={participantsVideosHidden}
|
enabled={participantsVideosHidden}
|
||||||
onChange={setParticipantsVideosHidden}
|
onChange={setParticipantsVideosHidden}
|
||||||
/>
|
/>
|
||||||
<div className="2xl:space-y-[0.278vw] space-y-1">
|
<p className="text-m">Скрыть видео участников</p>
|
||||||
<p className="text-m">Скрыть видео участников</p>
|
|
||||||
<p className="caption-s text-[#7D7D7D]">
|
|
||||||
Это снизит нагрузку на сеть
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useClickAway } from "@uidotdev/usehooks";
|
||||||
|
import ModalWrapper from "../ModalWrapper";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import ChevronDownIcon from "../icons/ChevronDownIcon";
|
||||||
|
import RestartIcon from "../icons/RestartIcon";
|
||||||
|
|
||||||
|
interface SoundCheckModalProps {
|
||||||
|
selectedSpeaker: string;
|
||||||
|
speakers: { deviceId: string; label: string }[];
|
||||||
|
onSelectSpeaker: (label: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SoundCheckModal({
|
||||||
|
selectedSpeaker,
|
||||||
|
speakers,
|
||||||
|
onSelectSpeaker,
|
||||||
|
onClose,
|
||||||
|
}: SoundCheckModalProps) {
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
const dropdownRef = useClickAway<HTMLDivElement>(() =>
|
||||||
|
setIsDropdownOpen(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Очистка AudioContext при размонтировании
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (audioContextRef.current) {
|
||||||
|
audioContextRef.current.close();
|
||||||
|
audioContextRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const playTestSound = () => {
|
||||||
|
// Создаём AudioContext если его нет
|
||||||
|
if (!audioContextRef.current) {
|
||||||
|
audioContextRef.current = new AudioContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioContext = audioContextRef.current;
|
||||||
|
const oscillator = audioContext.createOscillator();
|
||||||
|
const gainNode = audioContext.createGain();
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(audioContext.destination);
|
||||||
|
|
||||||
|
// Настраиваем звук (440 Hz - нота A)
|
||||||
|
oscillator.frequency.value = 440;
|
||||||
|
oscillator.type = "sine";
|
||||||
|
|
||||||
|
// Плавное затухание
|
||||||
|
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(
|
||||||
|
0.01,
|
||||||
|
audioContext.currentTime + 1
|
||||||
|
);
|
||||||
|
|
||||||
|
// Играем звук 1 секунду
|
||||||
|
oscillator.start(audioContext.currentTime);
|
||||||
|
oscillator.stop(audioContext.currentTime + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalWrapper title="" className="max-w-[21.111vw]">
|
||||||
|
<div className="flex flex-col 2xl:gap-[1.667vw] gap-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]">
|
||||||
|
Если нет, проверьте громкость и подключение динамиков
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center 2xl:gap-[0.278vw] gap-1">
|
||||||
|
<p className="caption-xs text-[#7D7D7D]">Динамик</p>
|
||||||
|
<div ref={dropdownRef} className="relative w-full">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
|
className="w-full bg-[#F3F3F3] 2xl:py-[0.972vw] py-3.5 2xl:px-[1.111vw] px-4 2xl:rounded-[1.111vw] rounded-2xl flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span className="button-m">{selectedSpeaker}</span>
|
||||||
|
<div
|
||||||
|
className={`2xl:size-[1.111vw] size-4 text-[#7D7D7D] transition-transform ${
|
||||||
|
isDropdownOpen ? "rotate-180" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<div className="absolute top-full left-0 w-full bg-[#F3F3F3] 2xl:rounded-b-[1.111vw] rounded-b-2xl overflow-hidden z-10">
|
||||||
|
{speakers.map((speaker) => (
|
||||||
|
<button
|
||||||
|
key={speaker.deviceId}
|
||||||
|
onClick={() => {
|
||||||
|
onSelectSpeaker(speaker.label);
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left 2xl:py-[0.972vw] py-3.5 2xl:px-[1.111vw] px-4 button-m hover:bg-[#f0f0f0] transition-colors"
|
||||||
|
>
|
||||||
|
{speaker.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sound Wave Visualization */}
|
||||||
|
<div className="flex flex-col items-center 2xl:gap-[0.833vw] gap-3">
|
||||||
|
<div className="flex items-end justify-center 2xl:gap-[0.278vw] gap-1 2xl:h-[4.167vw] h-[60px]">
|
||||||
|
{[3, 12, 16, 20, 44, 28, 34, 60, 8, 40, 18, 36, 24, 13, 10, 6].map(
|
||||||
|
(height, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="2xl:w-[0.208vw] w-[3px] bg-[#D6D6D6] 2xl:rounded-[0.556vw] rounded-lg"
|
||||||
|
style={{ height: `${height}px` }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={playTestSound}
|
||||||
|
className="2xl:size-[2.778vw] size-10 rounded-full bg-[#7B60F3] hover:bg-[#9184F6] active:bg-[#B3AAF9] flex items-center justify-center text-white transition-colors"
|
||||||
|
>
|
||||||
|
<div className="2xl:size-[1.111vw] size-4">
|
||||||
|
<RestartIcon />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="primary" size="large" onClick={onClose}>
|
||||||
|
Завершить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ModalWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SoundCheckModal;
|
||||||
@@ -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]">
|
||||||
|
Вы должны видеть движение на индикаторе
|
||||||
|
</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;
|
||||||
@@ -6,6 +6,7 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
className?: string;
|
className?: string;
|
||||||
size?: "small" | "medium" | "large";
|
size?: "small" | "medium" | "large";
|
||||||
ref?: React.RefObject<HTMLButtonElement | null>;
|
ref?: React.RefObject<HTMLButtonElement | null>;
|
||||||
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
@@ -16,6 +17,7 @@ function Button({
|
|||||||
ref,
|
ref,
|
||||||
type,
|
type,
|
||||||
onClick,
|
onClick,
|
||||||
|
isActive,
|
||||||
...props
|
...props
|
||||||
}: ButtonProps) {
|
}: ButtonProps) {
|
||||||
return (
|
return (
|
||||||
@@ -28,6 +30,7 @@ function Button({
|
|||||||
}}
|
}}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"transition-all select-none cursor-pointer disabled:!cursor-default flex outline-none gap-2 items-center justify-center font-medium disabled:bg-[#F6F6F6] disabled:!text-[#D6D6D6]",
|
"transition-all select-none cursor-pointer disabled:!cursor-default flex outline-none gap-2 items-center justify-center font-medium disabled:bg-[#F6F6F6] disabled:!text-[#D6D6D6]",
|
||||||
|
isActive && "bg-[#F3F1FD] !text-[#7B60F3]",
|
||||||
variant === "menu" &&
|
variant === "menu" &&
|
||||||
"text-[#7D7D7D] hover:bg-[#F3F3F3] active:bg-[#F3F1FD] active:text-[#7B60F3]",
|
"text-[#7D7D7D] hover:bg-[#F3F3F3] active:bg-[#F3F1FD] active:text-[#7B60F3]",
|
||||||
variant === "cta" &&
|
variant === "cta" &&
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useClickAway } from "@uidotdev/usehooks";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import ChevronDownIcon from "../icons/ChevronDownIcon";
|
import ChevronDownIcon from "../icons/ChevronDownIcon";
|
||||||
|
import CheckIcon from "../icons/CheckIcon";
|
||||||
|
|
||||||
interface SelectProps {
|
interface SelectProps {
|
||||||
options: string[];
|
options: string[];
|
||||||
@@ -14,16 +15,16 @@ function Select({
|
|||||||
options,
|
options,
|
||||||
onSelect,
|
onSelect,
|
||||||
className,
|
className,
|
||||||
defaultOption: defaultValue = "",
|
defaultOption = "",
|
||||||
}: SelectProps) {
|
}: SelectProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [selectedOption, setSelectedOption] = useState(defaultValue);
|
const [selectedOption, setSelectedOption] = useState(defaultOption);
|
||||||
|
|
||||||
const ref = useClickAway<HTMLDivElement>(() => setIsOpen(false));
|
const ref = useClickAway<HTMLDivElement>(() => setIsOpen(false));
|
||||||
|
|
||||||
const dropDownRef = useRef<HTMLDivElement>(null);
|
const dropDownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => setSelectedOption(defaultValue), [defaultValue]);
|
useEffect(() => setSelectedOption(defaultOption), [defaultOption]);
|
||||||
|
|
||||||
useEffect(() => onSelect(selectedOption), [onSelect, selectedOption]);
|
useEffect(() => onSelect(selectedOption), [onSelect, selectedOption]);
|
||||||
|
|
||||||
@@ -42,38 +43,57 @@ function Select({
|
|||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={ref} className={clsx("relative", className)}>
|
||||||
ref={ref}
|
{/* Select Button */}
|
||||||
className={clsx(
|
<button
|
||||||
"bg-[#F3F3F3] 2xl:p-[1.111vw] p-4 flex justify-between items-center 2xl:gap-[0.556vw] gap-2 relative select-none cursor-pointer",
|
|
||||||
isOpen
|
|
||||||
? "2xl:rounded-t-[1.111vw] rounded-t-2xl"
|
|
||||||
: "2xl:rounded-[1.111vw] rounded-2xl",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
>
|
|
||||||
<p className="text-m">{selectedOption}</p>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"2xl:size-[1.111vw] size-4 text-[#7D7D7D]",
|
"w-full bg-[#F3F3F3] 2xl:py-[0.972vw] py-3.5 2xl:px-[1.111vw] px-4 flex justify-between items-center 2xl:gap-[0.833vw] gap-3 select-none cursor-pointer transition-colors",
|
||||||
isOpen && "rotate-180"
|
"2xl:rounded-[1.111vw] rounded-2xl",
|
||||||
|
"hover:bg-[#F0F0F0]",
|
||||||
|
isOpen && "ring-1 ring-[#7B60F3]"
|
||||||
)}
|
)}
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
>
|
>
|
||||||
<ChevronDownIcon />
|
<p className="text-m text-ellipsis line-clamp-1 text-left">
|
||||||
</div>
|
{selectedOption || "Не выбрано"}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"2xl:size-[1.389vw] size-5 text-[#7D7D7D] shrink-0 transition-transform",
|
||||||
|
isOpen && "rotate-180"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Menu */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 top-full z-10 w-full 2xl:rounded-b-[1.111vw] rounded-b-2xlo overflow-hidden"
|
|
||||||
ref={dropDownRef}
|
ref={dropDownRef}
|
||||||
|
className="absolute left-0 2xl:top-[calc(100%+0.556vw)] top-[calc(100%+8px)] z-10 w-full bg-white 2xl:rounded-[1.111vw] rounded-2xl shadow-[0px_4px_40px_0px_rgba(0,0,0,0.05),0px_2px_2px_0px_rgba(0,0,0,0.05)] 2xl:p-[0.833vw] p-3 2xl:space-y-[0.278vw] space-y-1 overflow-auto"
|
||||||
>
|
>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<p
|
<button
|
||||||
key={option}
|
key={option}
|
||||||
className="bg-[#F3F3F3] 2xl:p-[1.111vw] p-4 text-m hover:bg-[#f0f0f0] transition-colors"
|
className={clsx(
|
||||||
|
"w-full flex items-center 2xl:gap-[0.278vw] gap-1 2xl:p-[0.833vw] p-3 2xl:rounded-[0.556vw] rounded-lg text-left transition-colors hover:bg-[#F3F3F3]"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedOption(option);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{option}
|
<div
|
||||||
</p>
|
className={clsx(
|
||||||
|
"2xl:size-[1.111vw] size-4 shrink-0 text-[#7B60F3]",
|
||||||
|
option !== selectedOption && "opacity-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
</div>
|
||||||
|
<span className="text-s">{option}</span>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,13 +4,10 @@ import { useMe, useLogout } from "../hooks/useAuth";
|
|||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import ShareFilledIcon from "../components/icons/ShareFilledIcon";
|
import ShareFilledIcon from "../components/icons/ShareFilledIcon";
|
||||||
import usePopupStore from "../store/popupStore";
|
import usePopupStore from "../store/popupStore";
|
||||||
<<<<<<< HEAD
|
|
||||||
import ParticipantsPopup from "../components/popups/ParticipantsPopup";
|
|
||||||
=======
|
|
||||||
import SettingsModal from "../components/modals/SettingsModal";
|
import SettingsModal from "../components/modals/SettingsModal";
|
||||||
import useModalStore from "../store/modalStore";
|
import useModalStore from "../store/modalStore";
|
||||||
import CogFilledIcon from "../components/icons/CogFilledIcon";
|
import CogFilledIcon from "../components/icons/CogFilledIcon";
|
||||||
>>>>>>> 79fb7f2748fcb76e887daa397ff450afc389a2b3
|
import SharePopup from "../components/popups/SharePopup";
|
||||||
|
|
||||||
function HomePage() {
|
function HomePage() {
|
||||||
const { data: user } = useMe();
|
const { data: user } = useMe();
|
||||||
@@ -35,15 +32,11 @@ function HomePage() {
|
|||||||
|
|
||||||
<FloatingActionButton
|
<FloatingActionButton
|
||||||
variant="default"
|
variant="default"
|
||||||
<<<<<<< HEAD
|
|
||||||
onClick={() => setPopup(<ParticipantsPopup />)}
|
|
||||||
=======
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setPopup(
|
setPopup(
|
||||||
<SharePopup link={"https://estate.stream/ahdy12jdco1"} />
|
<SharePopup link={"https://estate.stream/ahdy12jdco1"} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>>>>>>> 79fb7f2748fcb76e887daa397ff450afc389a2b3
|
|
||||||
>
|
>
|
||||||
<div className="2xl:size-[1.111vw] size-4 text-white">
|
<div className="2xl:size-[1.111vw] size-4 text-white">
|
||||||
<ShareFilledIcon />
|
<ShareFilledIcon />
|
||||||
|
|||||||
Reference in New Issue
Block a user