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
+8 -4
View File
@@ -366,9 +366,10 @@ describe('голосовая сессия: синхронизация с сер
s: 1,
d: makeVoiceState({ user_id: 'user-1', channel_id: 'c-other' }),
});
await flush(24);
await vi.waitFor(() => {
expect(useVoiceConnectionStore.getState().channelId).toBe('c-other');
});
expect(useVoiceConnectionStore.getState().channelId).toBe('c-other');
// Старая комната закрыта, для новой создана своя сессия SDK.
expect(first.disconnectCalls).toBe(1);
expect(FakeRoom.instances).toHaveLength(2);
@@ -382,9 +383,10 @@ describe('голосовая сессия: синхронизация с сер
const fetchMock = await joinRoom();
FakeRoom.latest().emit(RoomEvent.Disconnected);
await flush(24);
await vi.waitFor(() => {
expect(useVoiceConnectionStore.getState().status).toBe('idle');
});
expect(useVoiceConnectionStore.getState().status).toBe('idle');
expect(useVoiceConnectionStore.getState().participants).toEqual([]);
expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined();
});
@@ -456,6 +458,8 @@ describe('голосовая сессия: продление токена', ()
expect(state.status).toBe('error');
expect(state.error).toEqual({ code: VOICE_SESSION_EXPIRED_CODE });
expect(state.participants).toEqual([]);
// Комната остаётся в состоянии ошибки: подсказка видна в интерфейсе.
expect(state.channelId).toBe('c-voice');
expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined();
});
});