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:
@@ -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)}`}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,9 @@ export default defineConfig({
|
||||
emptyOutDir: true,
|
||||
sourcemap: false,
|
||||
target: 'es2022',
|
||||
// SDK LiveKit (~520 КБ) подгружается отдельным чанком только при входе в
|
||||
// голосовую комнату, поэтому порог предупреждения поднят осознанно.
|
||||
chunkSizeWarningLimit: 600,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
|
||||
Reference in New Issue
Block a user