first commit
This commit is contained in:
312
docs/architecture/conference-room-ui.md
Normal file
312
docs/architecture/conference-room-ui.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# Интерфейс комнаты конференции
|
||||
|
||||
**Ссылки:** `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` — тёмная тема комнаты
|
||||
Reference in New Issue
Block a user