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(null); const [error, setError] = useState(null); /** Активный лимит: `id` отличает новый отсчёт от предыдущего. */ const [limit, setLimit] = useState<{ id: number; seconds: number } | null>(null); const limitSeq = useRef(0); const anchorRef = useRef(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 => { 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 ( <> setOpen(false)} anchorRef={anchorRef} className="w-72" >
{secondsLeft > 0 ? (

{t('voice.soundboard.rateLimited', { seconds: secondsLeft })}

) : null} {notConnected ? (

{t('voice.soundboard.notConnected')}

) : error !== null ? (

{describeError(error)}

) : null} {sounds.length === 0 ? (

{t('voice.soundboard.empty')}

) : visible.length === 0 ? (

{t('voice.soundboard.noResults')}

) : (
    {visible.map((sound) => (
  • ))}
)}
); }