Files
vidconf/docs/architecture/conference-room-ui.md

23 KiB
Raw Blame History

Интерфейс комнаты конференции

Ссылки: frontend/src/pages/RoomPage.tsx, frontend/src/components/room/*, @livekit/components-react, LiveKit JS SDK

Обзор

Комната конференции — это отдельный замкнутый UI с собственной тёмной темой (§ frontend-themes.md):

  • Диалог настроек устройств (микрофон, камера) с персист выбора
  • Аватары участников (или инициалы при отсутствии)
  • Fullscreen API (кнопка)
  • Мини-плеер: Document Picture-in-Picture для активной плитки (Chrome/Edge 116+) с фолбэком на Video Picture-in-Picture (Safari/Firefox)
  • Демонстрация экрана/окна/вкладки (любой участник, focus-раскладка, last-wins, звук где браузер отдаёт)

1. Настройки устройств (Device Settings)

Архитектура

UI (DeviceSettingsDialog.tsx):

  • Кнопка-шестерёнка в тулбаре комнаты → диалог настроек
  • Селекты «Микрофон» и «Камера»

Список устройств, переключение активного и персист выбора между заходами в комнату целиком делегированы хукам @livekit/components-react:

  • useMediaDeviceSelect({ kind }) — список устройств (подписан на RoomEvent.MediaDevicesChanged), activeDeviceId, setActiveMediaDevice()
  • usePersistentUserChoices() — сохраняет выбранные deviceId (localStorage, ключи и формат — внутренняя реализация библиотеки); RoomPage.tsx читает сохранённый выбор через options-проп LiveKitRoom, чтобы применить его при следующем входе

Диалог должен рендериться внутри <LiveKitRoom>: useMediaDeviceSelect без явно переданного room берёт активную комнату из RoomContext.

Ключевой момент: ошибка переключения устройства (занято/отключено) показывается тостом; activeDeviceId хука остаётся источником истины — состояние селекта само не «откатывается».

2. Аватары участников

Механизм отображения

Общий компонент frontend/src/components/ui/Avatar.tsx используется и в топбаре/админке/пикере участников, и в комнате (RoomParticipantTile.tsx):

  • Если передан avatarUrl — рендерится <img>
  • Иначе — инициалы имени (первые буквы первых двух слов), на фоне базового класса .avatar; отдельного визуального различия между зарегистрированным пользователем без аватара и гостем нет — оба показывают инициалы одинаково

Передача аватара в LiveKit

LiveKit-токен (выдаётся POST /api/v1/conferences/{id}/join для зарегистрированных участников) содержит метаданные:

{
  "metadata": "{\"avatar_url\": \"https://vidconf.example.com/media/avatars/550e8400....jpg\"}"
}

Парсинг — инлайн-функция parseAvatarUrl() в RoomParticipantTile.tsx: разбирает participant.metadata (реактивно, через useParticipantInfo), возвращает null при пустых/невалидных метаданных. Для гостей avatar_url в токен не кладётся — parseAvatarUrl вернёт null, показываются инициалы.

3. Fullscreen API

Реализация

Хук frontend/src/hooks/useFullscreen.ts — единственный источник истины о состоянии — событие fullscreenchange документа (не промис requestFullscreen(): выход по Esc браузер выполняет сам, без обратного вызова). Цель — корневой контейнер комнаты (div[data-theme="room"] в RoomPage.tsx), чтобы тулбар и чат оставались видны внутри полноэкранного режима. supported = document.fullscreenEnabled — кнопка в RoomToolbar.tsx скрывается, если false.

Поддержка: все современные браузеры (Chrome, Firefox, Safari, Edge).

4. Мини-плеер (Picture-in-Picture)

Матрица поддержки

Браузер Document PiP Video PiP Что использует
Chrome 116+ / Edge 116+ ✓ Да ✓ Да Document PiP (активная плитка в отдельном окне)
Safari 17+ ✗ Нет ✓ Да Video PiP (только видео активной плитки)
Firefox ✗ Нет ✓ Да Video PiP (только видео активной плитки)

Оба флага детектируются в рантайме ('documentPictureInPicture' in window, document.pictureInPictureEnabled) — если ни один браузер API не поддерживает, supported: false и кнопка скрывается (очень старые браузеры).

Единый хук useRoomPiP

frontend/src/hooks/useRoomPiP.ts инкапсулирует оба режима за одним API (supported, active, mode, pipWindow, toggle):

  1. Document Picture-in-Picture (Chrome/Edge) — toggle() синхронно (в рамках user gesture) вызывает window.documentPictureInPicture.requestWindow(), копирует таблицы стилей текущего документа в PiP-окно (copyStyleSheets; внешние cross-origin стили — ссылкой <link>, не инлайном) и проставляет data-theme="room" на <html> PiP-окна. Содержимое — RoomPage.tsx рендерит <RoomStage variant="pip" /> порталом (createPortal) прямо в pipWindow.document.body; React-контекст LiveKitRoom остаётся в основном дереве, поэтому хуки треков продолжают работать. В variant="pip" сцена показывает только одну активную плитку (без карусели/грида), фокус живо следует за активным спикером.
  2. Video Picture-in-Picture (Safari) — фолбэк, классический videoEl.requestPictureInPicture() на видео из фокус-плитки основного окна.
  3. Ни то, ни другое не поддерживается (Firefox) — supported: false, кнопка в тулбаре скрывается.

Ошибки открытия (например, requestWindow() отклонён) показываются тостом, не проваливаются молча. Закрытие PiP-окна пользователем через системный крестик отслеживается через событие pagehide окна; при размонтировании хука (уход со страницы) осиротевшее PiP-окно закрывается явно.

5. Вёрстка и CSS

Токены цвета комнаты

Комната всегда использует [data-theme="room"] и токены:

[data-theme="room"] {
  --color-room-bg: #1E1E1E;              /* Чёрный фон */
  --color-room-text-primary: #E8E8E8;    /* Светлый текст */
  --color-room-mic-on: #7FDDA8;          /* Мята (mic включен) */
  --color-room-mic-off: #EB93A1;         /* Роза (mic выключен) */
  --color-room-camera-on: #7FDDA8;       /* Мята (camera включена) */
  --color-room-camera-off: #EB93A1;      /* Роза (camera выключена) */
  --color-room-speaker-ring: #D6A83D;    /* Янтарь (спикер) */
}

