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:
@@ -1,9 +1,11 @@
|
||||
import { parseGuildEmojiList } from './emojis';
|
||||
import { parseChannelPayload } from './gatewayEvents';
|
||||
import { parseGuildSoundList } from './sounds';
|
||||
import { parseVoiceStateList } from './voice';
|
||||
import { describeUnknown } from '@/lib/http';
|
||||
|
||||
import type { GuildEmoji } from './emojis';
|
||||
import type { GuildSound } from './sounds';
|
||||
import type { VoiceState } from './voice';
|
||||
import type { Channel, Role, UserStatus } from './types';
|
||||
|
||||
@@ -49,6 +51,8 @@ export interface GatewayGuild {
|
||||
roles: Role[];
|
||||
/** Кастомные эмодзи сервера (AGENT.md 7.12). */
|
||||
emojis: GuildEmoji[];
|
||||
/** Саундборд и звуковая палитра сервера (AGENT.md 7.13). */
|
||||
sounds: GuildSound[];
|
||||
/** Кто из участников в голосовых комнатах (AGENT.md 7.14). */
|
||||
voice_states: VoiceState[];
|
||||
member_ids: string[];
|
||||
@@ -240,6 +244,7 @@ function parseGuild(value: unknown): GatewayGuild | null {
|
||||
channels,
|
||||
roles,
|
||||
emojis: parseGuildEmojiList(record['emojis']),
|
||||
sounds: parseGuildSoundList(record['sounds']),
|
||||
voice_states: parseVoiceStateList(record['voice_states']),
|
||||
member_ids: asStringArray(record['member_ids']),
|
||||
my_role_ids: asStringArray(record['my_role_ids']),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { parseGuildEmojiList } from './emojis';
|
||||
import { parseGuildSoundList } from './sounds';
|
||||
import { userStatuses, type Channel, type UserStatus } from './types';
|
||||
import type { GatewayUser } from './gateway';
|
||||
import type { GuildEmoji } from './emojis';
|
||||
import type { GuildSound } from './sounds';
|
||||
import type { Message, MessageAttachment, MessageReaction } from './messages';
|
||||
|
||||
/**
|
||||
@@ -58,6 +60,22 @@ export interface GuildEmojisUpdateEvent {
|
||||
emojis: GuildEmoji[];
|
||||
}
|
||||
|
||||
export interface GuildSoundsUpdateEvent {
|
||||
guild_id: string;
|
||||
sounds: GuildSound[];
|
||||
}
|
||||
|
||||
/** Событие `SOUNDBOARD_PLAY` (AGENT.md 7.13). */
|
||||
export interface SoundboardPlayEvent {
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
sound_id: string;
|
||||
file_id: string;
|
||||
name: string;
|
||||
user_id: string;
|
||||
played_at: string;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
@@ -203,6 +221,53 @@ export function parseGuildEmojisUpdate(value: unknown): GuildEmojisUpdateEvent |
|
||||
return { guild_id: guildId, emojis: parseGuildEmojiList(record['emojis']) };
|
||||
}
|
||||
|
||||
/**
|
||||
* `GUILD_SOUNDS_UPDATE`: набор звуков сервера изменился (AGENT.md 7.13).
|
||||
* Список приходит целиком — тем же форматом, что в снапшоте READY.
|
||||
*/
|
||||
export function parseGuildSoundsUpdate(value: unknown): GuildSoundsUpdateEvent | null {
|
||||
const record = asRecord(value);
|
||||
const guildId = asString(record?.['guild_id']);
|
||||
if (record === null || guildId === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { guild_id: guildId, sounds: parseGuildSoundList(record['sounds']) };
|
||||
}
|
||||
|
||||
/**
|
||||
* `SOUNDBOARD_PLAY`: кто-то сыграл звук в голосовой комнате. Событие
|
||||
* приходит только участникам этой комнаты, и каждый клиент играет звук сам.
|
||||
*/
|
||||
export function parseSoundboardPlay(value: unknown): SoundboardPlayEvent | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return null;
|
||||
}
|
||||
const guildId = asString(record['guild_id']);
|
||||
const channelId = asString(record['channel_id']);
|
||||
const soundId = asString(record['sound_id']);
|
||||
const fileId = asString(record['file_id']);
|
||||
const userId = asString(record['user_id']);
|
||||
if (
|
||||
guildId === undefined ||
|
||||
channelId === undefined ||
|
||||
soundId === undefined ||
|
||||
fileId === undefined ||
|
||||
userId === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
guild_id: guildId,
|
||||
channel_id: channelId,
|
||||
sound_id: soundId,
|
||||
file_id: fileId,
|
||||
name: asString(record['name']) ?? '',
|
||||
user_id: userId,
|
||||
played_at: asString(record['played_at']) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Канал из `CHANNEL_CREATE`/`CHANNEL_UPDATE` — та же форма, что в REST. */
|
||||
export function parseChannelPayload(value: unknown): Channel | null {
|
||||
const record = asRecord(value);
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { request } from './client';
|
||||
|
||||
/**
|
||||
* Саундборд и звуковая палитра сервера (AGENT.md 7.13).
|
||||
*
|
||||
* Контракт один и тот же в трёх местах: `GET /guilds/{id}/sounds`, снапшот
|
||||
* READY (`guilds[].sounds`) и событие шлюза `GUILD_SOUNDS_UPDATE`, поэтому
|
||||
* разбор payload'а общий для всех источников. Сам звук — обычный файл
|
||||
* (`/files/{file_id}`), который клиент скачивает и играет локально.
|
||||
*/
|
||||
|
||||
/** Вид набора: звуки саундборда или звуки интерфейса/событий. */
|
||||
export type SoundKind = 'soundboard' | 'ui';
|
||||
|
||||
/**
|
||||
* События, к которым сервер разрешает привязать UI-звук (AGENT.md 7.13).
|
||||
* Список — контракт: сервер отвергает всё остальное.
|
||||
*/
|
||||
export const SOUND_EVENTS = [
|
||||
'member_join',
|
||||
'member_leave',
|
||||
'mention',
|
||||
'message',
|
||||
'message_send',
|
||||
'voice_join',
|
||||
'voice_leave',
|
||||
'mic_on',
|
||||
'mic_off',
|
||||
'camera_on',
|
||||
'camera_off',
|
||||
'screen_on',
|
||||
'screen_off',
|
||||
'kick',
|
||||
'ban',
|
||||
'mute',
|
||||
'role_grant',
|
||||
'role_revoke',
|
||||
] as const;
|
||||
|
||||
export type SoundEvent = (typeof SOUND_EVENTS)[number];
|
||||
|
||||
/** Звук сервера: элемент `sounds` из READY, REST и `GUILD_SOUNDS_UPDATE`. */
|
||||
export interface GuildSound {
|
||||
id: string;
|
||||
name: string;
|
||||
file_id: string;
|
||||
kind: SoundKind;
|
||||
/** Событие UI-палитры: только у `kind: 'ui'`. */
|
||||
event?: SoundEvent;
|
||||
/** Необязательная эмодзи-иконка для сетки саундборда. */
|
||||
emoji?: string;
|
||||
/** Ссылка на файл (REST); в снапшоте READY её нет. */
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/** Лимиты из контракта: 512 КБ на звук, 30 звуков каждого вида на сервер. */
|
||||
export const SOUND_MAX_BYTES = 512 * 1024;
|
||||
export const SOUND_LIMIT_PER_KIND = 30;
|
||||
|
||||
/** Имя звука: та же проверка, что на сервере. */
|
||||
export const SOUND_NAME_PATTERN = /^[a-zA-Z0-9_]{2,32}$/u;
|
||||
|
||||
/**
|
||||
* Форматы, которые принимает сервер: MP3/OGG/WAV/WebM/M4A. Браузеры
|
||||
* называют одни и те же контейнеры по-разному, поэтому проверяем и MIME,
|
||||
* и расширение файла.
|
||||
*/
|
||||
export const SOUND_CONTENT_TYPES = [
|
||||
'audio/mpeg',
|
||||
'audio/mp3',
|
||||
'audio/ogg',
|
||||
'application/ogg',
|
||||
'audio/wav',
|
||||
'audio/wave',
|
||||
'audio/x-wav',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
'audio/m4a',
|
||||
'audio/x-m4a',
|
||||
] as const;
|
||||
|
||||
const SOUND_EXTENSIONS = ['.mp3', '.ogg', '.oga', '.wav', '.webm', '.m4a'] as const;
|
||||
|
||||
export const guildSoundsQueryKey = (guildId: string, kind?: SoundKind) =>
|
||||
kind === undefined
|
||||
? (['guilds', guildId, 'sounds'] as const)
|
||||
: (['guilds', guildId, 'sounds', kind] as const);
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value !== '' ? value : undefined;
|
||||
}
|
||||
|
||||
/** Разрешён ли файл по формату (размер проверяется отдельно). */
|
||||
export function isAllowedSoundType(file: File): boolean {
|
||||
if ((SOUND_CONTENT_TYPES as readonly string[]).includes(file.type)) {
|
||||
return true;
|
||||
}
|
||||
const name = file.name.toLocaleLowerCase();
|
||||
return SOUND_EXTENSIONS.some((extension) => name.endsWith(extension));
|
||||
}
|
||||
|
||||
/** Проходит ли имя клиентскую проверку (та же, что на сервере). */
|
||||
export function isValidSoundName(name: string): boolean {
|
||||
return SOUND_NAME_PATTERN.test(name);
|
||||
}
|
||||
|
||||
/** Событие из контракта или `undefined` (неизвестные имена отбрасываем). */
|
||||
export function asSoundEvent(value: unknown): SoundEvent | undefined {
|
||||
return typeof value === 'string' && (SOUND_EVENTS as readonly string[]).includes(value)
|
||||
? (value as SoundEvent)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбор одного звука. Поле `kind` может отсутствовать (старые снапшоты) —
|
||||
* считаем такой звук саундбордом, как и сервер по умолчанию.
|
||||
*/
|
||||
export function parseGuildSound(value: unknown): GuildSound | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return null;
|
||||
}
|
||||
const id = asString(record['id']);
|
||||
const name = asString(record['name']);
|
||||
const fileId = asString(record['file_id']);
|
||||
if (id === undefined || name === undefined || fileId === undefined) {
|
||||
return null;
|
||||
}
|
||||
const kind = record['kind'] === 'ui' ? 'ui' : 'soundboard';
|
||||
const sound: GuildSound = { id, name, file_id: fileId, kind };
|
||||
const event = asSoundEvent(record['event']);
|
||||
const emoji = asString(record['emoji']);
|
||||
const url = asString(record['url']);
|
||||
if (event !== undefined) {
|
||||
sound.event = event;
|
||||
}
|
||||
if (emoji !== undefined) {
|
||||
sound.emoji = emoji;
|
||||
}
|
||||
if (url !== undefined) {
|
||||
sound.url = url;
|
||||
}
|
||||
return sound;
|
||||
}
|
||||
|
||||
/** Разбор списка звуков: битые записи отбрасываются. */
|
||||
export function parseGuildSoundList(value: unknown): GuildSound[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map(parseGuildSound).filter((item): item is GuildSound => item !== null);
|
||||
}
|
||||
|
||||
/** Список звуков сервера; без `kind` сервер отдаёт оба набора. */
|
||||
export async function fetchGuildSounds(
|
||||
guildId: string,
|
||||
kind?: SoundKind,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GuildSound[]> {
|
||||
const query = kind === undefined ? '' : `?kind=${kind}`;
|
||||
const payload = await request<{ sounds?: unknown }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/sounds${query}`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return parseGuildSoundList(payload.sounds);
|
||||
}
|
||||
|
||||
/** Тело загрузки: имя, файл, для палитры — событие, опционально эмодзи. */
|
||||
export interface SoundUploadInput {
|
||||
name: string;
|
||||
file: File;
|
||||
event?: SoundEvent;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
/** Загрузка звука: multipart с полями `name`, `file`, `event` и `emoji`. */
|
||||
export async function uploadGuildSound(
|
||||
guildId: string,
|
||||
kind: SoundKind,
|
||||
input: SoundUploadInput,
|
||||
): Promise<GuildSound | null> {
|
||||
const form = new FormData();
|
||||
form.append('name', input.name);
|
||||
form.append('file', input.file);
|
||||
if (input.event !== undefined) {
|
||||
form.append('event', input.event);
|
||||
}
|
||||
if (input.emoji !== undefined && input.emoji !== '') {
|
||||
form.append('emoji', input.emoji);
|
||||
}
|
||||
const payload = await request<{ sound?: unknown }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/sounds?kind=${kind}`,
|
||||
{ method: 'POST', form },
|
||||
);
|
||||
return parseGuildSound(payload.sound);
|
||||
}
|
||||
|
||||
/** Переименование: сервер принимает `name` и необязательный `emoji`. */
|
||||
export async function renameGuildSound(
|
||||
guildId: string,
|
||||
soundId: string,
|
||||
input: { name: string; emoji?: string },
|
||||
): Promise<GuildSound | null> {
|
||||
const payload = await request<{ sound?: unknown }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/sounds/${encodeURIComponent(soundId)}`,
|
||||
{ method: 'PATCH', body: input },
|
||||
);
|
||||
return parseGuildSound(payload.sound);
|
||||
}
|
||||
|
||||
export async function deleteGuildSound(guildId: string, soundId: string): Promise<void> {
|
||||
await request<{ ok: true }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/sounds/${encodeURIComponent(soundId)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Просьба сыграть звук в голосовой комнате. Сервер проверит право
|
||||
* `USE_SOUNDBOARD`, присутствие в комнате (`voice.not_connected`) и лимит
|
||||
* 3 звука за 10 секунд (`rate_limited`).
|
||||
*/
|
||||
export async function playSoundboardSound(guildId: string, soundId: string): Promise<void> {
|
||||
await request<{ ok: true }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/sounds/${encodeURIComponent(soundId)}/play`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user