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
+86 -80
View File
@@ -1,4 +1,4 @@
import { ConnectionQuality, Room, RoomEvent, Track, type Participant } from 'livekit-client';
import type { Participant, Room, Track } from 'livekit-client';
import type { VoiceDeviceKind } from '@/stores/voice';
@@ -10,6 +10,9 @@ import type { VoiceDeviceKind } from '@/stores/voice';
* - UI не зависит от версии SDK и умеет рисовать участников без него;
* - в тестах SDK подменяется моком `livekit-client`, а логика стора
* проверяется настоящая.
*
* Сам SDK подгружается динамически при подключении: иначе тяжёлый
* `livekit-client` попадал бы в стартовый бандл приложения.
*/
/** Качество связи участника (значения совпадают с `ConnectionQuality` из SDK). */
@@ -66,86 +69,89 @@ export interface VoiceRoomHandle {
/** Запасной TTL токена, если `exp` не удалось разобрать (AGENT.md §7.14). */
export const DEFAULT_TOKEN_TTL_MS = 10 * 60_000;
function qualityOf(participant: Participant): VoiceQuality {
switch (participant.connectionQuality) {
case ConnectionQuality.Excellent:
return 'excellent';
case ConnectionQuality.Good:
return 'good';
case ConnectionQuality.Poor:
return 'poor';
case ConnectionQuality.Lost:
return 'lost';
default:
return 'unknown';
}
}
function handleOf(track: Track | undefined, kind: 'audio' | 'video'): VoiceTrackHandle | undefined {
if (track === undefined) {
return undefined;
}
return {
kind,
attach: (element) => {
track.attach(element);
},
detach: (element) => {
track.detach(element);
},
};
}
/** Опубликована ли дорожка и не замьючена ли она. */
function isLive(participant: Participant, source: Track.Source): boolean {
const publication = participant.getTrackPublication(source);
return publication !== undefined && publication.track !== undefined && !publication.isMuted;
}
function snapshotOf(participant: Participant, isLocal: boolean): VoiceParticipant {
const microphone = participant.getTrackPublication(Track.Source.Microphone)?.track;
const camera = participant.getTrackPublication(Track.Source.Camera)?.track;
const screen = participant.getTrackPublication(Track.Source.ScreenShare)?.track;
const snapshot: VoiceParticipant = {
userId: participant.identity,
name:
participant.name === undefined || participant.name === ''
? participant.identity
: participant.name,
isLocal,
isSpeaking: participant.isSpeaking,
micEnabled: isLive(participant, Track.Source.Microphone),
cameraEnabled: isLive(participant, Track.Source.Camera),
screenEnabled: isLive(participant, Track.Source.ScreenShare),
quality: qualityOf(participant),
};
// Свой микрофон не слушаем: элемент нужен только для чужих дорожек.
const audio = isLocal ? undefined : handleOf(microphone, 'audio');
const video = handleOf(camera, 'video');
const screenShare = handleOf(screen, 'video');
if (audio !== undefined) {
snapshot.audio = audio;
}
if (video !== undefined) {
snapshot.video = video;
}
if (screenShare !== undefined) {
snapshot.screen = screenShare;
}
return snapshot;
}
function snapshotAll(room: Room): VoiceParticipant[] {
const participants = [snapshotOf(room.localParticipant, true)];
for (const remote of room.remoteParticipants.values()) {
participants.push(snapshotOf(remote, false));
}
return participants;
}
/** Создаёт комнату LiveKit и подписывается на её события. */
export function createVoiceRoom(callbacks: VoiceRoomCallbacks): VoiceRoomHandle {
// Пустой deviceId в предпочтениях означает «устройство по умолчанию».
export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<VoiceRoomHandle> {
// SDK весит больше самого приложения: грузим его только при подключении.
const { ConnectionQuality, Room, RoomEvent, Track } = await import('livekit-client');
const qualityOf = (participant: Participant): VoiceQuality => {
switch (participant.connectionQuality) {
case ConnectionQuality.Excellent:
return 'excellent';
case ConnectionQuality.Good:
return 'good';
case ConnectionQuality.Poor:
return 'poor';
case ConnectionQuality.Lost:
return 'lost';
default:
return 'unknown';
}
};
const handleOf = (
track: Track | undefined,
kind: 'audio' | 'video',
): VoiceTrackHandle | undefined => {
if (track === undefined) {
return undefined;
}
return {
kind,
attach: (element) => {
track.attach(element);
},
detach: (element) => {
track.detach(element);
},
};
};
/** Опубликована ли дорожка и не замьючена ли она. */
const isLive = (participant: Participant, source: Track.Source): boolean => {
const publication = participant.getTrackPublication(source);
return publication !== undefined && publication.track !== undefined && !publication.isMuted;
};
const snapshotOf = (participant: Participant, isLocal: boolean): VoiceParticipant => {
const microphone = participant.getTrackPublication(Track.Source.Microphone)?.track;
const camera = participant.getTrackPublication(Track.Source.Camera)?.track;
const screen = participant.getTrackPublication(Track.Source.ScreenShare)?.track;
const name = participant.name;
const snapshot: VoiceParticipant = {
userId: participant.identity,
name: name === undefined || name === '' ? participant.identity : name,
isLocal,
isSpeaking: participant.isSpeaking,
micEnabled: isLive(participant, Track.Source.Microphone),
cameraEnabled: isLive(participant, Track.Source.Camera),
screenEnabled: isLive(participant, Track.Source.ScreenShare),
quality: qualityOf(participant),
};
// Свой микрофон не слушаем: элемент нужен только для чужих дорожек.
const audio = isLocal ? undefined : handleOf(microphone, 'audio');
const video = handleOf(camera, 'video');
const screenShare = handleOf(screen, 'video');
if (audio !== undefined) {
snapshot.audio = audio;
}
if (video !== undefined) {
snapshot.video = video;
}
if (screenShare !== undefined) {
snapshot.screen = screenShare;
}
return snapshot;
};
const snapshotAll = (room: Room): VoiceParticipant[] => {
const participants = [snapshotOf(room.localParticipant, true)];
for (const remote of room.remoteParticipants.values()) {
participants.push(snapshotOf(remote, false));
}
return participants;
};
const room = new Room({ adaptiveStream: true, dynacast: true });
const refresh = (): void => {
callbacks.onParticipants(snapshotAll(room));