diff --git a/client/.env b/client/.env index cd41370..101d1fc 100644 --- a/client/.env +++ b/client/.env @@ -1 +1,2 @@ -VITE_API_URL=http://localhost:3000 \ No newline at end of file +# VITE_API_URL=http://192.168.1.23:3000 +VITE_API_URL=http://192.168.1.224:3000 \ No newline at end of file diff --git a/client/public/img/popups/equalizer_bars.svg b/client/public/img/popups/equalizer_bars.svg new file mode 100644 index 0000000..423b34d --- /dev/null +++ b/client/public/img/popups/equalizer_bars.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/components/ActionsSidebarWrapper.tsx b/client/src/components/ActionsSidebarWrapper.tsx index eed1306..a7123bc 100644 --- a/client/src/components/ActionsSidebarWrapper.tsx +++ b/client/src/components/ActionsSidebarWrapper.tsx @@ -20,7 +20,7 @@ function ActionsSidebarWrapper({ animate={{ opacity: 1 }} exit={{ opacity: 0 }} className={clsx( - "flex 2xl:flex 2xl:gap-[0.556vw] 2xl:flex-col gap-2 max-2xl:p-2 max-2xl:rounded-[32px] absolute 2xl:top-1/2 2xl:-translate-y-1/2 2xl:right-[1.111vw] max-2xl:left-1/2 max-2xl:-translate-x-1/2 max-2xl:bottom-2 max-2xl:landscape:bg-[#00000026] max-2xl:landscape:backdrop-blur", + "flex 2xl:flex 2xl:gap-[0.556vw] 2xl:flex-col gap-2 max-2xl:p-2 max-2xl:rounded-[32px] absolute 2xl:top-1/2 2xl:-translate-y-1/2 2xl:right-[1.111vw] max-2xl:left-1/2 max-2xl:-translate-x-1/2 max-2xl:bottom-2 max-2xl:bg-[#00000026] max-2xl:backdrop-blur", className )} > diff --git a/client/src/components/ModalWrapper.tsx b/client/src/components/ModalWrapper.tsx index 7e625ea..d17bae6 100644 --- a/client/src/components/ModalWrapper.tsx +++ b/client/src/components/ModalWrapper.tsx @@ -15,9 +15,16 @@ function ModalWrapper({ className, }: ModalWrapperProps) { return ( -
+
-
{children}
+
+ {children} +
); } diff --git a/client/src/components/PixelStreamingWrapper.tsx b/client/src/components/PixelStreamingWrapper.tsx index 2cfe4d9..91a8cc1 100644 --- a/client/src/components/PixelStreamingWrapper.tsx +++ b/client/src/components/PixelStreamingWrapper.tsx @@ -10,10 +10,12 @@ import type { AllSettings } from "@epicgames-ps/lib-pixelstreamingfrontend-ue5.7 export interface PixelStreamingWrapperProps { initialSettings?: Partial; + onVideoInitialized?: () => void; } export const PixelStreamingWrapper = ({ initialSettings, + onVideoInitialized, }: PixelStreamingWrapperProps) => { // A reference to parent div element that the Pixel Streaming library attaches into: const videoParent = useRef(null); @@ -38,6 +40,10 @@ export const PixelStreamingWrapper = ({ setClickToPlayVisible(true); }); + streaming.addEventListener("videoInitialized", () => { + onVideoInitialized?.(); + }); + // Save the library instance into component state so that it can be accessed later: setPixelStreaming(streaming); diff --git a/client/src/components/PopupContainer.tsx b/client/src/components/PopupContainer.tsx index dd42e28..fe25491 100644 --- a/client/src/components/PopupContainer.tsx +++ b/client/src/components/PopupContainer.tsx @@ -2,17 +2,38 @@ import { AnimatePresence, motion } from "motion/react"; import usePopupStore from "../store/popupStore"; function PopupContainer() { - const { popup, position } = usePopupStore(); + const { popup, position, setPopup } = usePopupStore(); + + const isMobile = innerWidth < 640; + + function handleDragEnd( + _event: unknown, + info: { offset: { y: number }; velocity: { y: number } } + ) { + // Закрываем попап если свайпнули вниз больше чем на 100px или со скоростью > 500 + if (info.offset.y > 100 || info.velocity.y > 500) { + setPopup(null); + } + } return ( {popup && ( {popup} diff --git a/client/src/components/PopupHeader.tsx b/client/src/components/PopupHeader.tsx index 3888d38..62c5533 100644 --- a/client/src/components/PopupHeader.tsx +++ b/client/src/components/PopupHeader.tsx @@ -22,7 +22,7 @@ function PopupHeader({
diff --git a/client/src/components/PopupWrapper.tsx b/client/src/components/PopupWrapper.tsx index a7641d3..11c1df3 100644 --- a/client/src/components/PopupWrapper.tsx +++ b/client/src/components/PopupWrapper.tsx @@ -44,11 +44,11 @@ function PopupWrapper({ setPosition({ x: Math.min( Math.max(0, position.x + x - mouseDownPosition.x), - window.innerWidth - wrapperRef.current.clientWidth + innerWidth - wrapperRef.current.clientWidth ), y: Math.min( Math.max(0, position.y + y - mouseDownPosition.y), - window.innerHeight - wrapperRef.current.clientHeight + innerHeight - wrapperRef.current.clientHeight ), }); setMouseDownPosition({ x, y }); @@ -99,10 +99,15 @@ function PopupWrapper({
+ {/* Полоска-ручка для свайпа на мобильных */} +
+
+
+ (null); + const dragOffset = useRef({ x: 0, y: 0 }); + const dragStartPos = useRef({ x: 0, y: 0 }); + const isDragStarted = useRef(false); + const DRAG_THRESHOLD = 15; + + const handleMove = (e: MouseEvent | TouchEvent) => { + if (!containerRef.current) return; + + if (!isDragStarted.current) { + const distance = Math.hypot( + ("clientX" in e ? e.clientX : e.touches[0].clientX) - + dragStartPos.current.x, + ("clientY" in e ? e.clientY : e.touches[0].clientY) - + dragStartPos.current.y + ); + + if (distance >= DRAG_THRESHOLD) { + isDragStarted.current = true; + setIsDragging(true); + } else { + return; + } + } + + if (isDragStarted.current) { + setDragPosition({ + x: + ("clientX" in e ? e.clientX : e.touches[0].clientX) - + dragOffset.current.x, + y: + ("clientY" in e ? e.clientY : e.touches[0].clientY) - + dragOffset.current.y, + }); + } + }; + + const handleMouseUp = () => { + if (!containerRef.current) return; + + const rect = containerRef.current.getBoundingClientRect(); + const center = { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; + const shouldBeTop = center.y < window.innerHeight / 2; + const shouldBeLeft = center.x < window.innerWidth / 2; + + setIsDragging(!isDragStarted.current); + setIsTop(shouldBeTop); + setIsLeft(shouldBeLeft); + isDragStarted.current = false; + + window.removeEventListener("mousemove", handleMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + + const handleMouseDown = ( + e: React.MouseEvent | React.TouchEvent + ) => { + if (!containerRef.current) return; + + const rect = containerRef.current.getBoundingClientRect(); + const r_pos = { x: rect.left, y: rect.top }; + const c_pos = { + x: "clientX" in e ? e.clientX : e.touches[0].clientX, + y: "clientY" in e ? e.clientY : e.touches[0].clientY, + }; + + dragStartPos.current = c_pos; + dragOffset.current = { x: c_pos.x - r_pos.x, y: c_pos.y - r_pos.y }; + setDragPosition({ x: r_pos.x, y: r_pos.y }); + + isDragStarted.current = false; + window.addEventListener("mousemove", handleMove); + window.addEventListener("touchmove", handleMove); + window.addEventListener("mouseup", handleMouseUp); + window.addEventListener("touchend", handleMouseUp); + }; + + useEffect(() => { + return () => { + window.removeEventListener("mousemove", handleMove); + window.removeEventListener("mouseup", handleMouseUp); + window.removeEventListener("touchmove", handleMove); + window.removeEventListener("touchend", handleMouseUp); + }; + }, []); + + const getStyle = (): React.CSSProperties => { + if (isDragStarted.current && isDragging) { + return { + left: `${dragPosition.x}px`, + top: `${dragPosition.y}px`, + transition: "none", + }; + } + return { + left: isLeft ? "1.111vw" : "calc(100vw - 1.111vw)", + top: isTop ? "1.111vw" : "calc(100vh - 1.111vw)", + transform: `translate(${isLeft ? "0" : "-100%"}, ${ + isTop ? "0" : "-100%" + })`, + transition: "all 0.5s cubic-bezier(.63,.08,.37,.89)", + }; + }; + return ( -
-
+
+
{users.map((user) => ( ))} +
-
); } diff --git a/client/src/components/indicators/Warning.tsx b/client/src/components/indicators/Warning.tsx index 33a2cdd..9e45f27 100644 --- a/client/src/components/indicators/Warning.tsx +++ b/client/src/components/indicators/Warning.tsx @@ -10,7 +10,7 @@ export default function Warning({ return (
(""); 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]); // Загружаем видео устройства и запускаем видео при переключении на вкладку "Видео" @@ -296,7 +333,7 @@ function SettingsModal() { const openMicrophoneCheck = () => { setModal( { setModal( ); }; @@ -358,6 +395,17 @@ function SettingsModal() {

Видео

+ {mediaApiUnavailable && ( +
+

+ MediaDevices API недоступен +

+

+ Для работы с медиа-устройствами требуется безопасное соединение + (HTTPS) или localhost. Проверьте настройки сервера и браузера. +

+
+ )} {mediaType === "sound" && (
diff --git a/client/src/components/modals/SoundCheckModal.tsx b/client/src/components/modals/SoundCheckModal.tsx index df287b8..422ec36 100644 --- a/client/src/components/modals/SoundCheckModal.tsx +++ b/client/src/components/modals/SoundCheckModal.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect } from "react"; +import { useRef, useEffect, useState } from "react"; import ModalWrapper from "../ModalWrapper"; import Button from "../ui/Button"; import RestartIcon from "../icons/RestartIcon"; @@ -7,24 +7,81 @@ import useModalStore from "../../store/modalStore"; import Select from "../ui/Select"; interface SoundCheckModalProps { - selectedSpeaker: string; + initialSpeaker: string; speakers: { deviceId: string; label: string }[]; speakerVolume: number; onSelectSpeaker: (label: string) => void; } function SoundCheckModal({ - selectedSpeaker, + initialSpeaker, speakers, speakerVolume, onSelectSpeaker, }: SoundCheckModalProps) { const { setModal } = useModalStore(); + + const [selectedSpeaker, setSelectedSpeaker] = useState(initialSpeaker); const audioContextRef = useRef(null); + const animationFrameRef = useRef(null); + const audioElementRef = useRef(null); + const mediaStreamDestinationRef = + useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [playProgress, setPlayProgress] = useState(0); // Прогресс воспроизведения 0-1 + const playStartTimeRef = useRef(0); + + // Обработчик выбора динамика + const handleSelectSpeaker = (label: string) => { + setSelectedSpeaker(label); + onSelectSpeaker(label); + }; + + // Высоты баров эквалайзера (40 баров с несколькими волнами как на макете) + const barCount = 40; + const barHeights = [ + 3, 12, 16, 20, 44, 28, 12, 12, 16, 20, 34, 60, 28, 12, 8, 16, 12, 20, 40, + 28, 12, 12, 16, 12, 20, 18, 36, 24, 16, 3, 8, 12, 20, 44, 28, 16, 13, 10, 6, + 3, + ]; + + // Обновление прогресса воспроизведения + useEffect(() => { + if (!isPlaying) return; + + const updateProgress = () => { + const elapsed = Date.now() - playStartTimeRef.current; + const progress = Math.min(elapsed / 3000, 1); // 3 секунды + setPlayProgress(progress); + + if (progress < 1) { + animationFrameRef.current = requestAnimationFrame(updateProgress); + } + }; + + updateProgress(); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [isPlaying]); // Очистка AudioContext при размонтировании useEffect(() => { return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + if (audioElementRef.current) { + audioElementRef.current.pause(); + audioElementRef.current.srcObject = null; + audioElementRef.current = null; + } + if (mediaStreamDestinationRef.current) { + mediaStreamDestinationRef.current = null; + } if (audioContextRef.current) { audioContextRef.current.close(); audioContextRef.current = null; @@ -32,64 +89,161 @@ function SoundCheckModal({ }; }, []); - const playTestSound = () => { + const playTestSound = async () => { // Создаём AudioContext если его нет if (!audioContextRef.current) { audioContextRef.current = new AudioContext(); } const audioContext = audioContextRef.current; + + // Создаём MediaStreamDestination для вывода на выбранное устройство + if (!mediaStreamDestinationRef.current) { + mediaStreamDestinationRef.current = + audioContext.createMediaStreamDestination(); + } + + const destination = mediaStreamDestinationRef.current; + + // Создаём аудио элемент для вывода на конкретное устройство + if (!audioElementRef.current) { + audioElementRef.current = new Audio(); + audioElementRef.current.srcObject = destination.stream; + } + + // Устанавливаем выбранное устройство вывода + const selectedDevice = speakers.find((s) => s.label === selectedSpeaker); + if ( + selectedDevice && + audioElementRef.current && + "setSinkId" in audioElementRef.current + ) { + try { + await ( + audioElementRef.current as HTMLAudioElement & { + setSinkId: (sinkId: string) => Promise; + } + ).setSinkId(selectedDevice.deviceId); + } catch (error) { + console.error("Ошибка при установке устройства вывода:", error); + } + } + + // Запускаем воспроизведение через аудио элемент + audioElementRef.current + .play() + .catch((e) => console.error("Ошибка воспроизведения:", e)); + const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); + // Подключаем к MediaStreamDestination вместо audioContext.destination oscillator.connect(gainNode); - gainNode.connect(audioContext.destination); + gainNode.connect(destination); - // Настраиваем звук (440 Hz - нота A) - oscillator.frequency.value = 440; oscillator.type = "sine"; - // Применяем громкость из настроек и плавное затухание + // Применяем громкость из настроек const baseVolume = speakerVolume / 100; gainNode.gain.setValueAtTime(baseVolume * 0.3, audioContext.currentTime); + + // Создаём динамическое изменение частоты на основе высот баров + const duration = 3; // 3 секунды + const timePerBar = duration / barCount; + + // Начальная частота + const minFreq = 200; // Минимальная частота (низкая нота) + const maxFreq = 1200; // Максимальная частота (высокая нота) + const minHeight = 3; + const maxHeight = 60; + + // Устанавливаем начальную частоту + const initialFreq = + minFreq + + ((barHeights[0] - minHeight) / (maxHeight - minHeight)) * + (maxFreq - minFreq); + oscillator.frequency.setValueAtTime(initialFreq, audioContext.currentTime); + + // Создаём плавное изменение частоты для каждого бара + barHeights.forEach((height, index) => { + const time = audioContext.currentTime + index * timePerBar; + // Преобразуем высоту бара в частоту + const frequency = + minFreq + + ((height - minHeight) / (maxHeight - minHeight)) * (maxFreq - minFreq); + oscillator.frequency.linearRampToValueAtTime(frequency, time); + }); + + // Плавное затухание громкости к концу + gainNode.gain.linearRampToValueAtTime( + baseVolume * 0.3, + audioContext.currentTime + duration * 0.8 + ); gainNode.gain.exponentialRampToValueAtTime( 0.01, - audioContext.currentTime + 1 + audioContext.currentTime + duration ); - // Играем звук 1 секунду + // Запускаем анимацию прогресса + playStartTimeRef.current = Date.now(); + setIsPlaying(true); + setPlayProgress(0); + + // Играем звук 3 секунды oscillator.start(audioContext.currentTime); - oscillator.stop(audioContext.currentTime + 1); + oscillator.stop(audioContext.currentTime + duration); + + // Останавливаем анимацию после окончания звука + setTimeout(() => { + setIsPlaying(false); + setPlayProgress(0); + }, duration * 1000); }; return ( -
+

Динамик

m.label)} defaultOption={selectedMicrophone} - onSelect={onSelectMicrophone} + onSelect={handleSelectMicrophone} />
- {/* Визуализация уровня звука */} -
-
- {barHeights.map((height, index) => ( -
- ))} + {/* Эквалайзер */} +
+ {/* Визуализация эквалайзера */} +
+
+ {barHeights.map((height, index) => ( +
10 ? "bg-[#7B60F3]" : "bg-[#D6D6D6]" + )} + style={{ + height: `${Math.max(3, height)}px`, + }} + /> + ))} +
+ + {/* Статус */} + {isActive && detectionStatus === "waiting" && ( +

+ Говорите... +

+ )} + {isActive && detectionStatus === "detected" && ( +

Звук есть!

+ )} + {isActive && detectionStatus === "not_detected" && ( +

+ Звук не обнаружен +

+ )} + {!isActive && ( +

+ Ошибка подключения +

+ )}
- {!isTestRunning && status === "success" && ( -

Звук есть!

- )} - {!isTestRunning && status === "error" && ( -

- Звук не обнаружен -

- )} - {isTestRunning && ( -

Проверка...

- )}
@@ -278,29 +336,14 @@ function VoiceCheckModal({

-
- - - -
+
); diff --git a/client/src/components/popups/ChatPopup.tsx b/client/src/components/popups/ChatPopup.tsx index ad1a169..cc0ba1a 100644 --- a/client/src/components/popups/ChatPopup.tsx +++ b/client/src/components/popups/ChatPopup.tsx @@ -32,8 +32,8 @@ export default function ChatPopup() { } return ( - -
+ +
@@ -51,28 +51,21 @@ function MessageFeed({ messages }: { messages: MessageItemProps[] }) { return (
- {messages.length === 0 ? ( -
+
Здесь пока нет сообщений.
Можно начать беседу с приветствия
) : ( -
+
{messages.map((message, index) => ( ))} @@ -91,35 +84,34 @@ interface MessageItemProps { function MessageItem({ senderId, timestamp, content }: MessageItemProps) { const { data: user } = useMe(); + const isFromMe = senderId === "1"; return (
-
+
{!isFromMe && ( -
- {user?.fullName} -
+
{user?.fullName}
)}
{content}
{timestamp}
-
+
textareaRef.current?.focus()} - className="flex w-full min-h-[4.444vw] p-[1.111vw] items-end justify-between absolute bottom-0 left-0 bg-white" + className="flex w-full 2xl:min-h-[4.444vw] min-h-16 2xl:p-[1.111vw] p-4 items-end justify-between absolute bottom-0 left-0 bg-white" >