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,124 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user