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:
2026-09-20 19:30:43 +03:00
parent 2538e07ec3
commit 619bd91df4
40 changed files with 4269 additions and 48 deletions
+90 -1
View File
@@ -15,6 +15,7 @@ import {
parseDmChannelEvent,
parseGuildEmojisUpdate,
parseGuildId,
parseGuildSoundsUpdate,
parseGuildUpdate,
parseMessageDelete,
parseMessagePayload,
@@ -24,14 +25,19 @@ import {
parseReadStateUpdate,
parseRelationshipEvent,
parseRoleEvent,
parseSoundboardPlay,
parseTypingStart,
parseUserUpdate,
} from '@/api/gatewayEvents';
import { guildSoundsQueryKey } from '@/api/sounds';
import { parseSystemMessage } from '@/lib/systemMessage';
import { useFriendsStore } from '@/stores/friends';
import { useMessagesStore } from '@/stores/messages';
import { useSessionStore } from '@/stores/session';
import { useSessionStore, type SessionGuild } from '@/stores/session';
import { playEventSound, useSoundboardStore } from '@/stores/soundboard';
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
import { parseVoiceStateEvent } from '@/api/voice';
import type { Message } from '@/api/messages';
/**
* Состояние соединения со шлюзом. Данные приходят в session-store,
@@ -207,6 +213,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
messages.applyMessageCreate(message);
// В личной беседе «последнее сообщение» — источник бейджа и порядка.
session.touchDmChannel(message.channel_id, message.id, message.created_at);
playMessageSound(message);
}
return;
}
@@ -297,6 +304,23 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
session.patchDmPresence(presence.user_id, presence.visible_status ?? presence.status);
return;
}
case 'GUILD_SOUNDS_UPDATE': {
// Набор звуков сервера изменился целиком: снапшот и REST-кэш (7.13).
const update = parseGuildSoundsUpdate(event.d);
if (update !== null) {
session.setGuildSounds(update.guild_id, update.sounds);
void invalidateSoundQueries(update.guild_id);
}
return;
}
case 'SOUNDBOARD_PLAY': {
// Событие приходит только участникам комнаты: играем звук локально.
const played = parseSoundboardPlay(event.d);
if (played !== null) {
useSoundboardStore.getState().applyGatewayPlay(played);
}
return;
}
case 'RELATIONSHIP_UPDATE': {
// Связь появилась или пропала — перечитываем список целиком.
const relationship = parseRelationshipEvent(event.d);
@@ -319,10 +343,30 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
if (voice === null) {
return;
}
const me = session.user?.id ?? null;
const myChannel = useVoiceConnectionStore.getState().channelId;
if (voice.kind === 'left') {
// До удаления записи узнаём, из какой комнаты ушёл участник.
const leftChannel =
(session.voiceStates[voice.guildId] ?? []).find((item) => item.user_id === voice.userId)
?.channel_id ?? null;
session.applyVoiceLeave(voice.guildId, voice.userId);
if (voice.userId !== me && leftChannel !== null && leftChannel === myChannel) {
playEventSound(voice.guildId, 'voice_leave');
}
} else {
// Участник мог быть в другой комнате — тогда это перемещение в нашу.
const previous = (session.voiceStates[voice.state.guild_id] ?? []).find(
(item) => item.user_id === voice.state.user_id,
);
session.applyVoiceState(voice.state);
if (
voice.state.user_id !== me &&
voice.state.channel_id === myChannel &&
previous?.channel_id !== myChannel
) {
playEventSound(voice.state.guild_id, 'voice_join');
}
}
// server_mute/server_deaf и перемещение себя применяем к своей сессии.
useVoiceConnectionStore.getState().syncServerState();
@@ -334,6 +378,45 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
}
}
/**
* Звук события UI-палитры для нового сообщения (AGENT.md 7.13): системные
* сообщения — это вход/выход участника, остальные — упоминание или обычное
* сообщение. Свои сообщения звуком не сопровождаются.
*/
function playMessageSound(message: Message): void {
const session = useSessionStore.getState();
const guildId = guildIdOfChannel(session.guilds, message.channel_id);
if (guildId === null) {
return;
}
if (message.type === 'system') {
const event = parseSystemMessage(message.content);
if (event?.kind === 'member_join') {
playEventSound(guildId, 'member_join');
return;
}
if (event?.kind === 'member_leave' || event?.kind === 'member_kick') {
playEventSound(guildId, 'member_leave');
}
return;
}
const me = session.user?.id ?? null;
if (me !== null && message.author_id === me) {
return;
}
playEventSound(guildId, me !== null && message.mentions.includes(me) ? 'mention' : 'message');
}
/** Сервер, которому принадлежит комната: в личной беседе его нет. */
function guildIdOfChannel(guilds: SessionGuild[], channelId: string): string | null {
for (const guild of guilds) {
if (guild.channels.some((channel) => channel.id === channelId)) {
return guild.id;
}
}
return null;
}
/** Сервер появился (создан или найден): забираем сводку из REST. */
async function addGuildFromRest(guildId: string): Promise<void> {
const [{ getQueryClient }, users] = await Promise.all([
@@ -392,6 +475,12 @@ async function invalidateEmojiQueries(guildId: string): Promise<void> {
await getQueryClient().invalidateQueries({ queryKey: guildEmojisQueryKey(guildId) });
}
/** Инвалидация списка звуков сервера после `GUILD_SOUNDS_UPDATE` (7.13). */
async function invalidateSoundQueries(guildId: string): Promise<void> {
const { getQueryClient } = await import('@/lib/queryClient');
await getQueryClient().invalidateQueries({ queryKey: guildSoundsQueryKey(guildId) });
}
/** Держит URL в согласии с выбранным сервером/комнатой после удалений. */
function syncUrlWithSelection(): void {
if (typeof window === 'undefined') {