Files
grendervill 619bd91df4 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.
2026-09-20 19:30:43 +03:00

125 lines
4.3 KiB
TypeScript

import { apiError, json, type FetchRoute } from './helpers';
import type { GuildSound } from '@/api/sounds';
/**
* Маршруты ручек звуков для тестов: список, загрузка, переименование,
* удаление и просьба сыграть. Состояние живёт в замыкании, поэтому ответы
* согласованы между запросами (как у настоящего сервера).
*/
export interface SoundRoutesOptions {
guildId?: string;
/** Набор звуков «сервера»: его же проверяют тесты. */
sounds?: GuildSound[];
/** Ошибка на `POST .../play` (лимит 429, `voice.not_connected` и т.п.). */
playError?: { code: string; status: number; retryAfterMs?: number };
/** Ошибка на загрузку звука (`sound.name_taken`, `sounds.limit_reached`). */
uploadError?: { code: string; status: number };
}
export interface SoundRoutes {
routes: FetchRoute[];
sounds: GuildSound[];
/** Сколько раз просили сыграть звук. */
playCount: () => number;
}
export function soundRoutes(options: SoundRoutesOptions = {}): SoundRoutes {
const guildId = options.guildId ?? 'g-1';
const sounds = [...(options.sounds ?? [])];
let plays = 0;
const list = (kind: string | null): GuildSound[] =>
kind === null ? sounds : sounds.filter((sound) => sound.kind === kind);
const routes: FetchRoute[] = [
{
match: `/guilds/${guildId}/sounds/`,
method: 'POST',
response: ({ url }) => {
plays += 1;
if (options.playError !== undefined) {
const { code, status, retryAfterMs } = options.playError;
return json(
{
error: {
code,
message: 'error',
...(retryAfterMs === undefined ? {} : { retry_after_ms: retryAfterMs }),
},
},
status,
);
}
// `/guilds/{id}/sounds/{sound_id}/play`
const soundId = url.split('/sounds/')[1]?.split('/')[0] ?? '';
const known = sounds.find((sound) => sound.id === soundId);
return known === undefined ? apiError('sounds.not_found', 404) : json({ ok: true });
},
},
{
match: `/guilds/${guildId}/sounds`,
method: 'POST',
response: ({ url }) => {
if (options.uploadError !== undefined) {
return apiError(options.uploadError.code, options.uploadError.status);
}
const kind = url.includes('kind=ui') ? 'ui' : 'soundboard';
const created: GuildSound = {
id: `s-${sounds.length + 1}`,
name: 'uploaded',
file_id: '950',
kind,
...(kind === 'ui' ? { event: 'member_join' as const } : {}),
};
sounds.push(created);
return json({ sound: created });
},
},
{
match: `/guilds/${guildId}/sounds/`,
method: 'PATCH',
response: ({ url, body }) => {
const soundId = url.split('/sounds/')[1]?.split(/[/?]/u)[0] ?? '';
const patch = (body ?? {}) as Record<string, unknown>;
const index = sounds.findIndex((sound) => sound.id === soundId);
const current = sounds[index];
if (current === undefined) {
return apiError('sounds.not_found', 404);
}
const next: GuildSound = {
...current,
...(typeof patch['name'] === 'string' ? { name: patch['name'] } : {}),
};
sounds[index] = next;
return json({ sound: next });
},
},
{
match: `/guilds/${guildId}/sounds/`,
method: 'DELETE',
response: ({ url }) => {
const soundId = url.split('/sounds/')[1]?.split(/[/?]/u)[0] ?? '';
const index = sounds.findIndex((sound) => sound.id === soundId);
if (index >= 0) {
sounds.splice(index, 1);
}
return json({ ok: true });
},
},
{
match: `/guilds/${guildId}/sounds`,
response: ({ url }) => {
const kind = url.includes('kind=ui')
? 'ui'
: url.includes('kind=soundboard')
? 'soundboard'
: null;
return json({ sounds: list(kind) });
},
},
];
return { routes, sounds, playCount: () => plays };
}