feat(voice): голосовой интерфейс на LiveKit (Фаза 3, клиент)

- `livekit-client` подключён как ленивый чанк: SDK грузится только при входе в
  комнату, стартовый бандл не вырос;
- `src/lib/livekit.ts` — единственное место знания о SDK: подключение, дорожки
  микрофона/камеры/экрана, снимки участников (речь, флаги, качество связи),
  выбор устройств на лету, разбор exp токена;
- `src/stores/voiceConnection.ts` — сессия голоса: статусы
  idle/connecting/connected/reconnecting/error, продление токена за минуту до
  истечения с переподключением и повторным применением флагов, серверный мьют и
  глушение, громкость участников, перемещение, подсказка «говорите с выключенным
  микрофоном» не чаще раза в 5 с;
- UI: состав голосовой комнаты в сайдбаре (речь, флаги, качество), панель
  комнаты с плитками и модерацией, меню участника (личная громкость, мьют,
  перемещение), drag&drop между голосовыми комнатами, кнопки панели пользователя
  управляют реальной сессией, при выключенном голосе — понятная подсказка;
- 368 тестов Vitest (было 330), check/lint/test/build зелёные.
This commit is contained in:
2026-09-20 18:51:31 +03:00
parent 0db4c63023
commit 3ae03ec515
9 changed files with 133 additions and 102 deletions
+10 -3
View File
@@ -197,7 +197,7 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
};
/** Не удалось продлить сессию: выходим из комнаты и показываем ошибку. */
const failRefresh = async (): Promise<void> => {
const failRefresh = async (guildId: string, channelId: string): Promise<void> => {
clearTokenTimer();
await closeRoom();
const channel = joinedChannelId;
@@ -211,7 +211,10 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
}
set({
...defaults,
// Комнату оставляем в состоянии: так подсказка видна у канала и в панели.
status: 'error',
guildId,
channelId,
error: { code: VOICE_SESSION_EXPIRED_CODE },
});
};
@@ -270,7 +273,7 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
return;
} catch {
if (attempt === TOKEN_REFRESH_ATTEMPTS) {
await failRefresh();
await failRefresh(guildId, channelId);
return;
}
}
@@ -347,7 +350,7 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
useSessionStore.getState().applyVoiceState(result.state);
scheduleTokenRefresh(result.token, mine);
const handle = createVoiceRoom({
const handle = await createVoiceRoom({
onParticipants: (participants) => {
if (mine === generation) {
set({ participants });
@@ -370,6 +373,10 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
},
onSpeakingWhileMuted: warnSpeakingWhileMuted,
});
if (mine !== generation) {
handle.dispose();
return;
}
room = handle;
await handle.connect(result.url, result.token);
if (mine !== generation) {