Update API URL to localhost in .env; add equalizer_bars.svg for audio visualization; refactor PopupWrapper and SessionUsersPanel for improved responsiveness; enhance SoundCheckModal and VoiceCheckModal with audio playback and visualization features; update SharePopup to handle sharing functionality; improve UI components for better interaction and consistency.
This commit is contained in:
+2
-1
@@ -1 +1,2 @@
|
||||
VITE_API_URL=http://192.168.1.23:3000
|
||||
# VITE_API_URL=http://192.168.1.23:3000
|
||||
VITE_API_URL=http://localhost:3000
|
||||
@@ -0,0 +1,44 @@
|
||||
<svg width="243" height="88" viewBox="0 0 243 88" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect y="28.5" width="3" height="3" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="6" y="24" width="3" height="12" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="12" y="22" width="3" height="16" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="18" y="24" width="3" height="12" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="24" y="20" width="3" height="20" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="30" y="8" width="3" height="44" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="36" y="16" width="3" height="28" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="42" y="24" width="3" height="12" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="48" y="24" width="3" height="12" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="54" y="22" width="3" height="16" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="60" y="20" width="3" height="20" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="66" y="13" width="3" height="34" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="72" width="3" height="60" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="78" y="16" width="3" height="28" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="84" y="24" width="3" height="12" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="90" y="26" width="3" height="8" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="96" y="22" width="3" height="16" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="102" y="24" width="3" height="12" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="108" y="20" width="3" height="20" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="114" y="10" width="3" height="40" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="120" y="16" width="3" height="28" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="126" y="24" width="3" height="12" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="132" y="24" width="3" height="12" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="138" y="22" width="3" height="16" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="144" y="24" width="3" height="12" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="150" y="20" width="3" height="20" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="156" y="21" width="3" height="18" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="162" y="12" width="3" height="36" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="168" y="18" width="3" height="24" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="174" y="22" width="3" height="16" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="180" y="28.5" width="3" height="3" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="186" y="26" width="3" height="8" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="192" y="24" width="3" height="12" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="198" y="20" width="3" height="20" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="204" y="8" width="3" height="44" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="210" y="16" width="3" height="28" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="216" y="22" width="3" height="16" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="222" y="23.5" width="3" height="13" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="228" y="25" width="3" height="10" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="234" y="27" width="3" height="6" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="240" y="28.5" width="3" height="3" rx="1.5" fill="#D6D6D6"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M116.67 78.706C116.897 77.8581 117.343 77.0849 117.964 76.4641C118.584 75.8433 119.357 75.3969 120.205 75.1696C121.053 74.9423 121.946 74.9422 122.794 75.1693C123.642 75.3964 124.415 75.8427 125.035 76.4634L126.304 77.732H124.182C124.049 77.732 123.922 77.7847 123.828 77.8785C123.735 77.9722 123.682 78.0994 123.682 78.232C123.682 78.3646 123.735 78.4918 123.828 78.5856C123.922 78.6794 124.049 78.732 124.182 78.732H127.51C127.643 78.732 127.77 78.6794 127.864 78.5856C127.957 78.4918 128.01 78.3646 128.01 78.232V74.904C128.01 74.7714 127.957 74.6442 127.864 74.5505C127.77 74.4567 127.643 74.404 127.51 74.404C127.377 74.404 127.25 74.4567 127.156 74.5505C127.063 74.6442 127.01 74.7714 127.01 74.904V77.024L125.743 75.7574C124.999 75.0123 124.071 74.4765 123.054 74.2037C122.036 73.9309 120.965 73.9308 119.947 74.2034C118.93 74.4759 118.002 75.0116 117.257 75.7565C116.512 76.5013 115.977 77.4292 115.704 78.4467C115.687 78.5102 115.683 78.5764 115.691 78.6415C115.7 78.7066 115.721 78.7695 115.754 78.8263C115.787 78.8832 115.831 78.9331 115.883 78.9731C115.935 79.0131 115.995 79.0424 116.058 79.0594C116.122 79.0763 116.188 79.0807 116.253 79.0721C116.318 79.0635 116.381 79.0421 116.438 79.0093C116.495 78.9764 116.544 78.9326 116.584 78.8805C116.624 78.8283 116.654 78.7688 116.671 78.7054L116.67 78.706ZM126.942 80.9407C126.879 80.9237 126.812 80.9193 126.747 80.9278C126.682 80.9363 126.619 80.9576 126.563 80.9904C126.506 81.0233 126.456 81.067 126.416 81.119C126.376 81.1711 126.346 81.2306 126.329 81.294C126.102 82.1419 125.656 82.9151 125.036 83.5359C124.415 84.1567 123.642 84.6032 122.794 84.8305C121.946 85.0578 121.054 85.0579 120.206 84.8308C119.358 84.6037 118.585 84.1573 117.964 83.5367L116.696 82.268H118.818C118.951 82.268 119.078 82.2154 119.172 82.1216C119.265 82.0278 119.318 81.9006 119.318 81.768C119.318 81.6354 119.265 81.5082 119.172 81.4145C119.078 81.3207 118.951 81.268 118.818 81.268H115.489C115.357 81.268 115.23 81.3207 115.136 81.4145C115.042 81.5082 114.989 81.6354 114.989 81.768V85.096C114.989 85.2286 115.042 85.3558 115.136 85.4496C115.23 85.5434 115.357 85.596 115.489 85.596C115.622 85.596 115.749 85.5434 115.843 85.4496C115.937 85.3558 115.989 85.2286 115.989 85.096V82.976L117.256 84.2427C118.001 84.9877 118.929 85.5235 119.946 85.7963C120.964 86.069 122.035 86.0691 123.052 85.7965C124.07 85.5238 124.998 84.9881 125.743 84.2431C126.487 83.4982 127.023 82.5703 127.295 81.5527C127.33 81.4247 127.312 81.2883 127.245 81.1736C127.179 81.0588 127.07 80.9751 126.942 80.9407Z" fill="#CCCCCC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -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 });
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function SessionUsersPanel() {
|
||||
transform: `translate(${isLeft ? "0" : "-100%"}, ${
|
||||
isTop ? "0" : "-100%"
|
||||
})`,
|
||||
transition: "all 0.3s ease-out",
|
||||
transition: "all 0.3s ease-in-out",
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -21,10 +21,46 @@ function SoundCheckModal({
|
||||
}: SoundCheckModalProps) {
|
||||
const { setModal } = useModalStore();
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [playProgress, setPlayProgress] = useState(0); // Прогресс воспроизведения 0-1
|
||||
const playStartTimeRef = useRef<number>(0);
|
||||
|
||||
// Высоты баров эквалайзера (16 баров как в дизайне)
|
||||
const barCount = 16;
|
||||
const barHeights = [
|
||||
3, 12, 16, 20, 44, 28, 34, 60, 8, 40, 18, 36, 24, 13, 10, 6,
|
||||
];
|
||||
|
||||
// Обновление прогресса воспроизведения
|
||||
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 (audioContextRef.current) {
|
||||
audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
@@ -42,6 +78,7 @@ function SoundCheckModal({
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
// Подключаем напрямую
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
@@ -54,12 +91,23 @@ function SoundCheckModal({
|
||||
gainNode.gain.setValueAtTime(baseVolume * 0.3, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(
|
||||
0.01,
|
||||
audioContext.currentTime + 1
|
||||
audioContext.currentTime + 3
|
||||
);
|
||||
|
||||
// Играем звук 1 секунду
|
||||
// Запускаем анимацию прогресса
|
||||
playStartTimeRef.current = Date.now();
|
||||
setIsPlaying(true);
|
||||
setPlayProgress(0);
|
||||
|
||||
// Играем звук 3 секунды
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 1);
|
||||
oscillator.stop(audioContext.currentTime + 3);
|
||||
|
||||
// Останавливаем анимацию после окончания звука
|
||||
setTimeout(() => {
|
||||
setIsPlaying(false);
|
||||
setPlayProgress(0);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -76,20 +124,28 @@ function SoundCheckModal({
|
||||
|
||||
<div className="flex flex-col items-center 2xl:gap-[0.833vw] gap-3">
|
||||
<div className="flex items-center justify-center 2xl:gap-[0.208vw] gap-[3px] 2xl:h-[4.167vw] h-[60px]">
|
||||
{[3, 12, 16, 20, 44, 28, 34, 60, 8, 40, 18, 36, 24, 13, 10, 6].map(
|
||||
(height, index) => (
|
||||
{barHeights.map((height, index) => {
|
||||
// Определяем, заполнен ли бар синим (прогресс слева направо)
|
||||
const barProgress = (index + 1) / barCount;
|
||||
const isActivated = playProgress >= barProgress || !isPlaying;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="2xl:w-[0.208vw] w-[3px] bg-[#D6D6D6] 2xl:rounded-[0.556vw] rounded-lg"
|
||||
style={{ height: `${(height / 1440) * innerWidth}px` }}
|
||||
className="2xl:w-[0.208vw] w-[3px] 2xl:rounded-[0.556vw] rounded-lg transition-all duration-150 ease-out"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
backgroundColor: isActivated ? "#7B60F3" : "#D6D6D6",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={playTestSound}
|
||||
className="2xl:size-[2.778vw] size-10 rounded-full bg-[#7B60F3] hover:bg-[#9184F6] active:bg-[#B3AAF9] flex items-center justify-center text-white transition-colors"
|
||||
disabled={isPlaying}
|
||||
className="2xl:size-[2.778vw] size-10 rounded-full bg-[#7B60F3] hover:bg-[#9184F6] active:bg-[#B3AAF9] disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-white transition-colors"
|
||||
>
|
||||
<div className="2xl:size-[1.111vw] size-4">
|
||||
<RestartIcon />
|
||||
|
||||
@@ -24,13 +24,11 @@ function VoiceCheckModal({
|
||||
}: 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<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
@@ -41,30 +39,6 @@ function VoiceCheckModal({
|
||||
const testTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const statusRef = useRef<"default" | "success" | "error">("default");
|
||||
|
||||
function detectAudioLevel() {
|
||||
if (!analyserRef.current) return;
|
||||
|
||||
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount);
|
||||
analyserRef.current.getByteFrequencyData(dataArray);
|
||||
|
||||
const average = dataArray.reduce((a, b) => a + b) / dataArray.length;
|
||||
setAudioLevel(average);
|
||||
|
||||
// Обновляем максимальный уровень звука
|
||||
setMaxAudioLevel((prev) => Math.max(prev, average));
|
||||
|
||||
// Если звук обнаружен и статус ещё не success, устанавливаем success
|
||||
if (average > 5) {
|
||||
if (statusRef.current !== "success") {
|
||||
statusRef.current = "success";
|
||||
setStatus("success");
|
||||
}
|
||||
setSoundDetected(true);
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(detectAudioLevel);
|
||||
}
|
||||
|
||||
async function startMicrophoneTest() {
|
||||
// Проверяем доступность API
|
||||
if (!isMediaDevicesSupported()) {
|
||||
@@ -94,7 +68,10 @@ function VoiceCheckModal({
|
||||
audioContextRef.current = audioContext;
|
||||
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
analyser.fftSize = 2048; // Увеличиваем для более точного анализа
|
||||
analyser.smoothingTimeConstant = 0.85; // Увеличиваем сглаживание для плавных волн
|
||||
analyser.minDecibels = -90; // Минимальный уровень для лучшей чувствительности
|
||||
analyser.maxDecibels = -10; // Максимальный уровень
|
||||
analyserRef.current = analyser;
|
||||
|
||||
const gainNode = audioContext.createGain();
|
||||
@@ -113,21 +90,10 @@ function VoiceCheckModal({
|
||||
statusRef.current = "default";
|
||||
setStatus("default");
|
||||
setSoundDetected(false);
|
||||
setAudioLevel(0);
|
||||
setMaxAudioLevel(0);
|
||||
setIsTestRunning(true);
|
||||
|
||||
// Запускаем проверку звука
|
||||
detectAudioLevel();
|
||||
|
||||
// Останавливаем проверку через 3 секунды и устанавливаем результат
|
||||
testTimeoutRef.current = setTimeout(() => {
|
||||
// Останавливаем анимацию
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
|
||||
// Устанавливаем финальный статус
|
||||
if (statusRef.current === "default") {
|
||||
statusRef.current = "error";
|
||||
@@ -203,7 +169,6 @@ function VoiceCheckModal({
|
||||
|
||||
cleanupAudioResources();
|
||||
|
||||
// НЕ сбрасываем audioLevel и maxAudioLevel - они сохраняются до нового теста
|
||||
setSoundDetected(false);
|
||||
setIsTestRunning(false);
|
||||
}
|
||||
@@ -227,20 +192,94 @@ function VoiceCheckModal({
|
||||
}
|
||||
}, [microphoneVolume]);
|
||||
|
||||
// Генерируем высоты для баров на основе уровня звука
|
||||
function generateBarHeights() {
|
||||
const baseHeights = [
|
||||
3, 12, 16, 20, 44, 28, 34, 60, 8, 40, 18, 36, 24, 13, 10, 6,
|
||||
];
|
||||
// Используем maxAudioLevel для сохранения максимальной высоты
|
||||
const levelToUse = isTestRunning ? audioLevel : maxAudioLevel;
|
||||
const multiplier = Math.min(levelToUse / 5);
|
||||
return baseHeights.map((h) => Math.max(3, Math.min(60, h * multiplier)));
|
||||
}
|
||||
// Состояние для баров эквалайзера (40 баров для плавных волн)
|
||||
const barCount = 40;
|
||||
const [barHeights, setBarHeights] = useState<number[]>(
|
||||
new Array(barCount).fill(3)
|
||||
);
|
||||
|
||||
const barHeights = generateBarHeights();
|
||||
// Обновляем бары эквалайзера в режиме реального времени
|
||||
useEffect(() => {
|
||||
if (!isTestRunning || !analyserRef.current) return;
|
||||
|
||||
console.log(barHeights);
|
||||
const updateBars = () => {
|
||||
if (!analyserRef.current) return;
|
||||
|
||||
const bufferLength = analyserRef.current.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
analyserRef.current.getByteFrequencyData(dataArray);
|
||||
|
||||
// Сначала создаём меньше опорных точек для интерполяции
|
||||
const keyPoints = Math.floor(barCount / 2.5); // ~16 опорных точек
|
||||
const keyHeights: number[] = [];
|
||||
|
||||
for (let i = 0; i < keyPoints; i++) {
|
||||
// Логарифмическое распределение частот
|
||||
const startFreq = Math.pow(2, (i / keyPoints) * 10);
|
||||
const endFreq = Math.pow(2, ((i + 1) / keyPoints) * 10);
|
||||
|
||||
const startIndex = Math.floor((startFreq / 1024) * bufferLength);
|
||||
const endIndex = Math.floor((endFreq / 1024) * bufferLength);
|
||||
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let j = startIndex; j < endIndex && j < bufferLength; j++) {
|
||||
sum += dataArray[j];
|
||||
count++;
|
||||
}
|
||||
|
||||
const average = count > 0 ? sum / count : 0;
|
||||
// Нормализуем к диапазону 3-60 пикселей
|
||||
const normalizedHeight = Math.min(
|
||||
60,
|
||||
Math.max(3, (average / 255) * 60 * 2.5)
|
||||
);
|
||||
keyHeights.push(normalizedHeight);
|
||||
}
|
||||
|
||||
// Интерполируем между опорными точками для плавных волн
|
||||
const newBarHeights: number[] = [];
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const position = (i / barCount) * (keyPoints - 1);
|
||||
const index = Math.floor(position);
|
||||
const fraction = position - index;
|
||||
|
||||
if (index >= keyPoints - 1) {
|
||||
newBarHeights.push(keyHeights[keyPoints - 1]);
|
||||
} else {
|
||||
// Линейная интерполяция между двумя опорными точками
|
||||
const interpolated =
|
||||
keyHeights[index] * (1 - fraction) +
|
||||
keyHeights[index + 1] * fraction;
|
||||
newBarHeights.push(interpolated);
|
||||
}
|
||||
}
|
||||
|
||||
setBarHeights(newBarHeights);
|
||||
|
||||
// Определяем наличие звука по максимальному бару
|
||||
const maxBar = Math.max(...newBarHeights);
|
||||
if (maxBar > 10) {
|
||||
// Если есть бары выше 10px, значит есть звук
|
||||
if (statusRef.current !== "success") {
|
||||
statusRef.current = "success";
|
||||
setStatus("success");
|
||||
}
|
||||
setSoundDetected(true);
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(updateBars);
|
||||
};
|
||||
|
||||
updateBars();
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [isTestRunning, barCount]);
|
||||
|
||||
return (
|
||||
<ModalWrapper className="2xl:max-w-[21.111vw] max-w-[304px]">
|
||||
@@ -255,31 +294,40 @@ function VoiceCheckModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Визуализация уровня звука */}
|
||||
<div className="flex flex-col items-center 2xl:gap-[0.556vw] gap-2">
|
||||
<div className="flex items-center justify-center 2xl:gap-[0.208vw] gap-[3px] 2xl:h-[4.167vw] h-[60px]">
|
||||
{barHeights.map((height, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={clsx(
|
||||
`2xl:rounded-[0.556vw] rounded-lg transition-all duration-150 2xl:w-[0.208vw] w-[3px]`,
|
||||
soundDetected ? "bg-[#7B60F3]" : "bg-[#D6D6D6]"
|
||||
)}
|
||||
style={{ height: `${(height / 1440) * innerWidth}px` }}
|
||||
/>
|
||||
))}
|
||||
{/* Эквалайзер */}
|
||||
<div className="flex flex-col 2xl:gap-[0.833vw] gap-3">
|
||||
{/* Визуализация эквалайзера */}
|
||||
<div className="flex flex-col items-center 2xl:gap-[0.556vw] gap-2">
|
||||
<div className="flex items-center justify-center 2xl:gap-[0.208vw] gap-[3px] 2xl:h-[4.167vw] h-[60px]">
|
||||
{barHeights.map((height, index) => (
|
||||
<div
|
||||
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]"
|
||||
)}
|
||||
style={{
|
||||
height: `${Math.max(3, height)}px`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Статус */}
|
||||
{!isTestRunning && status === "success" && (
|
||||
<p className="caption-s text-[#29AF61] font-medium">Звук есть!</p>
|
||||
)}
|
||||
{!isTestRunning && status === "error" && (
|
||||
<p className="caption-s text-[#FF4517] font-medium">
|
||||
Звук не обнаружен
|
||||
</p>
|
||||
)}
|
||||
{isTestRunning && (
|
||||
<p className="caption-s text-[#7D7D7D] font-medium">
|
||||
Проверка...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!isTestRunning && status === "success" && (
|
||||
<p className="caption-s text-[#29AF61] font-medium">Звук есть!</p>
|
||||
)}
|
||||
{!isTestRunning && status === "error" && (
|
||||
<p className="caption-s text-[#FF4517] font-medium">
|
||||
Звук не обнаружен
|
||||
</p>
|
||||
)}
|
||||
{isTestRunning && (
|
||||
<p className="caption-s text-[#7D7D7D] font-medium">Проверка...</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center 2xl:gap-[0.556vw] gap-2 text-center">
|
||||
|
||||
@@ -14,6 +14,7 @@ function QRCodePopup({ link }: QRCodePopupProps) {
|
||||
|
||||
return (
|
||||
<PopupWrapper
|
||||
className="w-[21.667vw]"
|
||||
draggable
|
||||
leftButton={
|
||||
<Button
|
||||
|
||||
@@ -9,11 +9,23 @@ import QRCodePopup from "./QRCodePopup";
|
||||
function SharePopup({ link }: { link: string }) {
|
||||
const { setPopup } = usePopupStore();
|
||||
|
||||
function handleShare() {
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: "Пригласить",
|
||||
text: "Присоединяйся к моей встрече",
|
||||
url: link,
|
||||
});
|
||||
} else {
|
||||
navigator.clipboard.writeText(link);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PopupWrapper
|
||||
title="Пригласить"
|
||||
draggable
|
||||
className="max-w-[21.667vw]"
|
||||
className="w-[21.667vw]"
|
||||
leftButton={
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -30,7 +42,12 @@ function SharePopup({ link }: { link: string }) {
|
||||
<p className="title-s mb-[0.833vw] font-medium">Скопировать ссылку</p>
|
||||
<LinkShare link={link} />
|
||||
</div>
|
||||
<Button variant="primary" size="large" className="w-full">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
className="w-full"
|
||||
onClick={handleShare}
|
||||
>
|
||||
Отправить
|
||||
<div className="2xl:size-[1.111vw] size-4">
|
||||
<ShareFilledIcon />
|
||||
|
||||
@@ -29,7 +29,7 @@ function Button({
|
||||
onClick?.(e);
|
||||
}}
|
||||
className={clsx(
|
||||
"transition-all select-none cursor-pointer disabled:!cursor-default flex outline-none gap-2 items-center justify-center font-medium disabled:bg-[#F6F6F6] disabled:!text-[#D6D6D6]",
|
||||
"transition-colors select-none cursor-pointer disabled:!cursor-default flex outline-none gap-2 items-center justify-center font-medium disabled:bg-[#F6F6F6] disabled:!text-[#D6D6D6]",
|
||||
isActive && "bg-[#F3F1FD] !text-[#7B60F3]",
|
||||
variant === "menu" &&
|
||||
"text-[#7D7D7D] hover:bg-[#F3F3F3] active:bg-[#F3F1FD] active:text-[#7B60F3]",
|
||||
|
||||
@@ -4,12 +4,10 @@ interface ControlButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
size: "small" | "large";
|
||||
icon: React.ReactNode;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
function ControlButton({
|
||||
size,
|
||||
enabled,
|
||||
icon,
|
||||
className,
|
||||
onClick,
|
||||
@@ -20,13 +18,10 @@ function ControlButton({
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
className={clsx(
|
||||
"backdrop-blur-[10px] rounded-full transition-all cursor-pointer disabled:!cursor-default outline-none",
|
||||
size === "large" ? "2xl:p-[0.833vw] p-3" : "2xl:p-[0.417vw] p-[6px]",
|
||||
!enabled
|
||||
? "bg-[#FF4517] hover:bg-[#FF4517]/85"
|
||||
: size === "large"
|
||||
? "bg-[#FFFFFF]/15 hover:bg-[#FFFFFF]/25"
|
||||
: "bg-[#141414]/15 hover:bg-[#141414]/25",
|
||||
"backdrop-blur-[10px] rounded-full transition-colors cursor-pointer disabled:!cursor-default outline-none disabled:bg-[#FF4517] disabled:hover:bg-[#FF4517]/85",
|
||||
size === "large"
|
||||
? "2xl:p-[0.833vw] p-3 enabled:bg-[#FFFFFF]/15 enabled:hover:bg-[#FFFFFF]/25"
|
||||
: "2xl:p-[0.417vw] p-[6px] enabled:bg-[#141414]/15 enabled:hover:bg-[#141414]/25",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -97,7 +97,9 @@ function Select({
|
||||
>
|
||||
<CheckIcon />
|
||||
</div>
|
||||
<span className="text-s">{option}</span>
|
||||
<span className="text-s line-clamp-1 text-ellipsis text-nowrap">
|
||||
{option}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import HandRaisedOffFilledIcon from "../icons/HandRaisedOffFilledIcon";
|
||||
import HandRaisedFilledIcon from "../icons/HandRaisedFilledIcon";
|
||||
import MicrophoneFilledIcon from "../icons/MicrophoneFilledIcon";
|
||||
@@ -23,7 +21,7 @@ interface UserCameraControlsProps {
|
||||
interface UserCameraProps extends UserCameraControlsProps {
|
||||
isAdmin?: boolean;
|
||||
name?: string;
|
||||
mediaStream?: string;
|
||||
mediaStream?: MediaStream | null;
|
||||
isSpeaking?: boolean;
|
||||
}
|
||||
|
||||
@@ -37,23 +35,25 @@ export default function UserCamera({
|
||||
isSpeaking = false,
|
||||
isAdmin = false,
|
||||
name = "Гость",
|
||||
mediaStream = "",
|
||||
mediaStream = null,
|
||||
}: UserCameraProps) {
|
||||
const [isHover, setIsHover] = useState(false);
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.srcObject = mediaStream;
|
||||
}
|
||||
}, [mediaStream]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
onMouseEnter={() => setIsHover(true)}
|
||||
onMouseLeave={() => setIsHover(false)}
|
||||
animate={{
|
||||
width: isHover ? "10.833vw" : "6.944vw",
|
||||
border: isSpeaking
|
||||
? "0.139vw solid #7B60F3"
|
||||
: "0.139vw solid #FFFFFF4D",
|
||||
}}
|
||||
<div
|
||||
className={clsx(
|
||||
"aspect-square group rounded-[1.667vw] bg-yellow-500 relative flex-shrink-0 transition-all duration-300 pointer-events-auto hover:w-[10.833vw]w-[6.944vw] shadow-[0_4px_40px_0_rgba(15,16,17,0.1),0_2px_2px_0_rgba(0,0,0,0.06)]",
|
||||
isAdmin && "order-3"
|
||||
"aspect-square group 2xl:rounded-[1.667vw] rounded-2xl relative flex-shrink-0 transition-[width,box-shadow,background-color] duration-300 pointer-events-auto hover:w-[10.833vw] w-[6.944vw] shadow-[0_4px_40px_0_rgba(15,16,17,0.1),0_2px_2px_0_rgba(0,0,0,0.06)]",
|
||||
isAdmin && "order-3",
|
||||
isSpeaking
|
||||
? "ring-[0.139vw] ring-[#7B60F3]"
|
||||
: "ring-[0.069vw] ring-[#FFFFFF4D]",
|
||||
isVideoOff ? "bg-green-500" : "bg-yellow-500"
|
||||
)}
|
||||
>
|
||||
{isAdmin && <Admin className="absolute top-0 right-0" />}
|
||||
@@ -66,7 +66,7 @@ export default function UserCamera({
|
||||
</div>
|
||||
|
||||
<video
|
||||
src={mediaStream}
|
||||
ref={ref}
|
||||
className="size-full object-cover"
|
||||
autoPlay
|
||||
muted={isMuted}
|
||||
@@ -77,12 +77,11 @@ export default function UserCamera({
|
||||
isMuted={isMuted}
|
||||
isVideoOff={isVideoOff}
|
||||
isControlDisabled={isControlDisabled}
|
||||
isHover={isHover}
|
||||
onMute={onMute}
|
||||
onVideoOff={onVideoOff}
|
||||
onCanControl={onCanControl}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,63 +89,51 @@ function UserCameraControls({
|
||||
isMuted,
|
||||
isVideoOff,
|
||||
isControlDisabled,
|
||||
isHover,
|
||||
onMute,
|
||||
onVideoOff,
|
||||
onCanControl,
|
||||
}: UserCameraControlsProps & { isHover: boolean }) {
|
||||
}: UserCameraControlsProps) {
|
||||
return (
|
||||
<div className="absolute bottom-[0.278vw] left-1/2 translate-x-[-50%]">
|
||||
<AnimatePresence mode="wait">
|
||||
{isHover ? (
|
||||
<motion.div
|
||||
key="controls"
|
||||
className="flex gap-[0.278vw] mb-[0.278vw]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ControlButton
|
||||
icon={isMuted ? <MicrophoneOffIcon /> : <MicrophoneFilledIcon />}
|
||||
size={"small"}
|
||||
enabled={!isMuted}
|
||||
onClick={onMute}
|
||||
/>
|
||||
<ControlButton
|
||||
icon={isVideoOff ? <VideoOffFilledIcon /> : <VideoFilledIcon />}
|
||||
size={"small"}
|
||||
enabled={!isVideoOff}
|
||||
onClick={onVideoOff}
|
||||
/>
|
||||
<ControlButton
|
||||
icon={
|
||||
isControlDisabled ? (
|
||||
<HandRaisedOffFilledIcon />
|
||||
) : (
|
||||
<HandRaisedFilledIcon />
|
||||
)
|
||||
}
|
||||
size={"small"}
|
||||
enabled={!isControlDisabled}
|
||||
onClick={onCanControl}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="controls-muted"
|
||||
className="size-[1.667vw] bg-[#14141426] backdrop-blur-[4px] rounded-full flex items-center justify-center z-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: isMuted ? 1 : 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="size-[0.972vw] text-white">
|
||||
<MicrophoneOffIcon />
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="absolute transition-[bottom] duration-300 2xl:bottom-[0.278vw] 2xl:group-hover:bottom-[0.556vw] group-hover:bottom-2 bottom-1 left-1/2 -translate-x-1/2">
|
||||
<div
|
||||
className={clsx(
|
||||
"2xl:size-[1.667vw] size-6 bg-[#14141426] backdrop-blur-[4px] transition-opacity duration-300 rounded-full flex items-center justify-center z-10a absolute left-1/2 -translate-x-1/2 2xl:bottom-0 [0.278vw] group-hover:opacity-0",
|
||||
isMuted ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
</AnimatePresence>
|
||||
>
|
||||
<div className="size-[0.972vw] text-white">
|
||||
<MicrophoneOffIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex gap-[0.278vw] mb-[0.278vw] group-hover:opacity-100 opacity-0 transition-opacity duration-300"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ControlButton
|
||||
icon={isMuted ? <MicrophoneOffIcon /> : <MicrophoneFilledIcon />}
|
||||
size={"small"}
|
||||
disabled={isMuted}
|
||||
onClick={onMute}
|
||||
/>
|
||||
<ControlButton
|
||||
icon={isVideoOff ? <VideoOffFilledIcon /> : <VideoFilledIcon />}
|
||||
size={"small"}
|
||||
disabled={isVideoOff}
|
||||
onClick={onVideoOff}
|
||||
/>
|
||||
<ControlButton
|
||||
icon={
|
||||
isControlDisabled ? (
|
||||
<HandRaisedOffFilledIcon />
|
||||
) : (
|
||||
<HandRaisedFilledIcon />
|
||||
)
|
||||
}
|
||||
size={"small"}
|
||||
disabled={isControlDisabled}
|
||||
onClick={onCanControl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,29 +23,29 @@ export default function UserDevicesControls() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hidden order-4 2xl:grid grid-cols-2 gap-[0.278vw] aspect-square p-[0.556vw] flex-wrap justify-between items-center size-[6.944vw] rounded-[1.667vw] bg-[#00000040] shadow-[0_4px_40px_0_#0F10111A] backdrop-blur-[10px] pointer-events-auto">
|
||||
<div className="hidden order-4 2xl:grid grid-cols-2 gap-[0.278vw] aspect-square p-[0.556vw] flex-wrap justify-between items-center size-[6.944vw] rounded-[1.667vw] bg-[#00000040] shadow-[0_4px_40px_0_#0F10111A] backdrop-blur-[10px] pointer-events-auto">
|
||||
<ControlButton
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
size="large"
|
||||
icon={<MicrophoneFilledIcon />}
|
||||
onClick={ToggleAudioDevice}
|
||||
enabled={true}
|
||||
/>
|
||||
<ControlButton
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
size="large"
|
||||
icon={<VideoFilledIcon />}
|
||||
enabled={true}
|
||||
onClick={ToggleVideoDevice}
|
||||
/>
|
||||
<ControlButton
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
size="large"
|
||||
icon={<HandRaisedFilledIcon />}
|
||||
onClick={ToggleCanControl}
|
||||
enabled={true}
|
||||
/>
|
||||
<ControlButton
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
size="large"
|
||||
icon={<CogFilledIcon />}
|
||||
enabled={true}
|
||||
onClick={ToggleSettings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,8 @@ import usePopupStore from "../store/popupStore";
|
||||
import SettingsModal from "../components/modals/SettingsModal";
|
||||
import useModalStore from "../store/modalStore";
|
||||
import CogFilledIcon from "../components/icons/CogFilledIcon";
|
||||
import ParticipantsPopup from "../components/popups/ParticipantsPopup";
|
||||
import SessionUsersPanel from "../components/SessionUsersPanel";
|
||||
import SharePopup from "../components/popups/SharePopup";
|
||||
|
||||
function HomePage() {
|
||||
const { data: user } = useMe();
|
||||
@@ -33,7 +33,9 @@ function HomePage() {
|
||||
|
||||
<FloatingActionButton
|
||||
variant="default"
|
||||
onClick={() => setPopup(<ParticipantsPopup />)}
|
||||
onClick={() =>
|
||||
setPopup(<SharePopup link="https://estate.stream/ahdy12jdco1" />)
|
||||
}
|
||||
>
|
||||
<div className="2xl:size-[1.111vw] size-4 text-white">
|
||||
<ShareFilledIcon />
|
||||
|
||||
Reference in New Issue
Block a user