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>