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