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:
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user