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

313 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Интерфейс комнаты конференции
**Ссылки:** `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, строки 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):
```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` — тёмная тема комнаты