Аватар (CSS)

Базовый класс .avatar — общий для всей оболочки (frontend/src/styles/shell.css); в комнате плитка добавляет модификатор .room-tile-avatar (frontend/src/styles/room.css) для адаптивного размера внутри плитки участника:

.avatar {
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: var(--color-ink-700);
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}
.avatar img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 50%;
}

/* Модификатор для плитки участника комнаты — адаптивный размер */
.room-tile-avatar {
  width: 40%;
  height: 40%;
  min-width: 32px;
  min-height: 32px;
  max-width: 96px;
  max-height: 96px;
  font-size: clamp(12px, 3vw, 28px);
  font-weight: 700;
}

Отдельного визуального варианта для гостей нет — инициалы гостя рендерятся тем же .avatar.

Диалог настроек устройств

Диалог использует общие модальные классы комнаты (frontend/src/styles/room.css):

.room-modal-overlay { /* полноэкранная подложка с затемнением */ }
.room-modal-panel { /* сама карточка диалога, --color-room-bg фон */ }
.room-modal-head { display: flex; align-items: flex-start; justify-content: space-between; }
.room-modal-close { /* кнопка закрытия */ }

6. Демонстрация экрана

Обзор

Участники конференции (включая гостей) могут поделиться своим экраном или отдельным окном. Демонстрация — это перманентный источник видео (как и камера), публикуется через Track.Source.ScreenShare, отображается крупно в фокус-плитке при наличии, а другие участники — в карусели сбоку. Новый демонстратор автоматически перехватывает фокус (политика last-wins); предыдущий остаётся виден как обычная плитка в карусели.

Управление (UI)

Кнопка в тулбаре комнаты (RoomToolbar.tsx, строки 128142):

  • Иконка: ScreenShare / ScreenShareOff (из lucide-react)
  • Текст: «Демонстрация»
  • Состояние: отражает, активна ли локальная демонстрация текущего участника
  • Клик: вызывает useTrackToggle({ source: Track.Source.ScreenShare, captureOptions: SCREEN_SHARE_CAPTURE_OPTIONS })

