From 90e9786ec97e27e5632706ec085769afe5ae3c7f Mon Sep 17 00:00:00 2001 From: Lanskikh Date: Mon, 13 Oct 2025 15:52:05 +0500 Subject: [PATCH] Add framer-motion and motion dependencies; implement animations in ModalContainer and PopupContainer components; enhance PopupWrapper for touch support; update SoundCheckModal and VoiceCheckModal for improved audio testing; refactor Select and ActionsPopover components for animation support. --- client/bun.lock | 11 ++ client/package.json | 1 + client/src/components/ModalContainer.tsx | 46 ++++--- client/src/components/PopupContainer.tsx | 17 ++- client/src/components/PopupWrapper.tsx | 42 ++++-- .../src/components/modals/SettingsModal.tsx | 89 ++++++------ .../src/components/modals/SoundCheckModal.tsx | 88 +++++------- .../src/components/modals/VoiceCheckModal.tsx | 128 ++++++++++++------ client/src/components/ui/ActionsPopover.tsx | 64 +++++---- client/src/components/ui/RangeInput.tsx | 25 ++-- client/src/components/ui/Select.tsx | 58 ++++---- 11 files changed, 328 insertions(+), 241 deletions(-) diff --git a/client/bun.lock b/client/bun.lock index 964685f..9ad6317 100644 --- a/client/bun.lock +++ b/client/bun.lock @@ -9,6 +9,7 @@ "@uidotdev/usehooks": "^2.4.1", "clsx": "^2.1.1", "ky": "^1.11.0", + "motion": "^12.23.24", "react": "^19.1.1", "react-dom": "^19.1.1", "react-qr-code": "^2.0.18", @@ -377,6 +378,8 @@ "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], + "framer-motion": ["framer-motion@12.23.24", "", { "dependencies": { "motion-dom": "^12.23.23", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -453,6 +456,12 @@ "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "motion": ["motion@12.23.24", "", { "dependencies": { "framer-motion": "^12.23.24", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw=="], + + "motion-dom": ["motion-dom@12.23.23", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA=="], + + "motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], @@ -591,6 +600,8 @@ "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/client/package.json b/client/package.json index 77f0268..b2409ef 100644 --- a/client/package.json +++ b/client/package.json @@ -15,6 +15,7 @@ "@uidotdev/usehooks": "^2.4.1", "clsx": "^2.1.1", "ky": "^1.11.0", + "motion": "^12.23.24", "react": "^19.1.1", "react-dom": "^19.1.1", "react-qr-code": "^2.0.18", diff --git a/client/src/components/ModalContainer.tsx b/client/src/components/ModalContainer.tsx index 17c39e8..1d52d3e 100644 --- a/client/src/components/ModalContainer.tsx +++ b/client/src/components/ModalContainer.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import useModalStore from "../store/modalStore"; import clsx from "clsx"; +import { AnimatePresence, motion } from "motion/react"; function ModalContainer() { const { modal, setModal, position } = useModalStore(); @@ -35,28 +36,35 @@ function ModalContainer() { }, []); return ( - modal && ( -
-
+ {modal && ( + -
-
-
setModal(null)} - /> - {modal} +
+
+
+
setModal(null)} + /> + {modal} +
-
-
- ) + + )} + ); } diff --git a/client/src/components/PopupContainer.tsx b/client/src/components/PopupContainer.tsx index 0bceddb..dd42e28 100644 --- a/client/src/components/PopupContainer.tsx +++ b/client/src/components/PopupContainer.tsx @@ -1,12 +1,23 @@ +import { AnimatePresence, motion } from "motion/react"; import usePopupStore from "../store/popupStore"; function PopupContainer() { const { popup, position } = usePopupStore(); return ( -
- {popup} -
+ + {popup && ( + + {popup} + + )} + ); } diff --git a/client/src/components/PopupWrapper.tsx b/client/src/components/PopupWrapper.tsx index b722750..a7641d3 100644 --- a/client/src/components/PopupWrapper.tsx +++ b/client/src/components/PopupWrapper.tsx @@ -29,30 +29,40 @@ function PopupWrapper({ useEffect(() => { addEventListener("mouseup", () => setMouseDown(false)); - return () => removeEventListener("mouseup", () => setMouseDown(false)); + addEventListener("touchend", () => setMouseDown(false)); + return () => { + removeEventListener("mouseup", () => setMouseDown(false)); + removeEventListener("touchend", () => setMouseDown(false)); + }; }, []); - function handleMouseMove(e: MouseEvent) { + function handleMove(e: MouseEvent | TouchEvent) { if (draggable && mouseDown && wrapperRef.current) { e.preventDefault(); + const x = "clientX" in e ? e.clientX : e.touches[0].clientX; + const y = "clientY" in e ? e.clientY : e.touches[0].clientY; setPosition({ x: Math.min( - Math.max(0, position.x + e.clientX - mouseDownPosition.x), + Math.max(0, position.x + x - mouseDownPosition.x), window.innerWidth - wrapperRef.current.clientWidth ), y: Math.min( - Math.max(0, position.y + e.clientY - mouseDownPosition.y), + Math.max(0, position.y + y - mouseDownPosition.y), window.innerHeight - wrapperRef.current.clientHeight ), }); - setMouseDownPosition({ x: e.clientX, y: e.clientY }); + setMouseDownPosition({ x, y }); } } useEffect(() => { - addEventListener("mousemove", handleMouseMove); - return () => removeEventListener("mousemove", handleMouseMove); - }, [handleMouseMove]); + addEventListener("mousemove", handleMove); + addEventListener("touchmove", handleMove); + return () => { + removeEventListener("mousemove", handleMove); + removeEventListener("touchmove", handleMove); + }; + }, [handleMove]); useEffect(() => { if (headerRef.current) { @@ -60,6 +70,13 @@ function PopupWrapper({ setMouseDown(true); setMouseDownPosition({ x: e.clientX, y: e.clientY }); }); + headerRef.current.addEventListener("touchstart", (e) => { + setMouseDown(true); + setMouseDownPosition({ + x: e.touches[0].clientX, + y: e.touches[0].clientY, + }); + }); } return () => { if (headerRef.current) { @@ -67,6 +84,13 @@ function PopupWrapper({ setMouseDown(true); setMouseDownPosition({ x: e.clientX, y: e.clientY }); }); + headerRef.current.removeEventListener("touchstart", (e) => { + setMouseDown(true); + setMouseDownPosition({ + x: e.touches[0].clientX, + y: e.touches[0].clientY, + }); + }); } }; }, []); @@ -75,7 +99,7 @@ function PopupWrapper({
diff --git a/client/src/components/modals/SettingsModal.tsx b/client/src/components/modals/SettingsModal.tsx index 367d625..457af3b 100644 --- a/client/src/components/modals/SettingsModal.tsx +++ b/client/src/components/modals/SettingsModal.tsx @@ -168,8 +168,7 @@ function SettingsModal() { if (device.kind === "videoinput") { const deviceInfo: MediaDevice = { deviceId: device.deviceId, - label: - device.label || `${device.kind} (${device.deviceId.slice(0, 8)})`, + label: device.label || `${device.kind} (${device.deviceId})`, }; videoInputs.push(deviceInfo); } @@ -299,8 +298,8 @@ function SettingsModal() { setModal(null)} /> ); }; @@ -312,13 +311,16 @@ function SettingsModal() { selectedSpeaker={selectedSpeaker} speakers={speakers} onSelectSpeaker={setSelectedSpeaker} - onClose={() => setModal(null)} + speakerVolume={speakerVolume} /> ); }; return ( - +
{mediaType === "sound" && ( @@ -390,7 +388,7 @@ function SettingsModal() {
) : ( -
+
s.label)} @@ -467,7 +465,7 @@ function SettingsModal() {
)} -
+
@@ -494,28 +492,13 @@ function SettingsModal() {

Загрузка камер...

- ) : cameras.length === 0 ? ( -
-

- Камеры не найдены. Проверьте подключение устройства и - разрешения браузера. -

- -
- ) : ( + ) : cameras.length > 0 ? ( s.label)} + defaultOption={selectedSpeaker} + onSelect={onSelectSpeaker} + />
- {/* Sound Wave Visualization */}
-
+
{[3, 12, 16, 20, 44, 28, 34, 60, 8, 40, 18, 36, 24, 13, 10, 6].map( (height, index) => (
) )} @@ -132,7 +97,18 @@ function SoundCheckModal({
-
diff --git a/client/src/components/modals/VoiceCheckModal.tsx b/client/src/components/modals/VoiceCheckModal.tsx index 95dc22f..0a9e85e 100644 --- a/client/src/components/modals/VoiceCheckModal.tsx +++ b/client/src/components/modals/VoiceCheckModal.tsx @@ -1,35 +1,44 @@ +/* eslint-disable react-hooks/exhaustive-deps */ 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"; interface VoiceCheckModalProps { selectedMicrophone: string; microphones: { deviceId: string; label: string }[]; + microphoneVolume: number; onSelectMicrophone: (label: string) => void; - onClose: () => void; } function VoiceCheckModal({ selectedMicrophone, microphones, + microphoneVolume, onSelectMicrophone, - onClose, }: VoiceCheckModalProps) { + const { setModal } = useModalStore(); + const [audioLevel, setAudioLevel] = useState(0); const [status, setStatus] = useState<"default" | "success" | "error">( "default" ); + const [isTestRunning, setIsTestRunning] = useState(false); + const [soundDetected, setSoundDetected] = useState(false); + const [maxAudioLevel, setMaxAudioLevel] = useState(0); const audioContextRef = useRef(null); const analyserRef = useRef(null); const streamRef = useRef(null); const sourceRef = useRef(null); + const gainNodeRef = useRef(null); const animationFrameRef = useRef(null); - const testTimeoutRef = useRef(null); + const testTimeoutRef = useRef | null>(null); const statusRef = useRef<"default" | "success" | "error">("default"); - const [isTestRunning, setIsTestRunning] = useState(false); function detectAudioLevel() { if (!analyserRef.current) return; @@ -40,11 +49,16 @@ function VoiceCheckModal({ const average = dataArray.reduce((a, b) => a + b) / dataArray.length; setAudioLevel(average); - // Определяем статус на основе уровня звука + // Обновляем максимальный уровень звука + setMaxAudioLevel((prev) => Math.max(prev, average)); + // Если звук обнаружен и статус ещё не success, устанавливаем success - if (average > 10 && statusRef.current !== "success") { - statusRef.current = "success"; - setStatus("success"); + if (average > 5) { + if (statusRef.current !== "success") { + statusRef.current = "success"; + setStatus("success"); + } + setSoundDetected(true); } animationFrameRef.current = requestAnimationFrame(detectAudioLevel); @@ -72,13 +86,24 @@ function VoiceCheckModal({ analyser.fftSize = 256; analyserRef.current = analyser; + const gainNode = audioContext.createGain(); + gainNode.gain.value = microphoneVolume / 100; + gainNodeRef.current = gainNode; + const source = audioContext.createMediaStreamSource(stream); sourceRef.current = source; - source.connect(analyser); + + // Подключаем: source -> gainNode -> analyser -> destination + source.connect(gainNode); + gainNode.connect(analyser); + analyser.connect(audioContext.destination); // Сбрасываем статус при новом тесте statusRef.current = "default"; setStatus("default"); + setSoundDetected(false); + setAudioLevel(0); + setMaxAudioLevel(0); setIsTestRunning(true); // Запускаем проверку звука @@ -100,7 +125,6 @@ function VoiceCheckModal({ // Если статус уже success, он остаётся success setIsTestRunning(false); - setAudioLevel(0); // Очищаем ресурсы после завершения теста cleanupAudioResources(); @@ -113,8 +137,8 @@ function VoiceCheckModal({ } function cleanupAudioResources() { - // Отключаем source от analyser - if (sourceRef.current && analyserRef.current) { + // Отключаем source + if (sourceRef.current) { try { sourceRef.current.disconnect(); } catch { @@ -123,6 +147,26 @@ function VoiceCheckModal({ sourceRef.current = null; } + // Отключаем gainNode + if (gainNodeRef.current) { + try { + gainNodeRef.current.disconnect(); + } catch { + // Ignore if already disconnected + } + gainNodeRef.current = null; + } + + // Отключаем analyser + if (analyserRef.current) { + try { + analyserRef.current.disconnect(); + } catch { + // Ignore if already disconnected + } + analyserRef.current = null; + } + // Останавливаем все треки медиа-потока if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); @@ -134,8 +178,6 @@ function VoiceCheckModal({ audioContextRef.current.close(); audioContextRef.current = null; } - - analyserRef.current = null; } function stopMicrophoneTest() { @@ -150,49 +192,48 @@ function VoiceCheckModal({ cleanupAudioResources(); - setAudioLevel(0); + // НЕ сбрасываем audioLevel и maxAudioLevel - они сохраняются до нового теста + setSoundDetected(false); setIsTestRunning(false); } function restartMicrophoneTest() { stopMicrophoneTest(); // Небольшая задержка перед запуском нового теста - setTimeout(() => { - startMicrophoneTest(); - }, 100); + setTimeout(startMicrophoneTest, 100); } useEffect(() => { startMicrophoneTest(); - return () => { - stopMicrophoneTest(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps + return stopMicrophoneTest; }, [selectedMicrophone]); + // Обновляем громкость микрофона при изменении слайдера + useEffect(() => { + if (gainNodeRef.current) { + gainNodeRef.current.gain.value = microphoneVolume / 100; + } + }, [microphoneVolume]); + // Генерируем высоты для баров на основе уровня звука 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); + // Используем maxAudioLevel для сохранения максимальной высоты + const levelToUse = isTestRunning ? audioLevel : maxAudioLevel; + const multiplier = Math.min(levelToUse / 5); return baseHeights.map((h) => Math.max(3, Math.min(60, h * multiplier))); } const barHeights = generateBarHeights(); - return ( - -
- {/* Заголовок и описание */} -
-

Говорите

-

- Вы должны видеть движение на индикаторе -

-
+ console.log(barHeights); + return ( + +
{/* Выбор микрофона */}

Микрофон

@@ -205,12 +246,15 @@ function VoiceCheckModal({ {/* Визуализация уровня звука */}
-
+
{barHeights.map((height, index) => (
))}
@@ -227,9 +271,14 @@ function VoiceCheckModal({ )}
- {/* Кнопки */} +
+

Говорите

+

+ Вы должны видеть движение на индикаторе +

+
+
- {/* Кнопка повторной проверки */} - {isOpened && ( -
{ - setMenuHeight(el?.offsetHeight || 0); - }} - className={`absolute z-10 right-0 w-[13.333vw] bg-white rounded-[1.111vw] shadow-[0_4px_40px_0_rgba(15,16,17,0.1)] overflow-hidden ${ - openUpwards ? "bottom-[100%]" : "top-[100%]" - }`} - > - {options.map((option) => ( - - ))} -
- )} + + {isOpened && ( + { + setMenuHeight(el?.offsetHeight || 0); + }} + className={`absolute z-10 right-0 w-[13.333vw] bg-white rounded-[1.111vw] shadow-[0_4px_40px_0_rgba(15,16,17,0.1)] overflow-hidden ${ + openUpwards ? "bottom-[100%]" : "top-[100%]" + }`} + > + {options.map((option) => ( + + ))} + + )} +
); } diff --git a/client/src/components/ui/RangeInput.tsx b/client/src/components/ui/RangeInput.tsx index 140caa9..432c8b5 100644 --- a/client/src/components/ui/RangeInput.tsx +++ b/client/src/components/ui/RangeInput.tsx @@ -20,20 +20,25 @@ function RangeInput({ useEffect(() => { addEventListener("mouseup", () => setMouseDown(false)); - addEventListener("mousemove", handleMouseMove); + addEventListener("touchend", () => setMouseDown(false)); + addEventListener("mousemove", handleMove); + addEventListener("touchmove", handleMove); return () => { removeEventListener("mouseup", () => setMouseDown(false)); - removeEventListener("mousemove", handleMouseMove); + removeEventListener("touchend", () => setMouseDown(false)); + removeEventListener("mousemove", handleMove); + removeEventListener("touchmove", handleMove); }; - }, [handleMouseMove]); + }, [handleMove]); - function handleMouseMove(e: MouseEvent) { + function handleMove(e: MouseEvent | TouchEvent) { if (mouseDown && ref.current) { onChange( Math.min( Math.max( min, - ((e.clientX - ref.current.getBoundingClientRect().left) / + ((("clientX" in e ? e.clientX : e.touches[0].clientX) - + ref.current.getBoundingClientRect().left) / ref.current.clientWidth) * (max - min) + min @@ -44,14 +49,17 @@ function RangeInput({ } } - function handleMouseDown(e: React.MouseEvent) { + function handleMouseDownOrTouchStart( + e: React.MouseEvent | React.TouchEvent + ) { e.preventDefault(); if (ref.current) { onChange( Math.min( Math.max( min, - ((e.clientX - ref.current.getBoundingClientRect().left) / + ((("clientX" in e ? e.clientX : e.touches[0].clientX) - + ref.current.getBoundingClientRect().left) / ref.current.clientWidth) * (max - min) + min @@ -67,7 +75,8 @@ function RangeInput({
{/* Dropdown Menu */} - {isOpen && ( -
- {options.map((option) => ( -
- {option} - - ))} -
- )} +
+ +
+ {option} + + ))} + + )} +
); }