Files
glchat/web/src/components/voice/VoiceSoundboard.tsx
T

172 lines
7.3 KiB
TypeScript
Raw Normal View History

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>
</>
);
}