Жизненный цикл:

  1. Начало демонстрации: пользователь нажимает кнопку → браузер показывает диалог выбора экрана/окна/вкладки → пользователь выбирает источник или отменяет → состояние кнопки и сцена обновляются
  2. Во время демонстрации:
    • Трек ScreenShare остаётся активным (публикуется)
    • Сцена переходит на focus-раскладку (см. ниже)
    • Пользователь может закончить в любой момент: нажать кнопку ещё раз ИЛИ нажать системную кнопку браузера «Прекратить доступ» (в браузере, обычно справа в адресной строке) → состояние синхронизируется автоматически
  3. Конец демонстрации: трек удаляется, фокус переходит на активного спикера или первого участника

Опции захвата (ScreenShareCaptureOptions)

Константа SCREEN_SHARE_CAPTURE_OPTIONS (RoomToolbar.tsx, строки 3237):

{
  audio: true,                    // Захватывать звук (вкладка/экран, где доступно)
  selfBrowserSurface: 'exclude',  // Не предлагать саму вкладку конференции
  surfaceSwitching: 'include',    // Разрешить переключать источник во время демо
  systemAudio: 'include'          // Не запрещать системный звук (если браузер отдаёт)
}

Обработка ошибок:

  • NotAllowedError (пользователь нажал «Отмена» в браузерном диалоге) — игнорируется молча
  • Прочие ошибки (например, NotReadableError при занятом источнике) — показываются в тосте: «Не удалось начать демонстрацию экрана»

Отображение на сцене (RoomStage.tsx)

Focus-раскладка при активной демонстрации

При наличии хотя бы одного активного трека Track.Source.ScreenShare (RoomStage.tsx, переменная hasScreenShare):

  • FocusLayoutContainer включается БЕЗУСЛОВНО (независимо от количества участников)
  • Фокус-плитка: первая активная демонстрация (по приоритету выбора)
  • Карусель слева: все camera-треки + прочие screenshare-треки (проигравшие фокус)
  • Инициалы/аватары заменены плитками видео, но если камера выключена — показывается аватар

Политика last-wins для нескольких демонстраторов

Логика в функции pickStageFocus (stageFocus.ts):

  • Если новый участник запустил демонстрацию → её трек становится фокусом
  • Предыдущая демонстрация остаётся в карусели как обычная плитка (не удаляется)
  • Каждый демонстратор может остановить свою демонстрацию независимо

Остановка собственной демонстрации

В фокус-плитке, когда текущий участник демонстрирует экран:

  • Отображается чип/кнопка: «Вы демонстрируете экран» ← click → вызов room.localParticipant.setScreenShareEnabled(false)
  • Трек прекращается, фокус переходит на спикера
  • Логика в RoomStage.tsx (функция handleStopSharing, передаётся в RoomParticipantTile)

Поведение в fullscreen/PiP

Fullscreen:

  • Демонстрация экрана работает в fullscreen-режиме как обычно
  • Focus-раскладка сохраняется: демонстрация крупно, участники узкой колонкой
  • Выход из fullscreen (кнопка или ESC) возвращает стандартный вид

Document Picture-in-Picture (Chrome/Edge 116+):

  • При открытии PiP-окна показывается только активная плитка (без карусели/грида)
  • Если активна демонстрация → в PiP она и отображается (фокус)
  • Фокус в PiP живо следует за активным спикером (followSpeaker), в отличие от основного окна
  • Закрытие PiP-окна возвращает вид на основное окно

Video Picture-in-Picture (Safari/Firefox фолбэк):

  • Показывает только активное видео из фокус-плитки основного окна
  • При активной демонстрации → в PiP видно именно её

Права доступа

  • Любой участник может начать демонстрацию экрана, включая гостей
  • Нет специальных прав или ролей для демонстрации
  • Ограничение: браузер может запросить разрешение на доступ к экрану у ОС (обычно да/нет в диалоге браузера)

Звук демонстрации: матрица браузеров

Функция getDisplayMedia (WebRTC API) отдаёт аудио-дорожку демонстрации там, где браузер и ОС позволяют. Ниже матрица по браузерам и ОС.

