From 34c9b58d8f64c623b0c1389d1b731b3e8c689693 Mon Sep 17 00:00:00 2001 From: Lanskikh Date: Thu, 16 Oct 2025 16:04:59 +0500 Subject: [PATCH 1/8] Update API URL in .env; refactor routing in main.tsx to switch between NewSessionPage and TestPage; enhance PixelStreamingWrapper with video initialization callback; improve UI components for better interaction and responsiveness in ActionsSidebarWrapper, UserCamera, UserDevicesControls, and ControlsPopover; add session data fetching and error handling in NewSessionPage. --- client/.env | 2 +- .../src/components/ActionsSidebarWrapper.tsx | 2 +- .../src/components/PixelStreamingWrapper.tsx | 6 + client/src/components/ui/ControlsPopover.tsx | 5 +- client/src/components/ui/UserCamera.tsx | 2 +- .../src/components/ui/UserDevicesControls.tsx | 3 +- client/src/main.tsx | 9 +- client/src/pages/NewSessionPage.tsx | 131 ++++++++++++++++-- client/src/pages/SessionPage.tsx | 4 +- client/src/types/Session.ts | 39 ++++++ 10 files changed, 178 insertions(+), 25 deletions(-) create mode 100644 client/src/types/Session.ts diff --git a/client/.env b/client/.env index cd41370..b8dedb4 100644 --- a/client/.env +++ b/client/.env @@ -1 +1 @@ -VITE_API_URL=http://localhost:3000 \ No newline at end of file +VITE_API_URL=http://192.168.1.23:3000 \ No newline at end of file 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/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/ui/ControlsPopover.tsx b/client/src/components/ui/ControlsPopover.tsx index d99830f..6c56c1a 100644 --- a/client/src/components/ui/ControlsPopover.tsx +++ b/client/src/components/ui/ControlsPopover.tsx @@ -13,6 +13,7 @@ import ChatPopup from "../popups/ChatPopup"; import ParticipantsPopup from "../popups/ParticipantsPopup"; import SharePopup from "../popups/SharePopup"; import SettingsModal from "../modals/SettingsModal"; +import clsx from "clsx"; function ControlsPopover() { const [isOpened, setIsOpened] = useState(false); @@ -41,10 +42,10 @@ function ControlsPopover() { const { setModal } = useModalStore(); return ( -
+
setIsOpened(!isOpened)} >
diff --git a/client/src/components/ui/UserCamera.tsx b/client/src/components/ui/UserCamera.tsx index 557423a..a0d5cc7 100644 --- a/client/src/components/ui/UserCamera.tsx +++ b/client/src/components/ui/UserCamera.tsx @@ -53,7 +53,7 @@ export default function UserCamera({ : "0.139vw solid #FFFFFF4D", }} className={clsx( - "aspect-square rounded-[1.667vw] bg-yellow-500 relative flex-shrink-0", + "aspect-square rounded-[1.667vw] bg-yellow-500 relative flex-shrink-0 pointer-events-auto", isAdmin && "order-last" )} > diff --git a/client/src/components/ui/UserDevicesControls.tsx b/client/src/components/ui/UserDevicesControls.tsx index 5068667..fc4824a 100644 --- a/client/src/components/ui/UserDevicesControls.tsx +++ b/client/src/components/ui/UserDevicesControls.tsx @@ -8,6 +8,7 @@ import SettingsModal from "../modals/SettingsModal"; export default function UserDevicesControls() { const { setModal } = useModalStore(); + function ToggleAudioDevice() { console.log("Mute device"); } @@ -22,7 +23,7 @@ export default function UserDevicesControls() { } return ( -
+
} diff --git a/client/src/main.tsx b/client/src/main.tsx index 953fd6f..16220df 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -2,16 +2,17 @@ import { createRoot } from "react-dom/client"; import "./index.css"; import HomePage from "./pages/HomePage"; import { createBrowserRouter, RouterProvider } from "react-router"; -import SessionPage from "./pages/SessionPage"; +// import SessionPage from "./pages/SessionPage"; import LoginPage from "./pages/LoginPage"; import RegisterPage from "./pages/RegisterPage"; -// import TestPage from "./pages/TestPage"; import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "./lib/queryClient"; import ProtectedRoute from "./components/ProtectedRoute"; import PublicRoute from "./components/PublicRoute"; import ModalContainer from "./components/ModalContainer"; import PopupContainer from "./components/PopupContainer"; +// import NewSessionPage from "./pages/NewSessionPage"; +import TestPage from "./pages/TestPage"; import NewSessionPage from "./pages/NewSessionPage"; const router = createBrowserRouter([ @@ -41,11 +42,11 @@ const router = createBrowserRouter([ }, { path: "/test", - element: , + element: , }, { path: "/sessions/:id", - element: , + element: , }, ]); diff --git a/client/src/pages/NewSessionPage.tsx b/client/src/pages/NewSessionPage.tsx index 74d24f1..dcacb1a 100644 --- a/client/src/pages/NewSessionPage.tsx +++ b/client/src/pages/NewSessionPage.tsx @@ -1,6 +1,5 @@ import ActionsSidebarWrapper from "../components/ActionsSidebarWrapper"; import ChatFilledIcon from "../components/icons/ChatFilledIcon"; -import CogFilledIcon from "../components/icons/CogFilledIcon"; import ExitFilledIcon from "../components/icons/ExitFilledIcon"; import FullscreenExitIcon from "../components/icons/FullscreenExitIcon"; import FullscreenIcon from "../components/icons/FullscreenIcon"; @@ -14,13 +13,19 @@ import usePopupStore from "../store/popupStore"; import ControlsPopover from "../components/ui/ControlsPopover"; import ChatPopup from "../components/popups/ChatPopup"; import SharePopup from "../components/popups/SharePopup"; -import SettingsModal from "../components/modals/SettingsModal"; -import useModalStore from "../store/modalStore"; import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "../lib/api"; +import type { Session } from "../types/Session"; +import { PixelStreamingWrapper } from "../components/PixelStreamingWrapper"; +import WarningIcon from "../components/icons/WarningIcon"; +import Button from "../components/ui/Button"; +import LoaderIcon from "../components/icons/LoaderIcon"; +import SessionUsersPanel from "../components/SessionUsersPanel"; function NewSessionPage() { const { setPopup } = usePopupStore(); - const { setModal } = useModalStore(); const [isFullscreen, setIsFullscreen] = useState(false); @@ -39,8 +44,109 @@ function NewSessionPage() { ); }, []); + const { id } = useParams(); + const navigate = useNavigate(); + + const { + data: sessionData, + isLoading, + error, + // refetch, + } = useQuery({ + queryKey: ["session", id], + queryFn: async () => { + const response = await api.get(`sessions/${id}`).json<{ + session: Session; + }>(); + return response; + }, + refetchInterval: (query) => { + // Автоматически обновляем каждые 2 секунды, если сессия в процессе запуска + const data = query.state.data; + if ( + data?.session.status === "starting" || + data?.session.status === "ending" + ) { + return 2000; + } + return false; + }, + }); + + const session = sessionData?.session; + + // Перенаправление на тестовую страницу при завершении сессии + useEffect(() => { + if (session?.status === "ended") { + const timer = setTimeout(() => { + navigate("/test"); + }, 5000); + return () => clearTimeout(timer); + } + }, [session?.status, navigate]); + + if (isLoading) { + return ( +
+
+
+ +
+

+ Загрузка информации о сессии... +

+
+
+ ); + } + + if (error || !session) { + return ( +
+
+
+
+ +
+
+

Сессия не найдена

+

+ {error instanceof Error + ? error.message + : "Не удалось загрузить информацию о сессии"} +

+ +
+
+
+
+ ); + } + return ( -
+
+ {session.status === "started" && + session.mode === "stream" && + session.server?.localIp && + session.playerPort && ( +
+ { + console.log("Video initialized"); + }} + /> +
+ )} - setPopup() + setPopup( + + ) } >
- setModal()} - > -
- -
-
@@ -101,6 +201,9 @@ function NewSessionPage() { +
+ +
); } diff --git a/client/src/pages/SessionPage.tsx b/client/src/pages/SessionPage.tsx index 915ada9..1c4fd6d 100644 --- a/client/src/pages/SessionPage.tsx +++ b/client/src/pages/SessionPage.tsx @@ -164,7 +164,9 @@ function SessionPage() { HoveringMouse: true, WaitForStreamer: true, }} - // onVideoInitialized={() => setIsVideoInitialized(true)} + onVideoInitialized={() => { + console.log("Video initialized"); + }} />
)} diff --git a/client/src/types/Session.ts b/client/src/types/Session.ts new file mode 100644 index 0000000..8c99b2b --- /dev/null +++ b/client/src/types/Session.ts @@ -0,0 +1,39 @@ +export interface Session { + id: string; + appId: string; + userId: string | null; + mode: "stream" | "local"; + status: "starting" | "started" | "ending" | "ended"; + tier: "demo" | "prod" | null; + serverId: string | null; + appPid: number | null; + cirrusPid: number | null; + streamerPort: number | null; + playerPort: number | null; + sfuPort: number | null; + startAt: string; + endAt: string | null; + createdAt: string; + updatedAt: string; + app?: { + id: string; + name: string; + title: string; + gpuLimitMb: number | null; + psVersion: number | null; + }; + server?: { + id: string; + localIp: string; + hostname: string; + type: "stream" | "local"; + tier: "demo" | "prod" | null; + location: "ru1" | "uae1" | null; + } | null; + user?: { + id: string; + email: string; + role: string; + displayName: string; + } | null; +} From a2d19fe646ea27d514e71fa1ff939d8d72cae514 Mon Sep 17 00:00:00 2001 From: C4rnivore Date: Thu, 16 Oct 2025 17:42:05 +0500 Subject: [PATCH 2/8] Cameras drag and snap; --- client/src/components/SessionUsersPanel.tsx | 97 ++++++++++++++++++- client/src/components/ui/UserCamera.tsx | 2 +- .../src/components/ui/UserDevicesControls.tsx | 2 +- client/src/pages/NewSessionPage.tsx | 6 +- 4 files changed, 98 insertions(+), 9 deletions(-) diff --git a/client/src/components/SessionUsersPanel.tsx b/client/src/components/SessionUsersPanel.tsx index 6df2a05..2ab738f 100644 --- a/client/src/components/SessionUsersPanel.tsx +++ b/client/src/components/SessionUsersPanel.tsx @@ -1,5 +1,7 @@ +import { useState, useRef, useEffect } from "react"; import UserCamera from "./ui/UserCamera"; import UserDevicesControls from "./ui/UserDevicesControls"; +import clsx from "clsx"; export default function SessionUsersPanel() { const users = [ @@ -40,9 +42,98 @@ export default function SessionUsersPanel() { console.log(`Can control user ${id}`); } + const [isTop, setIsTop] = useState(false); + const [isLeft, setIsLeft] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); + const containerRef = useRef(null); + const dragOffset = useRef({ x: 0, y: 0 }); + + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging || !containerRef.current) return; + setDragPosition({ + x: e.clientX - dragOffset.current.x, + y: e.clientY - dragOffset.current.y, + }); + }; + + const handleMouseUp = () => { + if (!isDragging || !containerRef.current) return; + + // Определяем, к какой стороне прилипнуть + const rect = containerRef.current.getBoundingClientRect(); + const centerY = rect.top + rect.height / 2; + const centerX = rect.left + rect.width / 2; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const shouldBeTop = centerY < viewportHeight / 2; + const shouldBeLeft = centerX < viewportWidth / 2; + + setIsDragging(false); + setIsTop(shouldBeTop); + setIsLeft(shouldBeLeft); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + if (!containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + dragOffset.current = { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }; + setDragPosition({ + x: rect.left, + y: rect.top, + }); + setIsDragging(true); + }; + + useEffect(() => { + if (isDragging) { + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + } + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging]); + + const getStyle = (): React.CSSProperties => { + if (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.3s ease-out", + }; + }; + return ( -
-
+
+
{users.map((user) => ( ))} +
-
); } diff --git a/client/src/components/ui/UserCamera.tsx b/client/src/components/ui/UserCamera.tsx index a0d5cc7..9525b77 100644 --- a/client/src/components/ui/UserCamera.tsx +++ b/client/src/components/ui/UserCamera.tsx @@ -54,7 +54,7 @@ export default function UserCamera({ }} className={clsx( "aspect-square rounded-[1.667vw] bg-yellow-500 relative flex-shrink-0 pointer-events-auto", - isAdmin && "order-last" + isAdmin && "order-3" )} > {isAdmin && } diff --git a/client/src/components/ui/UserDevicesControls.tsx b/client/src/components/ui/UserDevicesControls.tsx index fc4824a..122123a 100644 --- a/client/src/components/ui/UserDevicesControls.tsx +++ b/client/src/components/ui/UserDevicesControls.tsx @@ -23,7 +23,7 @@ export default function UserDevicesControls() { } return ( -
+
} diff --git a/client/src/pages/NewSessionPage.tsx b/client/src/pages/NewSessionPage.tsx index dcacb1a..f6b49b2 100644 --- a/client/src/pages/NewSessionPage.tsx +++ b/client/src/pages/NewSessionPage.tsx @@ -126,7 +126,7 @@ function NewSessionPage() { } return ( -
+
{session.status === "started" && session.mode === "stream" && session.server?.localIp && @@ -201,9 +201,7 @@ function NewSessionPage() { -
- -
+
); } From b8bdbc94f9c66b345a3b3e435a2353c7c4fff69b Mon Sep 17 00:00:00 2001 From: Lanskikh Date: Thu, 16 Oct 2025 19:09:44 +0500 Subject: [PATCH 3/8] 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. --- client/src/components/ModalWrapper.tsx | 4 +- client/src/components/SessionUsersPanel.tsx | 3 +- .../src/components/modals/SettingsModal.tsx | 58 +++++++++++++++++-- .../src/components/modals/SoundCheckModal.tsx | 2 +- .../src/components/modals/VoiceCheckModal.tsx | 13 ++++- client/src/components/popups/ChatPopup.tsx | 6 +- .../components/popups/ParticipantsPopup.tsx | 30 +++++++++- client/src/components/popups/SharePopup.tsx | 1 + client/src/components/ui/LinkShare.tsx | 2 +- client/src/components/ui/UserCamera.tsx | 26 +++------ client/src/lib/mediaDevices.ts | 48 +++++++++++++++ client/src/pages/NewSessionPage.tsx | 18 +++++- 12 files changed, 178 insertions(+), 33 deletions(-) create mode 100644 client/src/lib/mediaDevices.ts diff --git a/client/src/components/ModalWrapper.tsx b/client/src/components/ModalWrapper.tsx index 7e625ea..d6d1873 100644 --- a/client/src/components/ModalWrapper.tsx +++ b/client/src/components/ModalWrapper.tsx @@ -17,7 +17,9 @@ function ModalWrapper({ return (
-
{children}
+
+ {children} +
); } diff --git a/client/src/components/SessionUsersPanel.tsx b/client/src/components/SessionUsersPanel.tsx index 2ab738f..eb9fec7 100644 --- a/client/src/components/SessionUsersPanel.tsx +++ b/client/src/components/SessionUsersPanel.tsx @@ -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) { diff --git a/client/src/components/modals/SettingsModal.tsx b/client/src/components/modals/SettingsModal.tsx index 457af3b..283aedf 100644 --- a/client/src/components/modals/SettingsModal.tsx +++ b/client/src/components/modals/SettingsModal.tsx @@ -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(""); 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() {

Видео

+ {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..a786f10 100644 --- a/client/src/components/modals/SoundCheckModal.tsx +++ b/client/src/components/modals/SoundCheckModal.tsx @@ -64,7 +64,7 @@ function SoundCheckModal({ return ( -
+

Динамик

s.label)} defaultOption={selectedSpeaker} - onSelect={onSelectSpeaker} + onSelect={handleSelectSpeaker} />
@@ -127,15 +218,22 @@ function SoundCheckModal({ {barHeights.map((height, index) => { // Определяем, заполнен ли бар синим (прогресс слева направо) const barProgress = (index + 1) / barCount; - const isActivated = playProgress >= barProgress || !isPlaying; + const isActivated = playProgress >= barProgress; + + // Создаём эффект волны звука - бары впереди прогресса тоже окрашиваются + const distanceFromProgress = Math.abs( + index / barCount - playProgress + ); + const isInWave = isPlaying && distanceFromProgress < 0.15; // Волна охватывает 15% баров return (
); diff --git a/client/src/components/modals/VoiceCheckModal.tsx b/client/src/components/modals/VoiceCheckModal.tsx index 36728ec..d0ded76 100644 --- a/client/src/components/modals/VoiceCheckModal.tsx +++ b/client/src/components/modals/VoiceCheckModal.tsx @@ -3,32 +3,39 @@ 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"; import useModalStore from "../../store/modalStore"; import SettingsModal from "./SettingsModal"; import clsx from "clsx"; import { isMediaDevicesSupported } from "../../lib/mediaDevices"; interface VoiceCheckModalProps { - selectedMicrophone: string; + initialMicrophone: string; microphones: { deviceId: string; label: string }[]; microphoneVolume: number; onSelectMicrophone: (label: string) => void; } function VoiceCheckModal({ - selectedMicrophone, + initialMicrophone, microphones, microphoneVolume, onSelectMicrophone, }: VoiceCheckModalProps) { const { setModal } = useModalStore(); - const [status, setStatus] = useState<"default" | "success" | "error">( - "default" - ); - const [isTestRunning, setIsTestRunning] = useState(false); - const [soundDetected, setSoundDetected] = useState(false); + const [selectedMicrophone, setSelectedMicrophone] = + useState(initialMicrophone); + const [isActive, setIsActive] = useState(false); + const [currentVolume, setCurrentVolume] = useState(0); + const [detectionStatus, setDetectionStatus] = useState< + "waiting" | "detected" | "not_detected" + >("waiting"); + + // Обработчик выбора микрофона + const handleSelectMicrophone = (label: string) => { + setSelectedMicrophone(label); + onSelectMicrophone(label); + }; const audioContextRef = useRef(null); const analyserRef = useRef(null); @@ -36,17 +43,18 @@ function VoiceCheckModal({ const sourceRef = useRef(null); const gainNodeRef = useRef(null); const animationFrameRef = useRef(null); - const testTimeoutRef = useRef | null>(null); - const statusRef = useRef<"default" | "success" | "error">("default"); + const detectionTimeoutRef = useRef | null>( + null + ); + const soundDetectedRef = useRef(false); - async function startMicrophoneTest() { + async function startMicrophoneMonitoring() { // Проверяем доступность API if (!isMediaDevicesSupported()) { console.error( "navigator.mediaDevices недоступен. Возможно, требуется HTTPS или поддержка браузера." ); - setStatus("error"); - setIsTestRunning(false); + setIsActive(false); return; } @@ -68,10 +76,10 @@ function VoiceCheckModal({ audioContextRef.current = audioContext; const analyser = audioContext.createAnalyser(); - analyser.fftSize = 2048; // Увеличиваем для более точного анализа - analyser.smoothingTimeConstant = 0.85; // Увеличиваем сглаживание для плавных волн - analyser.minDecibels = -90; // Минимальный уровень для лучшей чувствительности - analyser.maxDecibels = -10; // Максимальный уровень + analyser.fftSize = 2048; + analyser.smoothingTimeConstant = 0.85; + analyser.minDecibels = -90; + analyser.maxDecibels = -10; analyserRef.current = analyser; const gainNode = audioContext.createGain(); @@ -86,30 +94,22 @@ function VoiceCheckModal({ gainNode.connect(analyser); analyser.connect(audioContext.destination); - // Сбрасываем статус при новом тесте - statusRef.current = "default"; - setStatus("default"); - setSoundDetected(false); - setIsTestRunning(true); + // Сбрасываем состояние обнаружения + soundDetectedRef.current = false; + setDetectionStatus("waiting"); + setIsActive(true); - // Останавливаем проверку через 3 секунды и устанавливаем результат - testTimeoutRef.current = setTimeout(() => { - // Устанавливаем финальный статус - if (statusRef.current === "default") { - statusRef.current = "error"; - setStatus("error"); + // Через 3 секунды проверяем, был ли обнаружен звук + detectionTimeoutRef.current = setTimeout(() => { + if (soundDetectedRef.current) { + setDetectionStatus("detected"); + } else { + setDetectionStatus("not_detected"); } - // Если статус уже success, он остаётся success - - setIsTestRunning(false); - - // Очищаем ресурсы после завершения теста - cleanupAudioResources(); }, 3000); } catch (error) { console.error("Ошибка доступа к микрофону:", error); - setStatus("error"); - setIsTestRunning(false); + setIsActive(false); } } @@ -157,32 +157,28 @@ function VoiceCheckModal({ } } - function stopMicrophoneTest() { + function stopMicrophoneMonitoring() { if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } - if (testTimeoutRef.current) { - clearTimeout(testTimeoutRef.current); - testTimeoutRef.current = null; + + if (detectionTimeoutRef.current) { + clearTimeout(detectionTimeoutRef.current); + detectionTimeoutRef.current = null; } cleanupAudioResources(); - setSoundDetected(false); - setIsTestRunning(false); - } - - function restartMicrophoneTest() { - stopMicrophoneTest(); - // Небольшая задержка перед запуском нового теста - setTimeout(startMicrophoneTest, 100); + setCurrentVolume(0); + setIsActive(false); + soundDetectedRef.current = false; } useEffect(() => { - startMicrophoneTest(); + startMicrophoneMonitoring(); - return stopMicrophoneTest; + return stopMicrophoneMonitoring; }, [selectedMicrophone]); // Обновляем громкость микрофона при изменении слайдера @@ -200,7 +196,7 @@ function VoiceCheckModal({ // Обновляем бары эквалайзера в режиме реального времени useEffect(() => { - if (!isTestRunning || !analyserRef.current) return; + if (!isActive || !analyserRef.current) return; const updateBars = () => { if (!analyserRef.current) return; @@ -258,15 +254,13 @@ function VoiceCheckModal({ setBarHeights(newBarHeights); - // Определяем наличие звука по максимальному бару + // Определяем текущую громкость по максимальному бару const maxBar = Math.max(...newBarHeights); + setCurrentVolume(maxBar); + + // Отмечаем, что звук был обнаружен (для проверки через 3 секунды) if (maxBar > 10) { - // Если есть бары выше 10px, значит есть звук - if (statusRef.current !== "success") { - statusRef.current = "success"; - setStatus("success"); - } - setSoundDetected(true); + soundDetectedRef.current = true; } animationFrameRef.current = requestAnimationFrame(updateBars); @@ -275,11 +269,10 @@ function VoiceCheckModal({ updateBars(); return () => { - if (animationFrameRef.current) { + if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current); - } }; - }, [isTestRunning, barCount]); + }, [isActive, barCount]); return ( @@ -288,9 +281,10 @@ function VoiceCheckModal({

Микрофон