619bd91df4
Клиентская часть Фазы 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.
125 lines
4.3 KiB
TypeScript
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 };
|
|
}
|