feat(voice): саундборд, звуковая палитра, push-to-talk и статистика соединения
Клиентская часть Фазы 3 (AGENT.md 7.13, 7.14): звуки сервера, локальные
звуки интерфейса и телеметрия медиа.
- саундборд: `api/sounds.ts` (лимиты 512 КБ и 30 на вид, multipart-загрузка,
play, `SOUND_EVENTS`), `lib/soundboard.ts` (blob-кэш `/files/{id}`,
наложение до 8 звуков, громкость = `outputVolume`, тишина при дефене),
попап в панели комнаты (`USE_SOUNDBOARD`), таймер на `rate_limited`,
подпись «включил звук» и защита от двойного воспроизведения;
- звуковая палитра: `SOUNDBOARD_PLAY` и события сервера (вход/выход
участника, упоминание, сообщение, вход/выход из голосовой, свои
микрофон/камера/экран, серверный мьют) → `playEventSound`;
- секция «Звуки» в настройках сервера (`MANAGE_SOUNDS`): два вида со
счётчиками, drag&drop, имя, эмодзи, событие палитры, переименование,
удаление и клиентские проверки файла;
- push-to-talk: режим и клавиша (`event.code`), хук в `AppLayout`, вход в
комнату с выключенным микрофоном, удержание включает публикацию, отпускание
и потеря фокуса выключают, серверный мьют приоритетнее;
- статистика: поповер с разрешением, fps, кодеком, битрейтом, RTT, потерями
и jitter (опрос раз в секунду, остановка при закрытии), выбор качества
публикации low/medium/high, пресеты шаринга экрана 1080p60…480p30;
- данные: `guilds[].sounds` из READY, `GUILD_SOUNDS_UPDATE`, селектор
`useGuildSounds`, тумблеры звуков в «Аудио-видео» (localStorage);
- тесты: `soundboard`, `soundSettings`, `pushToTalk`, `voiceStats` и
расширенный фейковый LiveKit SDK.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { playSoundboardSound, type GuildSound } from '@/api/sounds';
|
||||
import { apiErrorCode } from '@/api/client';
|
||||
import { retryAfterMs } from '@/api/messages';
|
||||
import { Popover } from '@/components/ui/Popover';
|
||||
import { useGuildSounds } from '@/components/voice/useGuildSounds';
|
||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useSoundboardStore } from '@/stores/soundboard';
|
||||
|
||||
/** Сколько миллисекунд в секунде: обратный отсчёт лимита (429). */
|
||||
const SECOND_MS = 1_000;
|
||||
|
||||
/**
|
||||
* Попап «Саундборд» в панели голосовой комнаты (AGENT.md 7.13).
|
||||
*
|
||||
* Звуки берутся из снапшота сервера (READY + `GUILD_SOUNDS_UPDATE`). Нажатие
|
||||
* уходит на сервер (`POST .../play`), а звук играет локально сразу: событие
|
||||
* `SOUNDBOARD_PLAY` придёт и остальным участникам комнаты. Лимит сервера —
|
||||
* 3 звука за 10 секунд: на 429 показываем обратный отсчёт, на
|
||||
* `voice.not_connected` — подсказку «сначала войдите в комнату».
|
||||
*/
|
||||
export function VoiceSoundboard({ guildId, disabled }: { guildId: string; disabled: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const describeError = useApiErrorMessage();
|
||||
const sounds = useGuildSounds(guildId, 'soundboard');
|
||||
const myId = useSessionStore((state) => state.user?.id ?? '');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
/** Активный лимит: `id` отличает новый отсчёт от предыдущего. */
|
||||
const [limit, setLimit] = useState<{ id: number; seconds: number } | null>(null);
|
||||
const limitSeq = useRef(0);
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const limitId = limit?.id ?? null;
|
||||
|
||||
// Обратный отсчёт лимита: пока он идёт, кнопки проигрывания выключены.
|
||||
useEffect(() => {
|
||||
if (limitId === null) {
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
setLimit((current) => {
|
||||
if (current === null || current.id !== limitId) {
|
||||
return current;
|
||||
}
|
||||
return current.seconds <= 1 ? null : { ...current, seconds: current.seconds - 1 };
|
||||
});
|
||||
}, SECOND_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [limitId]);
|
||||
|
||||
const secondsLeft = limit?.seconds ?? 0;
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
const visible =
|
||||
needle === ''
|
||||
? sounds
|
||||
: sounds.filter((sound) => sound.name.toLocaleLowerCase().includes(needle));
|
||||
const notConnected = apiErrorCode(error) === 'voice.not_connected';
|
||||
|
||||
const play = async (sound: GuildSound): Promise<void> => {
|
||||
if (pending !== null || secondsLeft > 0) {
|
||||
return;
|
||||
}
|
||||
setPending(sound.id);
|
||||
setError(null);
|
||||
try {
|
||||
await playSoundboardSound(guildId, sound.id);
|
||||
// Свой звук играем сразу: событие шлюза придёт позже и будет распознано
|
||||
// как эхо (сопоставление по sound_id + user_id + времени).
|
||||
useSoundboardStore.getState().playMySound(guildId, sound, myId);
|
||||
} catch (caught) {
|
||||
setError(caught);
|
||||
const wait = retryAfterMs(caught);
|
||||
if (wait !== null) {
|
||||
limitSeq.current += 1;
|
||||
setLimit({ id: limitSeq.current, seconds: Math.max(1, Math.ceil(wait / SECOND_MS)) });
|
||||
}
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-label={t('voice.soundboard.open')}
|
||||
title={t('voice.soundboard.open')}
|
||||
disabled={disabled}
|
||||
data-testid="voice-soundboard-button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-3 py-1.5 text-base leading-none text-fg transition-[color,background-color,transform] duration-[var(--duration-fast)] active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span aria-hidden="true">🔊</span>
|
||||
</button>
|
||||
|
||||
<Popover
|
||||
open={open}
|
||||
label={t('voice.soundboard.title')}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorRef={anchorRef}
|
||||
className="w-72"
|
||||
>
|
||||
<div data-testid="voice-soundboard-panel" className="flex flex-col gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase text-fg-muted">
|
||||
{t('voice.soundboard.searchLabel')}
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
aria-label={t('voice.soundboard.searchLabel')}
|
||||
placeholder={t('voice.soundboard.searchPlaceholder')}
|
||||
data-testid="voice-soundboard-search"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-1 px-2 py-1 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{secondsLeft > 0 ? (
|
||||
<p role="status" data-testid="voice-soundboard-limit" className="text-xs text-warning">
|
||||
{t('voice.soundboard.rateLimited', { seconds: secondsLeft })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{notConnected ? (
|
||||
<p role="alert" data-testid="voice-soundboard-error" className="text-xs text-warning">
|
||||
{t('voice.soundboard.notConnected')}
|
||||
</p>
|
||||
) : error !== null ? (
|
||||
<p role="alert" data-testid="voice-soundboard-error" className="text-xs text-danger">
|
||||
{describeError(error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{sounds.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t('voice.soundboard.empty')}</p>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t('voice.soundboard.noResults')}</p>
|
||||
) : (
|
||||
<ul className="grid grid-cols-2 gap-1">
|
||||
{visible.map((sound) => (
|
||||
<li key={sound.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`voice-soundboard-sound-${sound.id}`}
|
||||
aria-label={t('voice.soundboard.playLabel', { name: sound.name })}
|
||||
title={sound.name}
|
||||
disabled={pending !== null || secondsLeft > 0}
|
||||
onClick={() => void play(sound)}
|
||||
className="flex w-full items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1.5 text-left text-sm text-fg transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span aria-hidden="true">{sound.emoji ?? '🎵'}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{sound.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user