172 lines
7.3 KiB
TypeScript
172 lines
7.3 KiB
TypeScript
|
|
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>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|