313 lines
23 KiB
Markdown
313 lines
23 KiB
Markdown
# Интерфейс комнаты конференции
|
||
|
||
**Ссылки:** `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` для
|
||
зарегистрированных участников) содержит метаданные:
|
||
|
||
```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 стили — ссылкой `<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"]` и токены:
|
||
```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` — тёмная тема комнаты
|