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();
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"
>
<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 anchorRef = useRef<HTMLButtonElement | null>(null);
const volume = useVoiceStore((state) => state.participantVolumes[occupant.userId] ?? 100);
// Общая громкость звука умножается на личную громкость участника.
const outputVolume = useVoiceStore((state) => state.outputVolume);
const deafened = useVoiceConnectionStore((state) => state.deafened);
const media = occupant.participant;
const video = media?.screen ?? media?.video;
@@ -63,6 +65,14 @@ function VoiceTile({
data-speaking={occupant.isSpeaking}
draggable={permissions.move && !occupant.isLocal}
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)] ${
occupant.isSpeaking ? 'border-success/70' : 'border-border/60'
}`}
@@ -81,7 +91,11 @@ function VoiceTile({
<RemoteVideo track={video} muted={occupant.isLocal} />
)}
{media?.audio === undefined ? null : (
<RemoteAudio track={media.audio} volume={volume} muted={deafened} />
<RemoteAudio
track={media.audio}
volume={(volume * outputVolume) / 100}
muted={deafened}
/>
)}
</div>
+2 -6
View File
@@ -218,10 +218,6 @@
"title": "Chat arrives in Phase 2",
"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": {
"title": "Pick a channel",
"body": "The server and channel lists are on the left. Pick a channel to open it."
@@ -775,13 +771,13 @@
},
"voice": {
"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",
"camera": "Camera",
"output": "Sound",
"micVolume": "Microphone 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": {
"title": "Language & region",
+2 -6
View File
@@ -218,10 +218,6 @@
"title": "Чат появится в Фазе 2",
"body": "Комната «{{name}}» уже доступна. Отправка сообщений и голосовые каналы появятся в следующей фазе."
},
"voicePlaceholder": {
"title": "Голосовая комната",
"body": "Подключение к голосу появится в следующих фазах."
},
"noSelection": {
"title": "Выберите комнату",
"body": "Слева — список серверов и комнат. Выберите комнату, чтобы открыть её."
@@ -775,13 +771,13 @@
},
"voice": {
"title": "Аудио-видео",
"description": "Устройства ввода и вывода и громкость.",
"description": "Устройства ввода и вывода и громкость. Изменения применяются к текущей голосовой комнате сразу.",
"input": "Микрофон",
"camera": "Камера",
"output": "Звук",
"micVolume": "Громкость микрофона",
"outputVolume": "Громкость звука",
"stub": "Голосовые комнаты появятся в следующей фазе — пока настройки только сохраняются."
"stub": "Изменения применяются сразу и сохраняются на этом устройстве."
},
"language": {
"title": "Язык и регион",
+25 -19
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,7 +69,12 @@ export interface VoiceRoomHandle {
/** Запасной TTL токена, если `exp` не удалось разобрать (AGENT.md §7.14). */
export const DEFAULT_TOKEN_TTL_MS = 10 * 60_000;
function qualityOf(participant: Participant): VoiceQuality {
/** Создаёт комнату LiveKit и подписывается на её события. */
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';
@@ -79,9 +87,12 @@ function qualityOf(participant: Participant): VoiceQuality {
default:
return 'unknown';
}
}
};
function handleOf(track: Track | undefined, kind: 'audio' | 'video'): VoiceTrackHandle | undefined {
const handleOf = (
track: Track | undefined,
kind: 'audio' | 'video',
): VoiceTrackHandle | undefined => {
if (track === undefined) {
return undefined;
}
@@ -94,24 +105,22 @@ function handleOf(track: Track | undefined, kind: 'audio' | 'video'): VoiceTrack
track.detach(element);
},
};
}
};
/** Опубликована ли дорожка и не замьючена ли она. */
function isLive(participant: Participant, source: Track.Source): boolean {
/** Опубликована ли дорожка и не замьючена ли она. */
const 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 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:
participant.name === undefined || participant.name === ''
? participant.identity
: participant.name,
name: name === undefined || name === '' ? participant.identity : name,
isLocal,
isSpeaking: participant.isSpeaking,
micEnabled: isLive(participant, Track.Source.Microphone),
@@ -133,19 +142,16 @@ function snapshotOf(participant: Participant, isLocal: boolean): VoiceParticipan
snapshot.screen = screenShare;
}
return snapshot;
}
};
function snapshotAll(room: Room): VoiceParticipant[] {
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;
}
};
/** Создаёт комнату LiveKit и подписывается на её события. */
export function createVoiceRoom(callbacks: VoiceRoomCallbacks): VoiceRoomHandle {
// Пустой deviceId в предпочтениях означает «устройство по умолчанию».
const room = new Room({ adaptiveStream: true, dynacast: true });
const refresh = (): void => {
callbacks.onParticipants(snapshotAll(room));
@@ -8,8 +8,8 @@ import { SettingsSection } from '@/pages/settings/SettingsSection';
/**
* «Аудио-видео»: выбор устройств ввода/вывода и громкости.
*
* Значения пока только сохраняются локально (zustand + localStorage):
* голосовые комнаты появятся в Фазе 3, тогда их начнёт использовать RTC.
* Значения хранятся локально (zustand + localStorage) и применяются на лету:
* выбранное устройство сразу переключается в активной голосовой сессии.
*/
export function VoiceSettingsSection() {
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();
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) {
+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');
});
// Старая комната закрыта, для новой создана своя сессия 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().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();
});
});
+3
View File
@@ -15,6 +15,9 @@ export default defineConfig({
emptyOutDir: true,
sourcemap: false,
target: 'es2022',
// SDK LiveKit (~520 КБ) подгружается отдельным чанком только при входе в
// голосовую комнату, поэтому порог предупреждения поднят осознанно.
chunkSizeWarningLimit: 600,
},
server: {
port: 5173,