From 3ae03ec5155ad16f0dbbc7e63bfe50e936e9c2f2 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 20 Sep 2026 18:51:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(voice):=20=D0=B3=D0=BE=D0=BB=D0=BE=D1=81?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=B9=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84?= =?UTF-8?q?=D0=B5=D0=B9=D1=81=20=D0=BD=D0=B0=20LiveKit=20(=D0=A4=D0=B0?= =?UTF-8?q?=D0=B7=D0=B0=203,=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `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 зелёные. --- web/src/components/voice/VoiceOccupantRow.tsx | 5 + web/src/components/voice/VoiceRoomPanel.tsx | 16 +- web/src/i18n/locales/en.json | 8 +- web/src/i18n/locales/ru.json | 8 +- web/src/lib/livekit.ts | 166 +++++++++--------- .../pages/settings/VoiceSettingsSection.tsx | 4 +- web/src/stores/voiceConnection.ts | 13 +- web/tests/voiceConnection.test.ts | 12 +- web/vite.config.ts | 3 + 9 files changed, 133 insertions(+), 102 deletions(-) diff --git a/web/src/components/voice/VoiceOccupantRow.tsx b/web/src/components/voice/VoiceOccupantRow.tsx index 86a2f1b..5ad1ee6 100644 --- a/web/src/components/voice/VoiceOccupantRow.tsx +++ b/web/src/components/voice/VoiceOccupantRow.tsx @@ -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" > diff --git a/web/src/components/voice/VoiceRoomPanel.tsx b/web/src/components/voice/VoiceRoomPanel.tsx index 50475df..ac6396f 100644 --- a/web/src/components/voice/VoiceRoomPanel.tsx +++ b/web/src/components/voice/VoiceRoomPanel.tsx @@ -45,6 +45,8 @@ function VoiceTile({ const [menuOpen, setMenuOpen] = useState(false); const anchorRef = useRef(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({ )} {media?.audio === undefined ? null : ( - + )} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index dd9b76c..834f6ac 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 430481b..5561dd2 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -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": "Язык и регион", diff --git a/web/src/lib/livekit.ts b/web/src/lib/livekit.ts index 2ebf2dc..a6fedac 100644 --- a/web/src/lib/livekit.ts +++ b/web/src/lib/livekit.ts @@ -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 { + // 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)); diff --git a/web/src/pages/settings/VoiceSettingsSection.tsx b/web/src/pages/settings/VoiceSettingsSection.tsx index eee5772..ac036a4 100644 --- a/web/src/pages/settings/VoiceSettingsSection.tsx +++ b/web/src/pages/settings/VoiceSettingsSection.tsx @@ -8,8 +8,8 @@ import { SettingsSection } from '@/pages/settings/SettingsSection'; /** * «Аудио-видео»: выбор устройств ввода/вывода и громкости. * - * Значения пока только сохраняются локально (zustand + localStorage): - * голосовые комнаты появятся в Фазе 3, тогда их начнёт использовать RTC. + * Значения хранятся локально (zustand + localStorage) и применяются на лету: + * выбранное устройство сразу переключается в активной голосовой сессии. */ export function VoiceSettingsSection() { const { t } = useTranslation(); diff --git a/web/src/stores/voiceConnection.ts b/web/src/stores/voiceConnection.ts index a4de83a..ce422f0 100644 --- a/web/src/stores/voiceConnection.ts +++ b/web/src/stores/voiceConnection.ts @@ -197,7 +197,7 @@ export const useVoiceConnectionStore = create((set, get) = }; /** Не удалось продлить сессию: выходим из комнаты и показываем ошибку. */ - const failRefresh = async (): Promise => { + const failRefresh = async (guildId: string, channelId: string): Promise => { clearTokenTimer(); await closeRoom(); const channel = joinedChannelId; @@ -211,7 +211,10 @@ export const useVoiceConnectionStore = create((set, get) = } set({ ...defaults, + // Комнату оставляем в состоянии: так подсказка видна у канала и в панели. status: 'error', + guildId, + channelId, error: { code: VOICE_SESSION_EXPIRED_CODE }, }); }; @@ -270,7 +273,7 @@ export const useVoiceConnectionStore = create((set, get) = return; } catch { if (attempt === TOKEN_REFRESH_ATTEMPTS) { - await failRefresh(); + await failRefresh(guildId, channelId); return; } } @@ -347,7 +350,7 @@ export const useVoiceConnectionStore = create((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((set, get) = }, onSpeakingWhileMuted: warnSpeakingWhileMuted, }); + if (mine !== generation) { + handle.dispose(); + return; + } room = handle; await handle.connect(result.url, result.token); if (mine !== generation) { diff --git a/web/tests/voiceConnection.test.ts b/web/tests/voiceConnection.test.ts index cb85514..1a68041 100644 --- a/web/tests/voiceConnection.test.ts +++ b/web/tests/voiceConnection.test.ts @@ -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(); }); }); diff --git a/web/vite.config.ts b/web/vite.config.ts index 4120bc7..79e2993 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -15,6 +15,9 @@ export default defineConfig({ emptyOutDir: true, sourcemap: false, target: 'es2022', + // SDK LiveKit (~520 КБ) подгружается отдельным чанком только при входе в + // голосовую комнату, поэтому порог предупреждения поднят осознанно. + chunkSizeWarningLimit: 600, }, server: { port: 5173,