# Интерфейс комнаты конференции **Ссылки:** `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`, чтобы применить его при следующем входе Диалог должен рендериться внутри ``: `useMediaDeviceSelect` без явно переданного `room` берёт активную комнату из `RoomContext`. **Ключевой момент:** ошибка переключения устройства (занято/отключено) показывается тостом; `activeDeviceId` хука остаётся источником истины — состояние селекта само не «откатывается». ## 2. Аватары участников ### Механизм отображения Общий компонент `frontend/src/components/ui/Avatar.tsx` используется и в топбаре/админке/пикере участников, и в комнате (`RoomParticipantTile.tsx`): - Если передан `avatarUrl` — рендерится `` - Иначе — инициалы имени (первые буквы первых двух слов), на фоне базового класса `.avatar`; отдельного визуального различия между зарегистрированным пользователем без аватара и гостем нет — оба показывают инициалы одинаково ### Передача аватара в LiveKit LiveKit-токен (выдаётся `POST /api/v1/conferences/{id}/join` для зарегистрированных участников) содержит метаданные: ```json { "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 стили — ссылкой ``, не инлайном) и проставляет `data-theme="room"` на `` PiP-окна. Содержимое — `RoomPage.tsx` рендерит `` порталом (`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"]` и токены: ```css [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`) для адаптивного размера внутри плитки участника: ```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`): ```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, строки 128–142): - Иконка: `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, строки 32–37): ```typescript { 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](https://w3c.github.io/document-picture-in-picture/) — W3C - [Picture-in-Picture Spec](https://www.w3.org/TR/picture-in-picture/) — W3C (video PiP) - [Fullscreen API](https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API) — MDN - [Screen Capture API (getDisplayMedia)](https://w3c.github.io/mediacapture-screen-share/) — W3C - `docs/architecture/frontend-themes.md` — тёмная тема комнаты