# Интерфейс комнаты конференции

**Ссылки:** `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` — тёмная тема комнаты
