first commit
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled

This commit is contained in:
2026-07-23 02:38:05 +03:00
commit 8757bec8ac
335 changed files with 61527 additions and 0 deletions

View 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, строки 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` — тёмная тема комнаты