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
@@ -61,6 +61,11 @@ export function VoiceOccupantRow({
event.stopPropagation(); event.stopPropagation();
setOpen((value) => !value); setOpen((value) => !value);
}} }}
onContextMenu={(event) => {
// ПКМ — привычный жест для меню участника (AGENT.md 7.14).
event.preventDefault();
setOpen(true);
}}
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-[var(--radius-sm)] px-2 py-0.5 text-left text-xs text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg" className="flex min-w-0 flex-1 items-center gap-1.5 rounded-[var(--radius-sm)] px-2 py-0.5 text-left text-xs text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg"
> >
<span className={`shrink-0 rounded-full ${speakingRingClass(occupant.isSpeaking)}`}> <span className={`shrink-0 rounded-full ${speakingRingClass(occupant.isSpeaking)}`}>
+15 -1
View File
@@ -45,6 +45,8 @@ function VoiceTile({
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const anchorRef = useRef<HTMLButtonElement | null>(null); const anchorRef = useRef<HTMLButtonElement | null>(null);
const volume = useVoiceStore((state) => state.participantVolumes[occupant.userId] ?? 100); const volume = useVoiceStore((state) => state.participantVolumes[occupant.userId] ?? 100);
// Общая громкость звука умножается на личную громкость участника.
const outputVolume = useVoiceStore((state) => state.outputVolume);
const deafened = useVoiceConnectionStore((state) => state.deafened); const deafened = useVoiceConnectionStore((state) => state.deafened);
const media = occupant.participant; const media = occupant.participant;
const video = media?.screen ?? media?.video; const video = media?.screen ?? media?.video;
@@ -63,6 +65,14 @@ function VoiceTile({
data-speaking={occupant.isSpeaking} data-speaking={occupant.isSpeaking}
draggable={permissions.move && !occupant.isLocal} draggable={permissions.move && !occupant.isLocal}
onDragStart={onDragStart} onDragStart={onDragStart}
onContextMenu={(event) => {
// ПКМ по плитке открывает меню участника (AGENT.md 7.14).
if (occupant.isLocal) {
return;
}
event.preventDefault();
setMenuOpen(true);
}}
className={`flex flex-col gap-2 rounded-[var(--radius-lg)] border bg-surface-1 p-3 transition-colors duration-[var(--duration-fast)] ${ className={`flex flex-col gap-2 rounded-[var(--radius-lg)] border bg-surface-1 p-3 transition-colors duration-[var(--duration-fast)] ${
occupant.isSpeaking ? 'border-success/70' : 'border-border/60' occupant.isSpeaking ? 'border-success/70' : 'border-border/60'
}`} }`}
@@ -81,7 +91,11 @@ function VoiceTile({
<RemoteVideo track={video} muted={occupant.isLocal} /> <RemoteVideo track={video} muted={occupant.isLocal} />
)} )}
{media?.audio === undefined ? null : ( {media?.audio === undefined ? null : (
<RemoteAudio track={media.audio} volume={volume} muted={deafened} /> <RemoteAudio
track={media.audio}
volume={(volume * outputVolume) / 100}
muted={deafened}
/>
)} )}
</div> </div>
+2 -6
View File
@@ -218,10 +218,6 @@
"title": "Chat arrives in Phase 2", "title": "Chat arrives in Phase 2",
"body": "The channel “{{name}}” is ready. Messages and voice arrive in the next phase." "body": "The channel “{{name}}” is ready. Messages and voice arrive in the next phase."
}, },
"voicePlaceholder": {
"title": "Voice channel",
"body": "Connecting to voice arrives in the next phases."
},
"noSelection": { "noSelection": {
"title": "Pick a channel", "title": "Pick a channel",
"body": "The server and channel lists are on the left. Pick a channel to open it." "body": "The server and channel lists are on the left. Pick a channel to open it."
@@ -775,13 +771,13 @@
}, },
"voice": { "voice": {
"title": "Audio & video", "title": "Audio & video",
"description": "Input and output devices and volume.", "description": "Input and output devices and volume. Changes apply to the current voice room immediately.",
"input": "Microphone", "input": "Microphone",
"camera": "Camera", "camera": "Camera",
"output": "Sound", "output": "Sound",
"micVolume": "Microphone volume", "micVolume": "Microphone volume",
"outputVolume": "Sound volume", "outputVolume": "Sound volume",
"stub": "Voice channels arrive in the next phase — for now these settings are only stored." "stub": "Changes apply immediately and are stored on this device."
}, },
"language": { "language": {
"title": "Language & region", "title": "Language & region",
+2 -6
View File
@@ -218,10 +218,6 @@
"title": "Чат появится в Фазе 2", "title": "Чат появится в Фазе 2",
"body": "Комната «{{name}}» уже доступна. Отправка сообщений и голосовые каналы появятся в следующей фазе." "body": "Комната «{{name}}» уже доступна. Отправка сообщений и голосовые каналы появятся в следующей фазе."
}, },
"voicePlaceholder": {
"title": "Голосовая комната",
"body": "Подключение к голосу появится в следующих фазах."
},
"noSelection": { "noSelection": {
"title": "Выберите комнату", "title": "Выберите комнату",
"body": "Слева — список серверов и комнат. Выберите комнату, чтобы открыть её." "body": "Слева — список серверов и комнат. Выберите комнату, чтобы открыть её."
@@ -775,13 +771,13 @@
}, },
"voice": { "voice": {
"title": "Аудио-видео", "title": "Аудио-видео",
"description": "Устройства ввода и вывода и громкость.", "description": "Устройства ввода и вывода и громкость. Изменения применяются к текущей голосовой комнате сразу.",
"input": "Микрофон", "input": "Микрофон",
"camera": "Камера", "camera": "Камера",
"output": "Звук", "output": "Звук",
"micVolume": "Громкость микрофона", "micVolume": "Громкость микрофона",
"outputVolume": "Громкость звука", "outputVolume": "Громкость звука",
"stub": "Голосовые комнаты появятся в следующей фазе — пока настройки только сохраняются." "stub": "Изменения применяются сразу и сохраняются на этом устройстве."
}, },
"language": { "language": {
"title": "Язык и регион", "title": "Язык и регион",
+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'; import type { VoiceDeviceKind } from '@/stores/voice';
@@ -10,6 +10,9 @@ import type { VoiceDeviceKind } from '@/stores/voice';
* - UI не зависит от версии SDK и умеет рисовать участников без него; * - UI не зависит от версии SDK и умеет рисовать участников без него;
* - в тестах SDK подменяется моком `livekit-client`, а логика стора * - в тестах SDK подменяется моком `livekit-client`, а логика стора
* проверяется настоящая. * проверяется настоящая.
*
* Сам SDK подгружается динамически при подключении: иначе тяжёлый
* `livekit-client` попадал бы в стартовый бандл приложения.
*/ */
/** Качество связи участника (значения совпадают с `ConnectionQuality` из SDK). */ /** Качество связи участника (значения совпадают с `ConnectionQuality` из SDK). */
@@ -66,86 +69,89 @@ export interface VoiceRoomHandle {
/** Запасной TTL токена, если `exp` не удалось разобрать (AGENT.md §7.14). */ /** Запасной TTL токена, если `exp` не удалось разобрать (AGENT.md §7.14). */
export const DEFAULT_TOKEN_TTL_MS = 10 * 60_000; 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 и подписывается на её события. */ /** Создаёт комнату LiveKit и подписывается на её события. */
export function createVoiceRoom(callbacks: VoiceRoomCallbacks): VoiceRoomHandle { export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<VoiceRoomHandle> {
// Пустой deviceId в предпочтениях означает «устройство по умолчанию». // 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 room = new Room({ adaptiveStream: true, dynacast: true });
const refresh = (): void => { const refresh = (): void => {
callbacks.onParticipants(snapshotAll(room)); callbacks.onParticipants(snapshotAll(room));
@@ -8,8 +8,8 @@ import { SettingsSection } from '@/pages/settings/SettingsSection';
/** /**
* «Аудио-видео»: выбор устройств ввода/вывода и громкости. * «Аудио-видео»: выбор устройств ввода/вывода и громкости.
* *
* Значения пока только сохраняются локально (zustand + localStorage): * Значения хранятся локально (zustand + localStorage) и применяются на лету:
* голосовые комнаты появятся в Фазе 3, тогда их начнёт использовать RTC. * выбранное устройство сразу переключается в активной голосовой сессии.
*/ */
export function VoiceSettingsSection() { export function VoiceSettingsSection() {
const { t } = useTranslation(); const { t } = useTranslation();
+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(); clearTokenTimer();
await closeRoom(); await closeRoom();
const channel = joinedChannelId; const channel = joinedChannelId;
@@ -211,7 +211,10 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
} }
set({ set({
...defaults, ...defaults,
// Комнату оставляем в состоянии: так подсказка видна у канала и в панели.
status: 'error', status: 'error',
guildId,
channelId,
error: { code: VOICE_SESSION_EXPIRED_CODE }, error: { code: VOICE_SESSION_EXPIRED_CODE },
}); });
}; };
@@ -270,7 +273,7 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
return; return;
} catch { } catch {
if (attempt === TOKEN_REFRESH_ATTEMPTS) { if (attempt === TOKEN_REFRESH_ATTEMPTS) {
await failRefresh(); await failRefresh(guildId, channelId);
return; return;
} }
} }
@@ -347,7 +350,7 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
useSessionStore.getState().applyVoiceState(result.state); useSessionStore.getState().applyVoiceState(result.state);
scheduleTokenRefresh(result.token, mine); scheduleTokenRefresh(result.token, mine);
const handle = createVoiceRoom({ const handle = await createVoiceRoom({
onParticipants: (participants) => { onParticipants: (participants) => {
if (mine === generation) { if (mine === generation) {
set({ participants }); set({ participants });
@@ -370,6 +373,10 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
}, },
onSpeakingWhileMuted: warnSpeakingWhileMuted, onSpeakingWhileMuted: warnSpeakingWhileMuted,
}); });
if (mine !== generation) {
handle.dispose();
return;
}
room = handle; room = handle;
await handle.connect(result.url, result.token); await handle.connect(result.url, result.token);
if (mine !== generation) { if (mine !== generation) {
+8 -4
View File
@@ -366,9 +366,10 @@ describe('голосовая сессия: синхронизация с сер
s: 1, s: 1,
d: makeVoiceState({ user_id: 'user-1', channel_id: 'c-other' }), 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. // Старая комната закрыта, для новой создана своя сессия SDK.
expect(first.disconnectCalls).toBe(1); expect(first.disconnectCalls).toBe(1);
expect(FakeRoom.instances).toHaveLength(2); expect(FakeRoom.instances).toHaveLength(2);
@@ -382,9 +383,10 @@ describe('голосовая сессия: синхронизация с сер
const fetchMock = await joinRoom(); const fetchMock = await joinRoom();
FakeRoom.latest().emit(RoomEvent.Disconnected); 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(useVoiceConnectionStore.getState().participants).toEqual([]);
expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined(); expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined();
}); });
@@ -456,6 +458,8 @@ describe('голосовая сессия: продление токена', ()
expect(state.status).toBe('error'); expect(state.status).toBe('error');
expect(state.error).toEqual({ code: VOICE_SESSION_EXPIRED_CODE }); expect(state.error).toEqual({ code: VOICE_SESSION_EXPIRED_CODE });
expect(state.participants).toEqual([]); expect(state.participants).toEqual([]);
// Комната остаётся в состоянии ошибки: подсказка видна в интерфейсе.
expect(state.channelId).toBe('c-voice');
expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined(); expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined();
}); });
}); });
+3
View File
@@ -15,6 +15,9 @@ export default defineConfig({
emptyOutDir: true, emptyOutDir: true,
sourcemap: false, sourcemap: false,
target: 'es2022', target: 'es2022',
// SDK LiveKit (~520 КБ) подгружается отдельным чанком только при входе в
// голосовую комнату, поэтому порог предупреждения поднят осознанно.
chunkSizeWarningLimit: 600,
}, },
server: { server: {
port: 5173, port: 5173,