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') {
+76 -2
View File
@@ -3,6 +3,7 @@ import { create } from 'zustand';
import type { GuildEmoji } from '@/api/emojis';
import { fetchDirectChannels } from '@/api/friends';
import type { GatewayGuild, GatewaySnapshot, GatewayUser } from '@/api/gateway';
import type { GuildSound, SoundKind } from '@/api/sounds';
import type { VoiceState } from '@/api/voice';
import type { Channel, GuildSummary, User, UserStatus } from '@/api/types';
@@ -17,6 +18,8 @@ export interface SessionGuild extends GuildSummary {
channels: Channel[];
/** Кастомные эмодзи сервера: READY и `GUILD_EMOJIS_UPDATE` (AGENT.md 7.12). */
emojis: GuildEmoji[];
/** Саундборд и звуковая палитра: READY и `GUILD_SOUNDS_UPDATE` (7.13). */
sounds: GuildSound[];
my_nickname?: string;
}
@@ -67,6 +70,15 @@ interface SessionState {
upsertGuildEmoji: (guildId: string, emoji: GuildEmoji) => void;
/** Эмодзи удалён: убираем его из снапшота (реакции остаются как есть). */
removeGuildEmoji: (guildId: string, emojiId: string) => void;
/**
* Набор звуков сервера целиком: READY и `GUILD_SOUNDS_UPDATE` шлют его
* одним и тем же списком (AGENT.md 7.13).
*/
setGuildSounds: (guildId: string, sounds: GuildSound[]) => void;
/** Звук загружен или переименован: заменяем запись по id. */
upsertGuildSound: (guildId: string, sound: GuildSound) => void;
/** Звук удалён: убираем его из снапшота. */
removeGuildSound: (guildId: string, soundId: string) => void;
/** Личные беседы: список целиком (READY или `GET /users/@me/channels`). */
setDmChannels: (channels: Channel[]) => void;
upsertDmChannel: (channel: Channel) => void;
@@ -120,6 +132,7 @@ function toSessionGuild(guild: GatewayGuild): SessionGuild {
my_permissions: guild.my_permissions ?? [],
channels: guild.channels,
emojis: guild.emojis,
sounds: guild.sounds,
};
if (guild.icon_file_id !== undefined) {
summary.icon_file_id = guild.icon_file_id;
@@ -211,19 +224,20 @@ export const useSessionStore = create<SessionState>((set, get) => ({
const guilds = get().guilds;
const index = guilds.findIndex((item) => item.id === guild.id);
if (index === -1) {
set({ guilds: [...guilds, { ...guild, channels: [], emojis: [] }] });
set({ guilds: [...guilds, { ...guild, channels: [], emojis: [], sounds: [] }] });
return;
}
const existing = guilds[index];
if (existing === undefined) {
return;
}
// Сводка из REST не знает ни комнат, ни эмодзи — накопленное не теряем.
// Сводка из REST не знает ни комнат, ни эмодзи, ни звуков — накопленное не теряем.
const merged: SessionGuild = {
...existing,
...guild,
channels: existing.channels,
emojis: existing.emojis,
sounds: existing.sounds,
};
const next = [...guilds];
next[index] = merged;
@@ -352,6 +366,39 @@ export const useSessionStore = create<SessionState>((set, get) => ({
});
},
setGuildSounds: (guildId, sounds) => {
set({
guilds: get().guilds.map((guild) => (guild.id === guildId ? { ...guild, sounds } : guild)),
});
},
upsertGuildSound: (guildId, sound) => {
set({
guilds: get().guilds.map((guild) => {
if (guild.id !== guildId) {
return guild;
}
const index = guild.sounds.findIndex((item) => item.id === sound.id);
if (index === -1) {
return { ...guild, sounds: [...guild.sounds, sound] };
}
const sounds = [...guild.sounds];
sounds[index] = sound;
return { ...guild, sounds };
}),
});
},
removeGuildSound: (guildId, soundId) => {
set({
guilds: get().guilds.map((guild) =>
guild.id === guildId
? { ...guild, sounds: guild.sounds.filter((sound) => sound.id !== soundId) }
: guild,
),
});
},
setDmChannels: (channels) => {
set({ dmChannels: channels });
},
@@ -479,3 +526,30 @@ export function myVoiceState(
}
return (voiceStates[guildId] ?? []).find((state) => state.user_id === userId) ?? null;
}
/** Пустой список — стабильная ссылка: селекторы не должны перерисовывать зря. */
const NO_SOUNDS: GuildSound[] = [];
/**
* Звуки сервера из снапшота (READY и `GUILD_SOUNDS_UPDATE`). Без `kind`
* возвращаются оба набора: саундборд и звуковая палитра.
*/
export function guildSoundsOf(
guilds: readonly SessionGuild[],
guildId: string | null,
kind?: SoundKind,
): GuildSound[] {
if (guildId === null) {
return NO_SOUNDS;
}
const guild = guilds.find((item) => item.id === guildId);
if (guild === undefined) {
return NO_SOUNDS;
}
return kind === undefined ? guild.sounds : guild.sounds.filter((sound) => sound.kind === kind);
}
/** Готовый селектор для `useSessionStore`: звуки сервера одного вида. */
export function selectGuildSounds(guildId: string | null, kind?: SoundKind) {
return (state: SessionState): GuildSound[] => guildSoundsOf(state.guilds, guildId, kind);
}
+150
View File
@@ -0,0 +1,150 @@
import { create } from 'zustand';
import type { GuildSound, SoundEvent } from '@/api/sounds';
import { playSoundFileInBackground, stopAllSounds } from '@/lib/soundboard';
import { guildSoundsOf, useSessionStore } from '@/stores/session';
import { useVoiceStore } from '@/stores/voice';
import type { SoundboardPlayEvent } from '@/api/gatewayEvents';
/**
* Саундборд и звуковая палитра (AGENT.md 7.13).
*
* Сервер не транслирует звук: он присылает событие `SOUNDBOARD_PLAY`, а каждый
* клиент скачивает файл и играет его локально. Здесь живёт то, что нельзя
* размазать по компонентам: подпись «кто включил звук» и защита от двойного
* проигрывания — своё нажатие звучит сразу (оптимистично), а пришедшее следом
* событие шлюза уже не играет второй раз.
*/
/** Окно, в котором событие шлюза считается эхом своего нажатия. */
export const SOUNDBOARD_DEDUP_WINDOW_MS = 3_000;
/** Сколько держится подпись «{имя} включил звук {name}». */
export const SOUNDBOARD_NOTICE_MS = 3_000;
/** Сколько последних проигрываний помним для сопоставления. */
const RECENT_LIMIT = 20;
export interface SoundboardNotice {
guildId: string;
userId: string;
soundName: string;
}
interface RecentPlay {
soundId: string;
userId: string;
at: number;
}
interface SoundboardState {
/** Подпись о последнем сыгранном звуке (гаснет сама через 3 секунды). */
notice: SoundboardNotice | null;
/** Недавние проигрывания: сопоставление оптимистичного звука с событием. */
recent: RecentPlay[];
/** Своё нажатие «Проиграть»: звук играет сразу, не дожидаясь шлюза. */
playMySound: (guildId: string, sound: GuildSound, userId: string) => void;
/** Событие `SOUNDBOARD_PLAY`: играем чужой звук (свой уже сыграл). */
applyGatewayPlay: (event: SoundboardPlayEvent) => void;
clearNotice: () => void;
reset: () => void;
}
let noticeTimer: ReturnType<typeof setTimeout> | null = null;
function clearNoticeTimer(): void {
if (noticeTimer !== null) {
clearTimeout(noticeTimer);
noticeTimer = null;
}
}
export const useSoundboardStore = create<SoundboardState>((set, get) => {
/** Общая часть: звук играет, подпись появляется, проигрывание запоминается. */
const play = (guildId: string, userId: string, soundId: string, fileId: string, name: string) => {
playSoundFileInBackground(fileId);
const recent = [
{ soundId, userId, at: Date.now() },
...get().recent.filter((item) => !(item.soundId === soundId && item.userId === userId)),
].slice(0, RECENT_LIMIT);
clearNoticeTimer();
set({ recent, notice: { guildId, userId, soundName: name } });
noticeTimer = setTimeout(() => {
noticeTimer = null;
set({ notice: null });
}, SOUNDBOARD_NOTICE_MS);
};
return {
notice: null,
recent: [],
playMySound: (guildId, sound, userId) => {
if (!useVoiceStore.getState().soundboardEnabled) {
return;
}
play(guildId, userId, sound.id, sound.file_id, sound.name);
},
applyGatewayPlay: (event) => {
if (!useVoiceStore.getState().soundboardEnabled) {
return;
}
const now = Date.now();
const mine = get().recent.some(
(item) =>
item.soundId === event.sound_id &&
item.userId === event.user_id &&
now - item.at <= SOUNDBOARD_DEDUP_WINDOW_MS,
);
if (mine) {
// Свой звук уже сыграл оптимистично — второй раз не играем.
return;
}
const known = guildSoundsOf(useSessionStore.getState().guilds, event.guild_id).find(
(sound) => sound.id === event.sound_id,
);
const name = event.name !== '' ? event.name : (known?.name ?? '');
play(event.guild_id, event.user_id, event.sound_id, event.file_id, name);
},
clearNotice: () => {
clearNoticeTimer();
set({ notice: null });
},
reset: () => {
clearNoticeTimer();
stopAllSounds();
set({ notice: null, recent: [] });
},
};
});
/** Звук сервера, привязанный к событию UI-палитры (или `null`). */
export function findEventSound(guildId: string | null, event: SoundEvent): GuildSound | null {
if (guildId === null) {
return null;
}
const sounds = guildSoundsOf(useSessionStore.getState().guilds, guildId, 'ui');
return sounds.find((sound) => sound.event === event) ?? null;
}
/**
* Проигрывает звук события сервера, если он задан и звуки событий включены.
* Вызывается из диспетчера шлюза и из голосовой сессии: молчание — норма,
* когда звук не настроен или пользователь его выключил.
*/
export function playEventSound(guildId: string | null, event: SoundEvent): void {
if (guildId === null || !useVoiceStore.getState().eventSoundsEnabled) {
return;
}
const sound = findEventSound(guildId, event);
if (sound === null) {
return;
}
playSoundFileInBackground(sound.file_id);
}
/** Полная остановка звуков: выход из комнаты, сброс сессии (тесты). */
export function stopSoundboardPlayback(): void {
stopAllSounds();
}
+92 -2
View File
@@ -1,8 +1,11 @@
import { create } from 'zustand';
import type { ScreenSharePresetId } from '@/lib/livekit';
/**
* Локальные настройки «голосовой» панели: состояния кнопок микрофона, камеры и
* наушников, выбранные устройства и громкости.
* наушников, выбранные устройства, громкости, режим активации микрофона
* (push-to-talk) и качество публикации (AGENT.md 7.13/7.14).
*
* Это только предпочтения клиента: они хранятся в localStorage и не уходят на
* сервер. Реальная медиа-сессия живёт в `stores/voiceConnection.ts` и читает
@@ -12,7 +15,30 @@ import { create } from 'zustand';
/** Виды устройств, которые отдаёт `navigator.mediaDevices.enumerateDevices()`. */
export type VoiceDeviceKind = 'audioinput' | 'videoinput' | 'audiooutput';
interface VoicePreferences {
/** Как включается микрофон: голосовая активация или удержание клавиши. */
export type MicActivationMode = 'voice' | 'push_to_talk';
/** Качество публикации видео (`VideoQuality` из LiveKit). */
export type VoicePublishQuality = 'low' | 'medium' | 'high';
/**
* Клавиши push-to-talk. `code` не зависит от раскладки — так клавиша работает
* одинаково и в русской, и в английской. Для Alt/Ctrl берём левые варианты,
* но правая клавиша тоже считается нажатием (см. `matchesPushToTalk`).
*/
export const PUSH_TO_TALK_KEYS = [
'Space',
'KeyV',
'KeyB',
'KeyN',
'AltLeft',
'ControlLeft',
] as const;
export type PushToTalkKey = (typeof PUSH_TO_TALK_KEYS)[number];
/** Предпочтения клиента: то, что уходит в localStorage. */
export interface VoicePreferences {
micEnabled: boolean;
cameraEnabled: boolean;
headphonesEnabled: boolean;
@@ -25,6 +51,18 @@ interface VoicePreferences {
outputVolume: number;
/** Личная громкость участников голосовой комнаты, 0–100 (AGENT.md 7.14). */
participantVolumes: Record<string, number>;
/** Режим активации микрофона (AGENT.md 7.14: push-to-talk). */
micActivation: MicActivationMode;
/** Клавиша push-to-talk. */
pttKey: PushToTalkKey;
/** Звуки событий сервера (UI-палитра) включены (AGENT.md 7.13). */
eventSoundsEnabled: boolean;
/** Саундборд включён. */
soundboardEnabled: boolean;
/** Качество своей публикации видео. */
videoQuality: VoicePublishQuality;
/** Пресет разрешения и fps для демонстрации экрана. */
screenSharePreset: ScreenSharePresetId;
}
interface VoiceState extends VoicePreferences {
@@ -39,6 +77,12 @@ interface VoiceState extends VoicePreferences {
setOutputVolume: (value: number) => void;
/** Громкость конкретного участника: хранится только на этом устройстве. */
setParticipantVolume: (userId: string, value: number) => void;
setMicActivation: (mode: MicActivationMode) => void;
setPttKey: (key: PushToTalkKey) => void;
setEventSoundsEnabled: (enabled: boolean) => void;
setSoundboardEnabled: (enabled: boolean) => void;
setVideoQuality: (quality: VoicePublishQuality) => void;
setScreenSharePreset: (preset: ScreenSharePresetId) => void;
/** Вернуть исходные предпочтения (используется в тестах). */
reset: () => void;
}
@@ -55,6 +99,12 @@ const defaults: VoicePreferences = {
micVolume: 100,
outputVolume: 100,
participantVolumes: {},
micActivation: 'voice',
pttKey: 'Space',
eventSoundsEnabled: true,
soundboardEnabled: true,
videoQuality: 'high',
screenSharePreset: '1080p30',
};
function clampVolume(value: number): number {
@@ -90,6 +140,17 @@ function asParticipantVolumes(value: unknown): Record<string, number> {
return volumes;
}
/** Значение из фиксированного списка: чужие строки отбрасываем. */
function asChoice<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
return typeof value === 'string' && (allowed as readonly string[]).includes(value)
? (value as T)
: fallback;
}
const MIC_ACTIVATION_MODES: readonly MicActivationMode[] = ['voice', 'push_to_talk'];
const PUBLISH_QUALITIES: readonly VoicePublishQuality[] = ['low', 'medium', 'high'];
const SCREEN_PRESETS: readonly ScreenSharePresetId[] = ['1080p60', '1080p30', '720p30', '480p30'];
/** Начальные значения: то, что уже лежит в localStorage, поверх умолчаний. */
function initialPreferences(): VoicePreferences {
if (typeof window === 'undefined') {
@@ -119,6 +180,16 @@ function initialPreferences(): VoicePreferences {
micVolume: asVolume(stored['micVolume'], defaults.micVolume),
outputVolume: asVolume(stored['outputVolume'], defaults.outputVolume),
participantVolumes: asParticipantVolumes(stored['participantVolumes']),
micActivation: asChoice(stored['micActivation'], MIC_ACTIVATION_MODES, defaults.micActivation),
pttKey: asChoice(stored['pttKey'], PUSH_TO_TALK_KEYS, defaults.pttKey),
eventSoundsEnabled: asBoolean(stored['eventSoundsEnabled'], defaults.eventSoundsEnabled),
soundboardEnabled: asBoolean(stored['soundboardEnabled'], defaults.soundboardEnabled),
videoQuality: asChoice(stored['videoQuality'], PUBLISH_QUALITIES, defaults.videoQuality),
screenSharePreset: asChoice(
stored['screenSharePreset'],
SCREEN_PRESETS,
defaults.screenSharePreset,
),
};
}
@@ -138,6 +209,12 @@ function persist(state: VoicePreferences): void {
micVolume: state.micVolume,
outputVolume: state.outputVolume,
participantVolumes: state.participantVolumes,
micActivation: state.micActivation,
pttKey: state.pttKey,
eventSoundsEnabled: state.eventSoundsEnabled,
soundboardEnabled: state.soundboardEnabled,
videoQuality: state.videoQuality,
screenSharePreset: state.screenSharePreset,
} satisfies VoicePreferences),
);
}
@@ -183,6 +260,13 @@ export const useVoiceStore = create<VoiceState>((set, get) => {
patch({ participantVolumes: { ...get().participantVolumes, [userId]: clampVolume(value) } });
},
setMicActivation: (mode) => patch({ micActivation: mode }),
setPttKey: (key) => patch({ pttKey: key }),
setEventSoundsEnabled: (enabled) => patch({ eventSoundsEnabled: enabled }),
setSoundboardEnabled: (enabled) => patch({ soundboardEnabled: enabled }),
setVideoQuality: (quality) => patch({ videoQuality: quality }),
setScreenSharePreset: (preset) => patch({ screenSharePreset: preset }),
reset: () => {
set({ ...defaults });
if (typeof window !== 'undefined') {
@@ -204,5 +288,11 @@ function preferencesOf(state: VoiceState): VoicePreferences {
micVolume: state.micVolume,
outputVolume: state.outputVolume,
participantVolumes: state.participantVolumes,
micActivation: state.micActivation,
pttKey: state.pttKey,
eventSoundsEnabled: state.eventSoundsEnabled,
soundboardEnabled: state.soundboardEnabled,
videoQuality: state.videoQuality,
screenSharePreset: state.screenSharePreset,
};
}
+85 -5
View File
@@ -14,9 +14,11 @@ import {
tokenExpiresAt,
type VoiceParticipant,
type VoiceRoomHandle,
type VoiceStats,
} from '@/lib/livekit';
import { myVoiceState, useSessionStore } from '@/stores/session';
import { useVoiceStore, type VoiceDeviceKind } from '@/stores/voice';
import { playEventSound, stopSoundboardPlayback } from '@/stores/soundboard';
import { useVoiceStore, type VoiceDeviceKind, type VoicePublishQuality } from '@/stores/voice';
/**
* Голосовая сессия LiveKit: подключение к комнате, публикация своих дорожек,
@@ -69,9 +71,18 @@ export interface VoiceConnectionState {
join: (guildId: string, channelId: string) => Promise<void>;
leave: () => Promise<void>;
setMicEnabled: (enabled: boolean) => Promise<void>;
/**
* Push-to-talk: включает микрофон на время удержания клавиши, не меняя
* сохранённое предпочтение (AGENT.md 7.14). Серверный мьют приоритетнее.
*/
setPushToTalkActive: (active: boolean) => void;
setDeafened: (enabled: boolean) => Promise<void>;
setCameraEnabled: (enabled: boolean) => Promise<void>;
setScreenShareEnabled: (enabled: boolean) => Promise<void>;
/** Качество публикации своего видео: `low` | `medium` | `high`. */
setVideoQuality: (quality: VoicePublishQuality) => Promise<void>;
/** Статистика своей публикации для попапа «Статистика» (раз в секунду). */
fetchStats: () => Promise<VoiceStats | null>;
/** Смена устройства на лету: применяется к активной дорожке (AGENT.md 7.14). */
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise<void>;
/** Личная громкость участника, 0–100: хранится в предпочтениях клиента. */
@@ -162,6 +173,15 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
}
};
/** Звук события сервера играет только из активной комнаты (AGENT.md 7.13). */
const playSound = (event: Parameters<typeof playEventSound>[1]): void => {
const { guildId, status } = get();
if (guildId === null || status !== 'connected') {
return;
}
playEventSound(guildId, event);
};
/** Отключение от SDK-комнаты без запросов: используется и в leave, и в reset. */
const closeRoom = async (): Promise<void> => {
const handle = room;
@@ -184,6 +204,8 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
clearTokenTimer();
clearWarningTimer();
await closeRoom();
// Звуки саундборда не должны доигрывать после выхода из комнаты.
stopSoundboardPlayback();
const channel = joinedChannelId;
joinedChannelId = null;
if (channel !== null) {
@@ -323,6 +345,8 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
await get().leave();
}
const preferences = useVoiceStore.getState();
// В режиме push-to-talk микрофон до нажатия клавиши выключен.
const pushToTalk = preferences.micActivation === 'push_to_talk';
const mine = (generation += 1);
set({
status: 'connecting',
@@ -330,7 +354,7 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
channelId,
error: null,
participants: [],
micEnabled: preferences.micEnabled,
micEnabled: preferences.micEnabled && !pushToTalk,
deafened: !preferences.headphonesEnabled,
cameraEnabled: preferences.cameraEnabled,
screenEnabled: false,
@@ -386,8 +410,10 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
}
return;
}
await handle.setMicrophoneEnabled(preferences.micEnabled);
await handle.setMicrophoneEnabled(preferences.micEnabled && !pushToTalk);
await handle.setCameraEnabled(preferences.cameraEnabled);
// Качество публикации — из предпочтений клиента.
await handle.setVideoQuality(preferences.videoQuality);
set({ status: 'connected', participants: handle.participants() });
get().syncServerState();
} catch (error) {
@@ -418,6 +444,7 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
const guildId = get().guildId;
const me = useSessionStore.getState().user?.id ?? null;
await closeRoom();
stopSoundboardPlayback();
const channel = joinedChannelId;
joinedChannelId = null;
if (guildId !== null && me !== null) {
@@ -440,9 +467,27 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
}
useVoiceStore.getState().setMicEnabled(enabled);
await applyMic(enabled);
playSound(enabled ? 'mic_on' : 'mic_off');
await patchSelf({ self_mute: !enabled });
},
setPushToTalkActive: (active) => {
const { status, serverMuted, micEnabled } = get();
if (status !== 'connected' && status !== 'reconnecting') {
return;
}
// Серверный мьют приоритетнее: модератор выключил микрофон — молчим.
if (active && serverMuted) {
return;
}
if (micEnabled === active) {
return;
}
// Звуки палитры на нажатие клавиши не играем: это была бы пулемётная очередь.
void applyMic(active);
void patchSelf({ self_mute: !active });
},
setDeafened: async (enabled) => {
useVoiceStore.getState().setHeadphonesEnabled(!enabled);
set({ deafened: enabled });
@@ -461,6 +506,7 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
set({ error });
}
}
playSound(enabled ? 'camera_on' : 'camera_off');
await patchSelf({ camera: enabled });
},
@@ -469,15 +515,43 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
const handle = room;
if (handle !== null) {
try {
await handle.setScreenShareEnabled(enabled);
// Разрешение и fps берём из выбранного пресета (AGENT.md 7.14).
await handle.setScreenShareEnabled(enabled, useVoiceStore.getState().screenSharePreset);
set({ participants: handle.participants() });
} catch (error) {
set({ error });
}
}
playSound(enabled ? 'screen_on' : 'screen_off');
await patchSelf({ screen: enabled });
},
setVideoQuality: async (quality) => {
useVoiceStore.getState().setVideoQuality(quality);
const handle = room;
if (handle === null) {
return;
}
try {
await handle.setVideoQuality(quality);
} catch (error) {
set({ error });
}
},
fetchStats: async () => {
const handle = room;
if (handle === null) {
return null;
}
try {
return await handle.fetchStats();
} catch {
// Отчёт WebRTC может быть недоступен — попап покажет прочерки.
return null;
}
},
setVolume: (userId, volume) => {
useVoiceStore.getState().setParticipantVolume(userId, volume);
},
@@ -535,8 +609,13 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
}
const serverMuted = state.server_mute;
const serverDeafened = state.server_deaf;
if (serverMuted !== get().serverMuted || serverDeafened !== get().serverDeafened) {
const wasServerMuted = get().serverMuted;
if (serverMuted !== wasServerMuted || serverDeafened !== get().serverDeafened) {
set({ serverMuted, serverDeafened });
// Модератор только что выключил нам микрофон — звук события «mute».
if (serverMuted && !wasServerMuted) {
playSound('mute');
}
}
// Модератор выключил микрофон — гасим публикацию, предпочтение не трогаем.
if (serverMuted && micEnabled) {
@@ -560,6 +639,7 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
generation += 1;
clearTokenTimer();
clearWarningTimer();
stopSoundboardPlayback();
const handle = room;
room = null;
joinedChannelId = null;