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:
@@ -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