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:
+86
-80
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user