Enhance media device handling in SettingsModal and VoiceCheckModal; add media device availability checks and user feedback for unsupported environments. Refactor ModalWrapper and update UI components for improved responsiveness and layout consistency.

This commit is contained in:
2025-10-16 19:09:44 +05:00
parent a2d19fe646
commit b8bdbc94f9
12 changed files with 178 additions and 33 deletions
+3 -1
View File
@@ -17,7 +17,9 @@ function ModalWrapper({
return (
<div className={clsx("bg-white rounded-[1.111vw] relative", className)}>
<ModalHeader title={title} leftButton={leftButton} />
<div className="2xl:p-[1.389vw] p-5">{children}</div>
<div className={clsx("2xl:p-[1.389vw] p-5", !title && "!pt-0")}>
{children}
</div>
</div>
);
}
+2 -1
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useState, useRef, useEffect } from "react";
import UserCamera from "./ui/UserCamera";
import UserDevicesControls from "./ui/UserDevicesControls";
@@ -98,7 +99,7 @@ export default function SessionUsersPanel() {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isDragging]);
}, [handleMouseMove, handleMouseUp, isDragging]);
const getStyle = (): React.CSSProperties => {
if (isDragging) {
+53 -5
View File
@@ -13,6 +13,7 @@ import useModalStore from "../../store/modalStore";
import SoundCheckModal from "./SoundCheckModal";
import VoiceCheckModal from "./VoiceCheckModal";
import LoaderIcon from "../icons/LoaderIcon";
import { isMediaDevicesSupported } from "../../lib/mediaDevices";
interface MediaDevice {
deviceId: string;
@@ -34,6 +35,7 @@ function SettingsModal() {
const [selectedCamera, setSelectedCamera] = useState<string>("");
const [mediaType, setMediaType] = useState<"sound" | "video">("sound");
const [mediaApiUnavailable, setMediaApiUnavailable] = useState(false);
const [participantsVideosHidden, setParticipantsVideosHidden] =
useState(false);
@@ -66,6 +68,17 @@ function SettingsModal() {
setIsLoadingMicrophones(true);
setIsLoadingSpeakers(true);
// Проверяем доступность API
if (!isMediaDevicesSupported()) {
console.error(
"navigator.mediaDevices недоступен. Возможно, требуется HTTPS или поддержка браузера."
);
setMediaApiUnavailable(true);
setIsLoadingMicrophones(false);
setIsLoadingSpeakers(false);
return;
}
try {
// Запрашиваем разрешения на аудио
const stream = await navigator.mediaDevices.getUserMedia({
@@ -150,6 +163,15 @@ function SettingsModal() {
async function loadVideoDevices() {
setIsLoadingCameras(true);
// Проверяем доступность API
if (!isMediaDevicesSupported()) {
console.error(
"navigator.mediaDevices недоступен. Возможно, требуется HTTPS или поддержка браузера."
);
setIsLoadingCameras(false);
return;
}
try {
// Запрашиваем разрешения на видео
const stream = await navigator.mediaDevices.getUserMedia({
@@ -214,6 +236,15 @@ function SettingsModal() {
// Запуск видео
async function startVideoTest() {
// Проверяем доступность API
if (!isMediaDevicesSupported()) {
console.error(
"navigator.mediaDevices недоступен. Возможно, требуется HTTPS или поддержка браузера."
);
setIsVideoTestingError(true);
return;
}
try {
setIsVideoTestingLoading(true);
setIsVideoTestingError(false);
@@ -257,14 +288,20 @@ function SettingsModal() {
}
};
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
return () => {
navigator.mediaDevices.removeEventListener(
// Добавляем слушатель только если API доступен
if (isMediaDevicesSupported()) {
navigator.mediaDevices.addEventListener(
"devicechange",
handleDeviceChange
);
};
return () => {
navigator.mediaDevices.removeEventListener(
"devicechange",
handleDeviceChange
);
};
}
}, [mediaType]);
// Загружаем видео устройства и запускаем видео при переключении на вкладку "Видео"
@@ -358,6 +395,17 @@ function SettingsModal() {
<p className="font-medium">Видео</p>
</Button>
</div>
{mediaApiUnavailable && (
<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="title-s font-medium text-[#FF4517]">
MediaDevices API недоступен
</p>
<p className="text-s text-[#FF4517]">
Для работы с медиа-устройствами требуется безопасное соединение
(HTTPS) или localhost. Проверьте настройки сервера и браузера.
</p>
</div>
)}
{mediaType === "sound" && (
<div className="2xl:space-y-[1.667vw] space-y-6">
<div className="2xl:space-y-[0.833vw] space-y-3">
@@ -64,7 +64,7 @@ function SoundCheckModal({
return (
<ModalWrapper className="2xl:max-w-[21.111vw] max-w-[304px]">
<div className="flex flex-col 2xl:gap-[1.667vw] gap-6 2xl:py-[1.667vw] py-6">
<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
@@ -7,6 +7,7 @@ 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;
@@ -65,6 +66,16 @@ function VoiceCheckModal({
}
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
@@ -233,7 +244,7 @@ function VoiceCheckModal({
return (
<ModalWrapper className="2xl:max-w-[21.111vw] max-w-[304px]">
<div className="flex flex-col 2xl:gap-[1.667vw] gap-6 2xl:py-[1.667vw] py-6">
<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>
+5 -1
View File
@@ -32,7 +32,11 @@ export default function ChatPopup() {
}
return (
<PopupWrapper title="Чат" draggable className="max-h-[40] overflow-hidden">
<PopupWrapper
title="Чат"
draggable
className="max-h-[40] 2xl:w-[21.667vw] 2xl:h-[27.778vw]a overflow-hidden"
>
<div className="flex flex-col h-[27.778vw] relative -m-[1.389vw]">
<MessageFeed messages={messages} />
<MessageInput onMessageSend={onMessageSend} />
@@ -5,12 +5,19 @@ import VideoOffFilledIcon from "../icons/VideoOffFilledIcon";
import HandRaisedFilledIcon from "../icons/HandRaisedFilledIcon";
import XMarkFilledIcon from "../icons/XMarkFilledIcon";
import Avatar from "../ui/Avatar";
import Button from "../ui/Button";
import ShareFilledIcon from "../icons/ShareFilledIcon";
export default function ParticipantsPopup() {
const participants = [1, 2, 3];
return (
<PopupWrapper title="Участники" draggable className="h-max">
<div className="flex flex-col w-[21.667vw] relative">
<PopupWrapper
title="Участники"
draggable
className="h-max 2xl:w-[21.667vw]"
>
<div className="flex flex-col gap-[1.667vw] relative">
<ul className="flex flex-col gap-[1.111vw]">
{participants.map((participant, index) => (
<>
@@ -21,6 +28,25 @@ export default function ParticipantsPopup() {
</>
))}
</ul>
<Button
variant="primary"
onClick={() => {
if (navigator.share) {
navigator.share({
title: "Участники",
text: "Присоединяйся к моей встрече",
url: window.location.href,
});
} else {
navigator.clipboard.writeText(window.location.href);
}
}}
>
<p>Поделиться ссылкой</p>
<div className="2xl:size-[1.111vw] size-4">
<ShareFilledIcon />
</div>
</Button>
</div>
</PopupWrapper>
);
@@ -13,6 +13,7 @@ function SharePopup({ link }: { link: string }) {
<PopupWrapper
title="Пригласить"
draggable
className="max-w-[21.667vw]"
leftButton={
<Button
variant="secondary"
+1 -1
View File
@@ -21,7 +21,7 @@ export default function LinkShare({ link }: { link: string }) {
<div className="flex flex-col gap-[0.556vw]">
<div className="w-full h-[3.75vw] bg-[#F3F3F3] flex items-center justify-between gap-[0.833vw] px-[1.111vw] rounded-[1.111vw] relative">
<span
className="text-ellipsis text-s hover:cursor-pointer overflow-hidden"
className="text-ellipsis text-s hover:cursor-pointer overflow-hidden text-nowrap"
onClick={handleCopy}
>
{link}
+9 -17
View File
@@ -34,7 +34,6 @@ export default function UserCamera({
onMute,
onVideoOff,
onCanControl,
isSpeaking = false,
isAdmin = false,
name = "Гость",
@@ -53,25 +52,18 @@ export default function UserCamera({
: "0.139vw solid #FFFFFF4D",
}}
className={clsx(
"aspect-square rounded-[1.667vw] bg-yellow-500 relative flex-shrink-0 pointer-events-auto",
"aspect-square group rounded-[1.667vw] bg-yellow-500 relative flex-shrink-0 transition-all duration-300 pointer-events-auto hover:w-[10.833vw]w-[6.944vw] shadow-[0_4px_40px_0_rgba(15,16,17,0.1),0_2px_2px_0_rgba(0,0,0,0.06)]",
isAdmin && "order-3"
)}
>
{isAdmin && <Admin className="absolute top-0 right-0" />}
<AnimatePresence mode="wait">
{isHover && (
<motion.div
key="name"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="absolute whitespace-nowrap text-white button-s top-[0.556vw] left-1/2 translate-x-[-50%] px-[0.556vw] py-[0.278vw] rounded-full bg-[#14141440] backdrop-blur-[4px]"
>
{name}
</motion.div>
)}
</AnimatePresence>
<div
key="name"
className="absolute whitespace-nowrap transition-opacity duration-300 group-hover:opacity-100 opacity-0 text-white button-s top-[0.556vw] left-1/2 translate-x-[-50%] px-[0.556vw] py-[0.278vw] rounded-full bg-[#14141440] backdrop-blur-[4px]"
>
{name}
</div>
<video
src={mediaStream}
@@ -149,7 +141,7 @@ function UserCameraControls({
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="size-[0.972vw] text-white flex items-center justify-center z-20">
<div className="size-[0.972vw] text-white">
<MicrophoneOffIcon />
</div>
</motion.div>
+48
View File
@@ -0,0 +1,48 @@
/**
* Проверяет доступность navigator.mediaDevices API
*
* API может быть недоступен если:
* - Браузер не поддерживает MediaDevices API
* - Сайт открыт без HTTPS (кроме localhost)
* - В браузере отключены медиа-возможности
*
* @returns true если API доступен, иначе false
*/
export function isMediaDevicesSupported(): boolean {
return !!(
navigator &&
navigator.mediaDevices &&
navigator.mediaDevices.getUserMedia
);
}
/**
* Безопасная обертка для доступа к MediaDevices API
*
* @param callback - функция, которая использует navigator.mediaDevices
* @param onError - опциональный обработчик ошибки
* @returns результат callback или undefined при ошибке
*/
export async function safeMediaDeviceAccess<T>(
callback: () => Promise<T>,
onError?: (error: Error) => void
): Promise<T | undefined> {
if (!isMediaDevicesSupported()) {
const error = new Error(
"navigator.mediaDevices недоступен. Возможно, требуется HTTPS или поддержка браузера."
);
console.error(error.message);
onError?.(error);
return undefined;
}
try {
return await callback();
} catch (error) {
console.error("Ошибка при работе с MediaDevices:", error);
if (onError && error instanceof Error) {
onError(error);
}
return undefined;
}
}
+15 -3
View File
@@ -25,7 +25,7 @@ import LoaderIcon from "../components/icons/LoaderIcon";
import SessionUsersPanel from "../components/SessionUsersPanel";
function NewSessionPage() {
const { setPopup } = usePopupStore();
const { setPopup, setPosition } = usePopupStore();
const [isFullscreen, setIsFullscreen] = useState(false);
@@ -150,7 +150,13 @@ function NewSessionPage() {
<ActionsSidebarWrapper>
<FloatingActionButton
className="max-2xl:hidden"
onClick={() => setPopup(<ChatPopup />)}
onClick={() => {
setPosition({
x: ((1440 - 384) / 1440) * innerWidth,
y: (200 / 1440) * innerWidth,
});
setPopup(<ChatPopup />);
}}
>
<div className="size-[1.111vw] text-white">
<ChatFilledIcon />
@@ -158,7 +164,13 @@ function NewSessionPage() {
</FloatingActionButton>
<FloatingActionButton
className="max-2xl:hidden"
onClick={() => setPopup(<ParticipantsPopup />)}
onClick={() => {
setPosition({
x: ((1440 - 384) / 1440) * innerWidth,
y: (234 / 800) * innerHeight,
});
setPopup(<ParticipantsPopup />);
}}
>
<div className="size-[1.111vw] text-white">
<UsersFilledIcon />