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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user