Браузер / ОС Вкладка (tab) Весь экран Отдельное окно Примечания
Chrome/Edge — Windows ✓ Да ✓ Да (системный звук) ⚠ Обычно нет Вкладка: звук вкладки; весь экран: системный звук; окно — редко отдаёт звук (зависит от окна)
Chrome/Edge — Linux ✓ Да ✓ Да (PulseAudio) ⚠ Редко PulseAudio выбирает источник; окно ≈ как на Windows
Chrome/Edge — macOS ✓ Да ✗ Нет (ОС не отдаёт) ✗ Нет (ОС не отдаёт) Вкладка работает; весь экран и окно — ОС macOS не предоставляет системный звук браузеру для безопасности
Safari 16+ — macOS ✓ Да ✗ Нет ✗ Нет Поддержка getDisplayMedia есть, звук не отдаётся; некоторые поля ScreenShareCaptureOptions (systemAudio) игнорируются
Firefox — Windows/Linux/macOS ✓ Да ✓ Да ⚠ Редко Firefox поддерживает getDisplayMedia, поля systemAudio/selfBrowserSurface могут игнорироваться — безопасная деградация

Ключевые моменты:

  1. SDK не фейлит старт без аудио: если браузер не может захватить звук, getDisplayMedia() всё равно вернёт видео-дорожку (без аудио) — демонстрация работает, просто без звука
  2. RoomAudioRenderer: компонент (RoomStage.tsx) проигрывает аудио-дорожки удалённых демонстраций, если они присутствуют в треках
  3. Почему macOS без системного звука? Согласно WebRTC спецификации и политике безопасности Apple, браузеры на macOS не получают системный звук через getDisplayMedia() — только звук текущей вкладки. Пользователь должен явно выбрать вкладку (браузер, плеер, Zoom и т. д.) в диалоге браузера, чтобы захватить её звук.

Примечания

  1. Аватары гостей: гости не получают avatar_url в метаданных LiveKit-токена, поэтому всегда видят инициалы.
  2. Document PiP требует взаимодействия: запрос можно сделать только в ответ на click или похожий пользовательский жест (security policy браузера) — toggle() хука useRoomPiP поэтому вызывается синхронно из обработчика клика.
  3. Video PiP показывает только одну плитку: если нужна сетка целиком, используйте Document PiP (Chrome/Edge).
  4. Fullscreen работает везде: но некоторые браузеры могут показать UI-запрос перед вводом.
  5. Персист выбора устройств — через usePersistentUserChoices из @livekit/components-react; наличие сохранённого устройства не гарантирует, что оно всё ещё подключено — библиотека сама обрабатывает этот случай при следующем входе.
  6. Демонстрация экрана требует пользовательского жеста: браузер требует клика/касания перед открытием диалога выбора экрана (Permissions Policy, безопасность).
  7. Звук демонстрации теряется в macOS: если требуется захват системного звука, пользователю на Mac нужно выбрать конкретную вкладку браузера/плеера (не «весь экран»).

Ссылки

  • frontend/src/pages/RoomPage.tsx — главный компонент комнаты, подключение LiveKit, порталы PiP
  • frontend/src/components/room/RoomToolbar.tsx — тулбар (микрофон, камера, демонстрация, настройки, fullscreen, PiP, чат, выход)
  • frontend/src/components/room/RoomStage.tsx — сцена с focus-раскладкой и логикой демонстрации экрана
  • frontend/src/components/room/stageFocus.ts — чистая функция выбора фокуса (pickStageFocus, last-wins)
  • frontend/src/components/room/RoomParticipantTile.tsx — плитка участника, аватар, парсинг метаданных
  • frontend/src/components/room/DeviceSettingsDialog.tsx — диалог настроек микрофона/камеры
  • frontend/src/hooks/useFullscreen.ts — полноэкранный режим
  • frontend/src/hooks/useRoomPiP.ts — мини-плеер (Document PiP + Video PiP фолбэк)
  • Document Picture-in-Picture Spec — W3C
  • Picture-in-Picture Spec — W3C (video PiP)
  • Fullscreen API — MDN
  • Screen Capture API (getDisplayMedia) — W3C
  • docs/architecture/frontend-themes.md — тёмная тема комнаты