From 619bd91df4ae5e04595b1b89b87ba8d9cc5d98da Mon Sep 17 00:00:00 2001
From: grendervill
Date: Sun, 20 Sep 2026 19:30:43 +0300
Subject: [PATCH] =?UTF-8?q?feat(voice):=20=D1=81=D0=B0=D1=83=D0=BD=D0=B4?=
=?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=B4,=20=D0=B7=D0=B2=D1=83=D0=BA=D0=BE?=
=?UTF-8?q?=D0=B2=D0=B0=D1=8F=20=D0=BF=D0=B0=D0=BB=D0=B8=D1=82=D1=80=D0=B0?=
=?UTF-8?q?,=20push-to-talk=20=D0=B8=20=D1=81=D1=82=D0=B0=D1=82=D0=B8?=
=?UTF-8?q?=D1=81=D1=82=D0=B8=D0=BA=D0=B0=20=D1=81=D0=BE=D0=B5=D0=B4=D0=B8?=
=?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Клиентская часть Фазы 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.
---
web/.prettierignore | 2 +
web/src/api/gateway.ts | 5 +
web/src/api/gatewayEvents.ts | 65 +++
web/src/api/sounds.ts | 232 +++++++++
web/src/components/chat/MessageItem.tsx | 7 +
web/src/components/voice/SoundboardNotice.tsx | 52 ++
web/src/components/voice/VoiceRoomPanel.tsx | 147 +++++-
web/src/components/voice/VoiceSoundboard.tsx | 171 +++++++
web/src/components/voice/VoiceStatsButton.tsx | 139 +++++
web/src/components/voice/useGuildSounds.ts | 29 ++
web/src/components/voice/usePushToTalk.ts | 134 +++++
web/src/components/voice/useVoiceOccupants.ts | 25 +-
web/src/i18n/locales/en.json | 137 ++++-
web/src/i18n/locales/ru.json | 137 ++++-
web/src/lib/identity.ts | 36 ++
web/src/lib/livekit.ts | 237 ++++++++-
web/src/lib/soundboard.ts | 198 +++++++
web/src/lib/systemMessage.ts | 3 +-
web/src/pages/app/AppLayout.tsx | 8 +
web/src/pages/settings/GuildSettingsPage.tsx | 16 +-
web/src/pages/settings/GuildSoundsSection.tsx | 433 ++++++++++++++++
.../pages/settings/VoiceSettingsSection.tsx | 95 +++-
web/src/stores/gateway.ts | 91 +++-
web/src/stores/session.ts | 78 ++-
web/src/stores/soundboard.ts | 150 ++++++
web/src/stores/voice.ts | 94 +++-
web/src/stores/voiceConnection.ts | 90 +++-
web/tests/autocomplete.test.tsx | 1 +
web/tests/fakeAudio.ts | 63 +++
web/tests/fakeLiveKit.ts | 59 ++-
web/tests/guildEmojis.test.tsx | 1 +
web/tests/helpers.tsx | 20 +
web/tests/markdown.test.tsx | 1 +
web/tests/pushToTalk.test.tsx | 216 ++++++++
web/tests/soundRoutes.ts | 124 +++++
web/tests/soundSettings.test.tsx | 318 ++++++++++++
web/tests/soundboard.test.tsx | 482 ++++++++++++++++++
web/tests/voice.test.tsx | 1 +
web/tests/voiceConnection.test.ts | 1 +
web/tests/voiceStats.test.tsx | 219 ++++++++
40 files changed, 4269 insertions(+), 48 deletions(-)
create mode 100644 web/src/api/sounds.ts
create mode 100644 web/src/components/voice/SoundboardNotice.tsx
create mode 100644 web/src/components/voice/VoiceSoundboard.tsx
create mode 100644 web/src/components/voice/VoiceStatsButton.tsx
create mode 100644 web/src/components/voice/useGuildSounds.ts
create mode 100644 web/src/components/voice/usePushToTalk.ts
create mode 100644 web/src/lib/soundboard.ts
create mode 100644 web/src/pages/settings/GuildSoundsSection.tsx
create mode 100644 web/src/stores/soundboard.ts
create mode 100644 web/tests/fakeAudio.ts
create mode 100644 web/tests/pushToTalk.test.tsx
create mode 100644 web/tests/soundRoutes.ts
create mode 100644 web/tests/soundSettings.test.tsx
create mode 100644 web/tests/soundboard.test.tsx
create mode 100644 web/tests/voiceStats.test.tsx
diff --git a/web/.prettierignore b/web/.prettierignore
index 28cb763..b2b15f2 100644
--- a/web/.prettierignore
+++ b/web/.prettierignore
@@ -2,3 +2,5 @@ dist
node_modules
coverage
*.min.js
+test-results
+playwright-report
diff --git a/web/src/api/gateway.ts b/web/src/api/gateway.ts
index edd1eb6..b00a69b 100644
--- a/web/src/api/gateway.ts
+++ b/web/src/api/gateway.ts
@@ -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']),
diff --git a/web/src/api/gatewayEvents.ts b/web/src/api/gatewayEvents.ts
index 2be8b10..1256d32 100644
--- a/web/src/api/gatewayEvents.ts
+++ b/web/src/api/gatewayEvents.ts
@@ -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 | null {
return typeof value === 'object' && value !== null ? (value as Record) : 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);
diff --git a/web/src/api/sounds.ts b/web/src/api/sounds.ts
new file mode 100644
index 0000000..b1d7553
--- /dev/null
+++ b/web/src/api/sounds.ts
@@ -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 | null {
+ return typeof value === 'object' && value !== null ? (value as Record) : 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ await request<{ ok: true }>(
+ `/guilds/${encodeURIComponent(guildId)}/sounds/${encodeURIComponent(soundId)}/play`,
+ { method: 'POST' },
+ );
+}
diff --git a/web/src/components/chat/MessageItem.tsx b/web/src/components/chat/MessageItem.tsx
index e52a1bc..b2d0d89 100644
--- a/web/src/components/chat/MessageItem.tsx
+++ b/web/src/components/chat/MessageItem.tsx
@@ -177,6 +177,13 @@ function SystemMessageBody({ message, authors }: { message: Message; authors: Au
);
}
+ if (event.kind === 'member_leave') {
+ return (
+
+ {t('chat.system.memberLeave', { name: nameOf(event.userId) })}
+
+ );
+ }
if (event.kind === 'member_kick') {
return (
diff --git a/web/src/components/voice/SoundboardNotice.tsx b/web/src/components/voice/SoundboardNotice.tsx
new file mode 100644
index 0000000..b3ad467
--- /dev/null
+++ b/web/src/components/voice/SoundboardNotice.tsx
@@ -0,0 +1,52 @@
+import { useTranslation } from 'react-i18next';
+
+import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
+import { useSoundboardStore } from '@/stores/soundboard';
+
+/**
+ * Всплывающая подпись «{имя} включил звук {name}» (AGENT.md 7.13).
+ *
+ * Живёт в оболочке приложения — рядом с подсказкой о выключенном микрофоне,
+ * поэтому её видно с любого экрана. Гаснет сама: время жизни держит
+ * `stores/soundboard` (3 секунды), компонент только рисует текст.
+ */
+export function SoundboardNotice() {
+ const { t } = useTranslation();
+ const notice = useSoundboardStore((state) => state.notice);
+ const clear = useSoundboardStore((state) => state.clearNotice);
+ // Имя того, кто сыграл звук, резолвит общий справочник авторов.
+ const authors = useAuthorDirectory(
+ notice?.guildId ?? null,
+ notice === null ? [] : [notice.userId],
+ );
+
+ if (notice === null) {
+ return null;
+ }
+
+ return (
+
+
🔊
+
+ {t('voice.soundboard.playedBy', {
+ name: authors.resolve(notice.userId).name,
+ sound: notice.soundName,
+ })}
+
+
+
+ );
+}
diff --git a/web/src/components/voice/VoiceRoomPanel.tsx b/web/src/components/voice/VoiceRoomPanel.tsx
index ac6396f..75b3d1a 100644
--- a/web/src/components/voice/VoiceRoomPanel.tsx
+++ b/web/src/components/voice/VoiceRoomPanel.tsx
@@ -3,13 +3,17 @@ import { useTranslation } from 'react-i18next';
import type { Channel } from '@/api/types';
import { Avatar } from '@/components/ui/Avatar';
+import { Menu, MenuGroupLabel, MenuItem } from '@/components/ui/Menu';
import { Card, Button } from '@/components/ui/primitives';
import { RemoteAudio, RemoteVideo } from '@/components/voice/RemoteAudio';
import { VoiceFlags, VoiceQualityDot } from '@/components/voice/VoiceIndicators';
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
+import { VoiceSoundboard } from '@/components/voice/VoiceSoundboard';
+import { VoiceStatsButton } from '@/components/voice/VoiceStatsButton';
import { useVoiceEnabled } from '@/components/voice/useVoiceEnabled';
import { useGuildVoiceStates } from '@/components/voice/useGuildVoiceStates';
import {
+ useCanUseSoundboard,
useVoiceChannels,
useVoiceModeration,
useVoiceOccupants,
@@ -18,8 +22,9 @@ import {
import { VOICE_DRAG_MIME } from '@/components/voice/voiceDrag';
import { speakingRingClass } from '@/components/voice/voiceStyles';
import type { VoiceModerationPermissions } from '@/lib/identity';
+import { SCREEN_SHARE_PRESET_IDS, type ScreenSharePresetId } from '@/lib/livekit';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
-import { useVoiceStore } from '@/stores/voice';
+import { useVoiceStore, type VoicePublishQuality } from '@/stores/voice';
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
/** Плитка участника: видео или аватар, имя, флаги, качество связи. */
@@ -105,7 +110,11 @@ function VoiceTile({
{occupant.isLocal ? (
- {t('voice.you')}
+ <>
+ {/* Статистика своей публикации: разрешение, fps, кодек, канал (7.14). */}
+
+ {t('voice.you')}
+ >
) : (