Merge branch 'main' of http://192.168.1.163:3000/inmake/stream.graff.tech-new
This commit is contained in:
+1
-1
@@ -1,2 +1,2 @@
|
||||
# VITE_API_URL=http://192.168.1.23:3000
|
||||
VITE_API_URL=http://localhost:3000
|
||||
VITE_API_URL=http://192.168.1.224:3000
|
||||
@@ -15,7 +15,12 @@ function ModalWrapper({
|
||||
className,
|
||||
}: ModalWrapperProps) {
|
||||
return (
|
||||
<div className={clsx("bg-white rounded-[1.111vw] relative", className)}>
|
||||
<div
|
||||
className={clsx(
|
||||
"bg-white 2xl:rounded-[2.222vw] rounded-[32px] relative",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ModalHeader title={title} leftButton={leftButton} />
|
||||
<div className={clsx("2xl:p-[1.389vw] p-5", !title && "!pt-0")}>
|
||||
{children}
|
||||
|
||||
@@ -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 (
|
||||
<AnimatePresence>
|
||||
{popup && (
|
||||
<motion.div
|
||||
className="absolute"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
style={{ top: position.y, left: position.x }}
|
||||
className="absolute max-sm:fixed"
|
||||
initial={{ opacity: 0, y: isMobile ? "100%" : undefined }}
|
||||
animate={{ opacity: 1, y: isMobile ? "0%" : undefined }}
|
||||
exit={{ opacity: 0, y: isMobile ? "100%" : undefined }}
|
||||
drag={isMobile ? "y" : false}
|
||||
dragConstraints={{ top: 0, bottom: 0 }}
|
||||
dragElastic={{ top: 0, bottom: 0.5 }}
|
||||
onDragEnd={isMobile ? handleDragEnd : undefined}
|
||||
transition={{ bounce: 0 }}
|
||||
style={
|
||||
!isMobile
|
||||
? { top: position.y, left: position.x }
|
||||
: { bottom: 0, left: 0, right: 0 }
|
||||
}
|
||||
>
|
||||
{popup}
|
||||
</motion.div>
|
||||
|
||||
@@ -22,7 +22,7 @@ function PopupHeader({
|
||||
<div
|
||||
ref={headerRef}
|
||||
className={clsx(
|
||||
"2xl:p-[1.111vw] p-4 flex justify-between items-center cursor-graba select-none relative",
|
||||
"2xl:p-[1.111vw] p-4 flex justify-between items-center select-none relative",
|
||||
draggable && "cursor-grab active:cursor-grabbing"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -99,10 +99,15 @@ function PopupWrapper({
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={clsx(
|
||||
"2xl:rounded-[2.222vw] rounded-[32px] relative bg-white shadow-[0_4px_40px_0_rgba(15,16,17,0.1)]",
|
||||
"2xl:rounded-[2.222vw] relative bg-white shadow-[0_4px_40px_0_rgba(15,16,17,0.1)] 2xl:w-[21.667vw] sm:rounded-[32px] max-sm:w-screen max-sm:rounded-t-[32px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Полоска-ручка для свайпа на мобильных */}
|
||||
<div className="hidden max-sm:flex justify-center pt-1 pb-1 absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<div className="w-8 h-1 bg-[#141414] rounded-full opacity-50" />
|
||||
</div>
|
||||
|
||||
<PopupHeader
|
||||
headerRef={headerRef}
|
||||
title={title}
|
||||
|
||||
@@ -53,13 +53,15 @@ export default function SessionUsersPanel() {
|
||||
const isDragStarted = useRef(false);
|
||||
const DRAG_THRESHOLD = 15;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const handleMove = (e: MouseEvent | TouchEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (!isDragStarted.current) {
|
||||
const distance = Math.hypot(
|
||||
e.clientX - dragStartPos.current.x,
|
||||
e.clientY - dragStartPos.current.y
|
||||
("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) {
|
||||
@@ -72,8 +74,12 @@ export default function SessionUsersPanel() {
|
||||
|
||||
if (isDragStarted.current) {
|
||||
setDragPosition({
|
||||
x: e.clientX - dragOffset.current.x,
|
||||
y: e.clientY - dragOffset.current.y,
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -94,30 +100,39 @@ export default function SessionUsersPanel() {
|
||||
setIsLeft(shouldBeLeft);
|
||||
isDragStarted.current = false;
|
||||
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const handleMouseDown = (
|
||||
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
|
||||
) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const r_pos = { x: rect.left, y: rect.top };
|
||||
const c_pos = { x: e.clientX, y: e.clientY };
|
||||
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", handleMouseMove);
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("touchmove", handleMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
window.addEventListener("touchend", handleMouseUp);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
window.removeEventListener("touchmove", handleMove);
|
||||
window.removeEventListener("touchend", handleMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -135,7 +150,7 @@ export default function SessionUsersPanel() {
|
||||
transform: `translate(${isLeft ? "0" : "-100%"}, ${
|
||||
isTop ? "0" : "-100%"
|
||||
})`,
|
||||
transition: "all 0.3s ease-in-out",
|
||||
transition: "all 0.5s cubic-bezier(.63,.08,.37,.89)",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -143,9 +158,8 @@ export default function SessionUsersPanel() {
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
className={`flex gap-4 absolute ${
|
||||
isDragStarted.current ? "cursor-grabbing" : "cursor-grab"
|
||||
}`}
|
||||
onTouchStart={handleMouseDown}
|
||||
className="flex gap-4 active:cursor-grabbing cursor-grab absolute"
|
||||
style={getStyle()}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -333,7 +333,7 @@ function SettingsModal() {
|
||||
const openMicrophoneCheck = () => {
|
||||
setModal(
|
||||
<VoiceCheckModal
|
||||
selectedMicrophone={selectedMicrophone}
|
||||
initialMicrophone={selectedMicrophone}
|
||||
microphones={microphones}
|
||||
microphoneVolume={microphoneVolume}
|
||||
onSelectMicrophone={setSelectedMicrophone}
|
||||
@@ -345,10 +345,10 @@ function SettingsModal() {
|
||||
const openSpeakerCheck = () => {
|
||||
setModal(
|
||||
<SoundCheckModal
|
||||
selectedSpeaker={selectedSpeaker}
|
||||
initialSpeaker={selectedSpeaker}
|
||||
speakers={speakers}
|
||||
onSelectSpeaker={setSelectedSpeaker}
|
||||
speakerVolume={speakerVolume}
|
||||
onSelectSpeaker={setSelectedSpeaker}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,29 +7,42 @@ 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<AudioContext | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const audioElementRef = useRef<HTMLAudioElement | null>(null);
|
||||
const mediaStreamDestinationRef =
|
||||
useRef<MediaStreamAudioDestinationNode | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [playProgress, setPlayProgress] = useState(0); // Прогресс воспроизведения 0-1
|
||||
const playStartTimeRef = useRef<number>(0);
|
||||
|
||||
// Высоты баров эквалайзера (16 баров как в дизайне)
|
||||
const barCount = 16;
|
||||
// Обработчик выбора динамика
|
||||
const handleSelectSpeaker = (label: string) => {
|
||||
setSelectedSpeaker(label);
|
||||
onSelectSpeaker(label);
|
||||
};
|
||||
|
||||
// Высоты баров эквалайзера (40 баров с несколькими волнами как на макете)
|
||||
const barCount = 40;
|
||||
const barHeights = [
|
||||
3, 12, 16, 20, 44, 28, 34, 60, 8, 40, 18, 36, 24, 13, 10, 6,
|
||||
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,
|
||||
];
|
||||
|
||||
// Обновление прогресса воспроизведения
|
||||
@@ -61,6 +74,14 @@ function SoundCheckModal({
|
||||
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;
|
||||
@@ -68,30 +89,99 @@ 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<void>;
|
||||
}
|
||||
).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 + 3
|
||||
audioContext.currentTime + duration
|
||||
);
|
||||
|
||||
// Запускаем анимацию прогресса
|
||||
@@ -101,13 +191,13 @@ function SoundCheckModal({
|
||||
|
||||
// Играем звук 3 секунды
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 3);
|
||||
oscillator.stop(audioContext.currentTime + duration);
|
||||
|
||||
// Останавливаем анимацию после окончания звука
|
||||
setTimeout(() => {
|
||||
setIsPlaying(false);
|
||||
setPlayProgress(0);
|
||||
}, 3000);
|
||||
}, duration * 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -116,9 +206,10 @@ function SoundCheckModal({
|
||||
<div className="flex flex-col items-center 2xl:gap-[0.278vw] gap-1">
|
||||
<p className="caption-xs text-[#7D7D7D] font-medium">Динамик</p>
|
||||
<Select
|
||||
size="small"
|
||||
options={speakers.map((s) => s.label)}
|
||||
defaultOption={selectedSpeaker}
|
||||
onSelect={onSelectSpeaker}
|
||||
onSelect={handleSelectSpeaker}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
key={index}
|
||||
className="2xl:w-[0.208vw] w-[3px] 2xl:rounded-[0.556vw] rounded-lg transition-all duration-150 ease-out"
|
||||
className="2xl:w-[0.208vw] w-[3px] 2xl:rounded-[0.556vw] rounded-lg transition-colors duration-150 ease-out"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
backgroundColor: isActivated ? "#7B60F3" : "#D6D6D6",
|
||||
backgroundColor:
|
||||
isActivated || isInWave ? "#7B60F3" : "#D6D6D6",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
@@ -36,17 +43,18 @@ function VoiceCheckModal({
|
||||
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
const gainNodeRef = useRef<GainNode | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const testTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const statusRef = useRef<"default" | "success" | "error">("default");
|
||||
const detectionTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<ModalWrapper className="2xl:max-w-[21.111vw] max-w-[304px]">
|
||||
@@ -288,9 +281,10 @@ function VoiceCheckModal({
|
||||
<div className="flex flex-col items-center 2xl:gap-[0.278vw] gap-1">
|
||||
<p className="caption-xs text-[#7D7D7D] font-medium">Микрофон</p>
|
||||
<Select
|
||||
size="small"
|
||||
options={microphones.map((m) => m.label)}
|
||||
defaultOption={selectedMicrophone}
|
||||
onSelect={onSelectMicrophone}
|
||||
onSelect={handleSelectMicrophone}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -304,7 +298,7 @@ function VoiceCheckModal({
|
||||
key={index}
|
||||
className={clsx(
|
||||
"2xl:w-[0.208vw] w-[3px] 2xl:rounded-[0.556vw] rounded-lg transition-all duration-150 ease-out",
|
||||
soundDetected ? "bg-[#7B60F3]" : "bg-[#D6D6D6]"
|
||||
currentVolume > 10 ? "bg-[#7B60F3]" : "bg-[#D6D6D6]"
|
||||
)}
|
||||
style={{
|
||||
height: `${Math.max(3, height)}px`,
|
||||
@@ -314,17 +308,22 @@ function VoiceCheckModal({
|
||||
</div>
|
||||
|
||||
{/* Статус */}
|
||||
{!isTestRunning && status === "success" && (
|
||||
{isActive && detectionStatus === "waiting" && (
|
||||
<p className="caption-s text-[#7D7D7D] font-medium">
|
||||
Говорите...
|
||||
</p>
|
||||
)}
|
||||
{isActive && detectionStatus === "detected" && (
|
||||
<p className="caption-s text-[#29AF61] font-medium">Звук есть!</p>
|
||||
)}
|
||||
{!isTestRunning && status === "error" && (
|
||||
{isActive && detectionStatus === "not_detected" && (
|
||||
<p className="caption-s text-[#FF4517] font-medium">
|
||||
Звук не обнаружен
|
||||
</p>
|
||||
)}
|
||||
{isTestRunning && (
|
||||
<p className="caption-s text-[#7D7D7D] font-medium">
|
||||
Проверка...
|
||||
{!isActive && (
|
||||
<p className="caption-s text-[#FF4517] font-medium">
|
||||
Ошибка подключения
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -337,29 +336,14 @@ function VoiceCheckModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col 2xl:gap-[0.556vw] gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
onClick={restartMicrophoneTest}
|
||||
disabled={isTestRunning}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="2xl:size-[1.389vw] size-5">
|
||||
<RestartIcon />
|
||||
</div>
|
||||
Повторить проверку
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={() => setModal(<SettingsModal />)}
|
||||
className="w-full"
|
||||
>
|
||||
Завершить
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={() => setModal(<SettingsModal />)}
|
||||
className="w-full"
|
||||
>
|
||||
Завершить
|
||||
</Button>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
);
|
||||
|
||||
@@ -32,12 +32,8 @@ export default function ChatPopup() {
|
||||
}
|
||||
|
||||
return (
|
||||
<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]">
|
||||
<PopupWrapper title="Чат" draggable className="sm:overflow-hidden">
|
||||
<div className="flex flex-col 2xl:h-[27.778vw] relative 2xl:-m-[1.389vw] -m-5">
|
||||
<MessageFeed messages={messages} />
|
||||
<MessageInput onMessageSend={onMessageSend} />
|
||||
</div>
|
||||
@@ -55,28 +51,21 @@ function MessageFeed({ messages }: { messages: MessageItemProps[] }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col w-full h-[calc(100%-4.444vw)] bg-[#F0F0F0] p-[1.111vw] pb-[0] overflow-y-auto"
|
||||
className="flex flex-col w-full 2xl:h-[calc(100%-4.444vw)] bg-[#F0F0F0] 2xl:p-[1.111vw] p-4 pb-0 overflow-y-auto [-webkit-scrollbar]:hidden"
|
||||
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
|
||||
>
|
||||
<style>
|
||||
{`
|
||||
.flex::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
{messages.length === 0 ? (
|
||||
<div className="w-full flex flex-col gap-[1.667vw] items-center justify-center px-[2.778vw] m-auto">
|
||||
<div className="w-full flex flex-col 2xl:gap-[1.667vw] gap-6 items-center justify-center 2xl:px-[2.778vw] px-10 m-auto">
|
||||
<img
|
||||
src="/img/popups/EmptyMessageFeed.svg"
|
||||
className="size-[8.333vw]"
|
||||
className="2xl:size-[8.333vw] size-[120px]"
|
||||
/>
|
||||
<span className="text-center text-s">
|
||||
Здесь пока нет сообщений. <br /> Можно начать беседу с приветствия
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-[1.111vw] items-end mt-auto">
|
||||
<div className="flex flex-col 2xl:gap-[1.111vw] gap-4 items-end mt-auto">
|
||||
{messages.map((message, index) => (
|
||||
<MessageItem key={index} {...message} />
|
||||
))}
|
||||
@@ -95,35 +84,34 @@ interface MessageItemProps {
|
||||
|
||||
function MessageItem({ senderId, timestamp, content }: MessageItemProps) {
|
||||
const { data: user } = useMe();
|
||||
|
||||
const isFromMe = senderId === "1";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex w-full items-end gap-[0.556vw] justify-end",
|
||||
"flex w-full items-end 2xl:gap-[0.556vw] gap-2 justify-end",
|
||||
isFromMe ? "" : "flex-row-reverse"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"p-[1.111vw] w-[16.667vw] flex items-end justify-between",
|
||||
"2xl:p-[1.111vw] p-4 2xl:w-[16.667vw] w-[240px] flex items-end justify-between",
|
||||
isFromMe
|
||||
? "bg-[#7B60F3] rounded-[1.111vw_1.111vw_0_1.111vw] text-white"
|
||||
: "bg-[#FFFFFF] rounded-[1.111vw_1.111vw_1.111vw_0] c text-[#141414]"
|
||||
? "bg-[#7B60F3] 2xl:rounded-[1.111vw_1.111vw_0_1.111vw] rounded-[16px_16px_0_16px] text-white"
|
||||
: "bg-[#FFFFFF] 2xl:rounded-[1.111vw_1.111vw_1.111vw_0] rounded-[16px_16px_16px_0] text-[#141414]"
|
||||
)}
|
||||
>
|
||||
<div className="break-words text-s w-[12.917vw]">
|
||||
<div className="break-words text-s 2xl:space-y-[0.278vw] space-y-1">
|
||||
{!isFromMe && (
|
||||
<div className="caption-s text-[#7B60F3] mb-[0.278vw]">
|
||||
{user?.fullName}
|
||||
</div>
|
||||
<div className="caption-s text-[#7B60F3]">{user?.fullName}</div>
|
||||
)}
|
||||
<div>{content}</div>
|
||||
</div>
|
||||
<span className="caption-xs opacity-30">{timestamp}</span>
|
||||
</div>
|
||||
|
||||
<div className="size-[2.222vw rounded-full">
|
||||
<div className="2xl:size-[2.222vw] size-8 rounded-full">
|
||||
<img
|
||||
src="/img/popups/MessageUserPfp.png"
|
||||
className="size-full object-cover"
|
||||
@@ -169,7 +157,7 @@ function MessageInput({
|
||||
return (
|
||||
<div
|
||||
onClick={() => 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"
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -183,10 +171,9 @@ function MessageInput({
|
||||
size="small"
|
||||
variant="cta"
|
||||
disabled={message.length === 0}
|
||||
className="size-[2.222vw]"
|
||||
onClick={sendMessage}
|
||||
>
|
||||
<div className="size-full">
|
||||
<div className="2xl:size-[1.111vw] size-4">
|
||||
<SendIcon />
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -25,7 +25,6 @@ function SharePopup({ link }: { link: string }) {
|
||||
<PopupWrapper
|
||||
title="Пригласить"
|
||||
draggable
|
||||
className="w-[21.667vw]"
|
||||
leftButton={
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -38,8 +37,8 @@ function SharePopup({ link }: { link: string }) {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="mb-[1.389vw]">
|
||||
<p className="title-s mb-[0.833vw] font-medium">Скопировать ссылку</p>
|
||||
<div className="2xl:mb-[1.389vw] mb-3 2xl:space-y-[0.833vw] space-y-3">
|
||||
<p className="title-s font-medium">Скопировать ссылку</p>
|
||||
<LinkShare link={link} />
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -18,8 +18,8 @@ export default function LinkShare({ link }: { link: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="flex flex-col 2xl:gap-[0.556vw] gap-2">
|
||||
<div className="w-full 2xl:h-[3.75vw] h-[54px] bg-[#F3F3F3] flex items-center justify-between 2xl:gap-[0.833vw] gap-3 2xl:px-[1.111vw] px-4 2xl:rounded-[1.111vw] rounded-2xl relative">
|
||||
<span
|
||||
className="text-ellipsis text-s hover:cursor-pointer overflow-hidden text-nowrap"
|
||||
onClick={handleCopy}
|
||||
@@ -31,7 +31,7 @@ export default function LinkShare({ link }: { link: string }) {
|
||||
<Button
|
||||
variant="cta"
|
||||
size="medium"
|
||||
className="translate-x-[0.556vw]"
|
||||
className="2xl:translate-x-[0.556vw] translate-x-2"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
Копировать
|
||||
@@ -39,19 +39,19 @@ export default function LinkShare({ link }: { link: string }) {
|
||||
)}
|
||||
|
||||
{shareState === "loading" && (
|
||||
<div className="size-[1.389vw] text-[#7B60F3] animate-spin">
|
||||
<div className="2xl:size-[1.389vw] size-5 text-[#7B60F3] animate-spin">
|
||||
<LoaderIcon />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shareState === "done" && (
|
||||
<div className="size-[1.389vw] text-[#7B60F3]">
|
||||
<div className="2xl:size-[1.389vw] size-5 text-[#7B60F3]">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{shareState === "done" && (
|
||||
<div className="caption-s absolutea bottom-[-1.25vw] text-[#29AF61] left-0">
|
||||
<div className="caption-s absolutea 2xl:bottom-[-1.25vw] bottom-[-18px] text-[#29AF61] left-0">
|
||||
Ссылка скопирована
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useClickAway } from "@uidotdev/usehooks";
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -10,6 +11,7 @@ interface SelectProps {
|
||||
onSelect: (value: string) => void;
|
||||
className?: string;
|
||||
defaultOption?: string;
|
||||
size?: "small" | "large";
|
||||
}
|
||||
|
||||
function Select({
|
||||
@@ -17,6 +19,7 @@ function Select({
|
||||
onSelect,
|
||||
className,
|
||||
defaultOption = "",
|
||||
size = "large",
|
||||
}: SelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedOption, setSelectedOption] = useState(defaultOption);
|
||||
@@ -27,7 +30,7 @@ function Select({
|
||||
|
||||
useEffect(() => setSelectedOption(defaultOption), [defaultOption]);
|
||||
|
||||
useEffect(() => onSelect(selectedOption), [onSelect, selectedOption]);
|
||||
useEffect(() => onSelect(selectedOption), [selectedOption]);
|
||||
|
||||
function handleScroll() {
|
||||
if (!dropDownRef.current) return;
|
||||
@@ -51,16 +54,25 @@ function Select({
|
||||
"w-full bg-[#F3F3F3] 2xl:py-[0.972vw] py-3.5 2xl:px-[1.111vw] px-4 flex justify-between items-center 2xl:gap-[0.833vw] gap-3 select-none cursor-pointer transition-colors",
|
||||
"2xl:rounded-[1.111vw] rounded-2xl",
|
||||
"hover:bg-[#F0F0F0]",
|
||||
size === "small" &&
|
||||
"2xl:gap-[0.139vw] gap-0.5 !p-0 !bg-transparent !ring-transparent outline-none",
|
||||
isOpen && "ring-1 ring-[#7B60F3]"
|
||||
)}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<p className="text-m text-ellipsis line-clamp-1 text-left">
|
||||
<p
|
||||
className={clsx(
|
||||
"text-ellipsis line-clamp-1 text-left",
|
||||
size === "small" && "button-m",
|
||||
size === "large" && "text-m"
|
||||
)}
|
||||
>
|
||||
{selectedOption || "Не выбрано"}
|
||||
</p>
|
||||
<div
|
||||
className={clsx(
|
||||
"2xl:size-[1.389vw] size-5 text-[#7D7D7D] shrink-0 transition-transform",
|
||||
size === "small" && "2xl:!size-[1.111vw] !size-4",
|
||||
isOpen && "rotate-180"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -7,10 +7,12 @@ import usePopupStore from "../store/popupStore";
|
||||
import SettingsModal from "../components/modals/SettingsModal";
|
||||
import useModalStore from "../store/modalStore";
|
||||
import CogFilledIcon from "../components/icons/CogFilledIcon";
|
||||
import SessionUsersPanel from "../components/SessionUsersPanel";
|
||||
// import SessionUsersPanel from "../components/SessionUsersPanel";
|
||||
import SharePopup from "../components/popups/SharePopup";
|
||||
import { useEffect } from "react";
|
||||
import useToastsStore from "../store/toastsStore";
|
||||
import ChatPopup from "../components/popups/ChatPopup";
|
||||
import ChatFilledIcon from "../components/icons/ChatFilledIcon";
|
||||
|
||||
function HomePage() {
|
||||
const { data: user } = useMe();
|
||||
@@ -68,7 +70,15 @@ function HomePage() {
|
||||
<ShareFilledIcon />
|
||||
</div>
|
||||
</FloatingActionButton>
|
||||
<SessionUsersPanel />
|
||||
<FloatingActionButton
|
||||
variant="default"
|
||||
onClick={() => setPopup(<ChatPopup />)}
|
||||
>
|
||||
<div className="2xl:size-[1.111vw] size-4 text-white">
|
||||
<ChatFilledIcon />
|
||||
</div>
|
||||
</FloatingActionButton>
|
||||
{/* <SessionUsersPanel /> */}
|
||||
|
||||
<FloatingActionButton
|
||||
variant="default"
|
||||
|
||||
@@ -5,6 +5,7 @@ export default {
|
||||
extend: {},
|
||||
screens: {
|
||||
"2xl": "1440px",
|
||||
sm: "640px",
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||
Reference in New Issue
Block a user