17 Commits

Author SHA1 Message Date
d11b808e97 release: версия 0.0.26
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-04 00:01:22 +03:00
ed6f9fff44 feat(room): полноэкранный режим на мобильном с выезжающим тулбаром
Вход — кнопка «Экран» в тулбаре, теперь видна и на мобильном (по аналогии
с десктопом, а не спрятана в шторку настроек). В полноэкранном режиме
топбар и тулбар выходят из потока и лежат оверлеем поверх сцены: тап/клик
по сцене вне элементов управления показывает их, сами прячутся через
несколько секунд бездействия; на десктопе дополнительно — наведение мыши
в нижнюю полосу экрана. Работает одинаково на мобильном и десктопе.

Кнопка настроек устройств в тулбаре на мобильном подписана «Настройки»
вместо «Устройства». Раскладка кнопок мобильного тулбара, когда они не
помещаются в один ряд (7+, обычный случай с «Экраном» и «Очередью» у
организатора), стала равномерной сеткой на 4 колонки вместо переноса
«как получится» через flex-wrap.
2026-08-04 00:00:45 +03:00
06455f2401 fix(room): шторка настроек не закрывалась свайпом вниз
Обработчики висели только на ручке `.room-sheet-handle` (40×4px) —
попасть в неё пальцем практически невозможно, и палец почти всегда
приземлялся на панель, где обработчиков не было вовсе. Свайп теперь
закрывает шторку при жесте по любому месту панели, но только когда
содержимое проскроллено в самый верх — иначе палец должен листать
список устройств, как в любом стандартном bottom sheet.
2026-08-03 23:59:26 +03:00
3847476798 chore(deploy): render-templates.sh умеет читать другой файл значений
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Путь к файлу со значениями был жёстко зашит как `<корень>/.env`. На
машине разработчика корневой `.env` держит боевые адреса
(LIVEKIT_NODE_IP/TURN_EXTERNAL_IP смотрят на прод), поэтому рендерить из
него конфиги для локального стенда нельзя, а подменить нечем — локальные
сессии дважды повторяли логику скрипта вручную через envsubst, что
означало расхождение с реальным рендером при первой же правке шаблонов.

Теперь источник значений задаётся переменной ENV_FILE:

    ENV_FILE=.env.local ./deploy/render-templates.sh

Поведение по умолчанию не меняется — тот же корневой `.env`. install.sh
свою переменную ENV_FILE не экспортирует, так что она сюда не протекает;
вызов из install.sh и рендер на сервере работают как раньше. Скрипт
дополнительно печатает, из какого файла взяты значения, и подсказывает
про ENV_FILE, если файл не найден.
2026-08-03 18:45:20 +03:00
a895782250 release: версия 0.0.25
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-03 18:24:13 +03:00
fa8270c156 fix(room): мини-окно игнорировало закрепление, а демонстрация слетала от реплики
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Мини-плеер намеренно вёл себя иначе, чем основное окно: без удержания
демонстрации экрана (holdScreenShare), без приоритета говорящего с
включённой камерой, без антидребезга говорящего и с собственным чистым
useState для закрепления. На практике это читалось как поломка —
закрепление, сделанное в основном окне, в мини-окне не действовало, а
демонстрация экрана пропадала, стоило кому-то сказать слово.

Теперь pickStageFocus получает одинаковые правила в обоих вариантах
сцены. Единственное сознательное отличие — localKey («показать себя»
последним фолбэком), он остаётся только у мини-плеера: это защита от
дефекта 0.0.11, когда мини-окно открывалось на самом пользователе.

Закрепление переезжает между окнами тем же мостиком через RoomPage,
что и фокус (initialPinnedKey/onPinnedKeyChange). Отдельный общий
источник правды не нужен: экземпляр сцены в каждый момент ровно один —
пока открыт Document PiP, основное окно показывает заглушку.

Заодно в снятии закрепления «участник вышел из комнаты» добавлена
охрана tracksKnown. На первом рендере нового экземпляра сцены useTracks
отдаёт пустой массив, и пустой набор читался как «все вышли»: приехавшее
через initialPinnedKey закрепление обнулялось прямо при монтировании,
то есть мини-плеер терял его каждый раз.

Надпись на булавке — «Закрепить» вместо «Закрепить в основном окне»:
закрепление больше не ограничено основным окном.
2026-08-03 18:21:43 +03:00
a9e24f6692 fix(room): в Chrome пропадал звук после возврата из мини-окна
RoomAudioRenderer жил внутри RoomStage и рендерился дважды — в ветке
variant="pip" и в основной. При открытии Document PiP сцена
размонтируется в основном окне и монтируется в PiP-окне, поэтому
скрытые <audio> с чужими аудиотреками физически переезжали в ДРУГОЙ
документ, а при возврате — обратно. После такого переезда Chrome
теряет аудиовыход у remote-трека: пакеты продолжают приходить
(packetsReceived растёт), но totalSamplesDuration и totalAudioEnergy
замирают, и трек молчит даже в свежесозданном <audio> со свежим
MediaStream. Тот же цикл detach/attach в пределах одного документа
безвреден — дело именно в переезде между документами. В Safari бага не
было: там Document PiP не используется (video-фолбэк), сцена остаётся
в основном окне.

Рендерер вынесен в RoomPage — один экземпляр, всегда в основном
документе. В PiP-окне аудиоэлементов теперь нет вовсе, цикл «открыл
мини-окно → вернул» их не касается.
2026-08-03 18:19:19 +03:00
8b63c24332 release: версия 0.0.24
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-03 01:58:32 +03:00
cd5399f88a fix(auth): заголовок вылезал за брендовую панель на широком окне
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Кегль «Ваша инфраструктура» уменьшался только в @media по ширине ОКНА —
не спасало, когда окно широкое, а панель (42% от него, `.layout` flex
42/58) сама по себе узкая: слово не помещалось, overflow:hidden обрезал
его вместо переноса. Перешли на container query (`container-type:
inline-size` на .brand-panel + `cqw` на .brand-headline) — тот же приём,
что у аватара участника в комнате (container-type:size + cqmin,
room.css). Кегль теперь считается от реальной ширины панели, а не окна,
в любой раскладке.

Тем же механизмом клипались плашки .brand-stats («AI-саммари») — добавлен
flex-wrap в базовое правило вместо только-мобильного.
2026-08-03 01:56:42 +03:00
2be799b19d fix(room): эмодзи-поповер в чате — 5-я колонка вылезала за рамку
Настоящая причина (подтверждена вживую через getBoundingClientRect):
`.chat-input-row button` (стиль круглой кнопки «Отправить», width/height
42px) красил размер и кнопкам эмодзи внутри поповера — они тоже лежат в
.chat-input-row. Сетка на 5 колонок по 42px требовала ~238px при
фиксированной ширине попапа 220px, лишнее уезжало вправо за рамку.
Явные width/height: auto на .chat-emoji-option перебивают унаследованный
размер нужной специфичностью, дальше квадрат считает aspect-ratio от
реальной ширины колонки (~36px). Заодно — max-width на попап и
overflow:hidden/white-space:nowrap на кнопки как общая страховка от
переполнения на очень узких экранах.
2026-08-03 01:56:27 +03:00
6b9d0b833e fix(room): чат вылезает за панель в Firefox — min-width/min-height:0
`.chat-messages` (flex-колонка) без явного min-height:0 не сжимается до
flex:1 в Firefox и растёт по контенту списка сообщений. Textarea в
.chat-input-row без min-width:0 упирается в автоматическую минимальную
ширину, которую Firefox считает от атрибута cols (умолчание 20 символов) —
жёстче, чем Chrome/Safari.
2026-08-03 01:56:03 +03:00
f4e8f91839 release: версия 0.0.23
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-02 22:29:34 +03:00
fb50c5d8ea docs(deploy): таблица «профиль нагрузки → железо» на реальных замерах с прода
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Формула CPU/RAM/полосы для медиа-нагрузки (не AI): подписки = камер ×
(участников − 1), подтверждено точно двумя боевыми замерами (28.07 и
31.07.2026). Коэффициенты полосы на подписку и CPU на ядро — из тех же
замеров, с явными допущениями и предупреждением не путать пиковый трафик
с устойчивым. Профили — малая команда/совещание/большое собрание/
смешанная нагрузка, включая пример недостижимого профиля и как его
спасают лимит плиток и потолок качества публикации (0.0.21).

Перекрёстные ссылки из README, hardware-profiles.md, capacity.md.
2026-08-02 22:28:55 +03:00
826a0a391a fix(coturn): verbose-логирование — иначе ALLOCATE/CreatePermission не видны в docker logs
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Дефолтный simple-log в coturn не пишет построчно ALLOCATE/CreatePermission/
Refresh даже при реально работающем relay (найдено на релизе 0.0.22:
рабочий звонок через TURN, но grep -ci allocate по логам coturn = 0,
подтверждение пришлось брать из логов LiveKit). Добавлен флаг verbose
(умеренный режим, не Verbose/-V — тот слишком шумный). DEPLOYMENT.md
предупреждает, что проверка через grep -ci allocate надёжна только с
этим флагом.
2026-08-02 22:11:32 +03:00
4f5f336cd6 release: версия 0.0.22
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-02 20:27:36 +03:00
0441f4f72b docs(deploy): описать включение TURN over TLS, обновление сертификата, порты
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Раздел 8: как включить TURN_TLS_HOST, почему 443 недоступен coturn без
SNI-мультиплексора (порт уже занят nginx через Docker port-publish),
пошаговая проверка (openssl s_client, аллокации в логах). Раздел 5:
deploy-hook certbot теперь перекопирует сертификат в coturn-certs-init и
перезапускает coturn при продлении — без этого TLS-TURN тихо остановится
через ~60 дней со старым сертификатом.
2026-08-02 20:27:01 +03:00
9bc8d6174d feat(coturn): TURN over TLS на 5349 — сертификаты, монтирование, анонс клиентам
coturn (nobody:nogroup, без root-фазы в entrypoint) не может сам прочитать
приватный ключ Let's Encrypt — coturn-certs-init (по образцу
recordings-init/llm-models-init) копирует fullchain/privkey в отдельный
volume под правами 644, не трогая права на ключ на хосте.

TURN_TLS_HOST в .env — единственный переключатель фичи: пусто (dev-дефолт)
вырезает TLS-блоки из turnserver.conf и rtc.turn_servers целиком (маркеры
BEGIN/END-TLS-* в *.template, render-templates.sh), непустое значение
включает оба сразу — TLS без анонса LiveKit клиентам не имеет смысла
(история 0.0.14: coturn работал healthy, но клиенты о нём не знали, и не
было ни одной аллокации). Значение обязано быть доменом сертификата, а не
IP — иначе браузер не пройдёт TLS-валидацию по имени хоста для turns:.

TLS-запись в rtc.turn_servers стоит последней в списке (фолбэк дороже
прямого UDP/TCP).
2026-08-02 20:26:56 +03:00
22 changed files with 1091 additions and 122 deletions

View File

@@ -59,6 +59,16 @@ TURN_STATIC_AUTH_SECRET=change-me-turn-secret
# скриптом deploy/render-templates.sh (вызывается install.sh). # скриптом deploy/render-templates.sh (вызывается install.sh).
TURN_EXTERNAL_IP=127.0.0.1 TURN_EXTERNAL_IP=127.0.0.1
# TURN over TLS (5349) — единственный переключатель во всём проекте: пусто =
# TLS выключен везде (dev-дефолт, как ниже), непустое значение = coturn
# слушает TLS на 5349 (сертификат смонтирован из /etc/letsencrypt через
# coturn-certs-init, docker-compose.yml) И LiveKit объявляет клиентам запись
# protocol: tls. ⚠️ Обязан быть ДОМЕНОМ сертификата (например, vidconf.ru —
# тем же, что и NGINX_CERT_NAME), а НЕ IP-адресом, в отличие от
# TURN_EXTERNAL_IP выше: браузер проверяет TLS-сертификат TURN-сервера по
# имени хоста, а Let's Encrypt выписывает сертификат на домен.
TURN_TLS_HOST=
# --- Nginx: TLS (443) + список доменов — deploy/nginx/nginx.conf.template --- # --- Nginx: TLS (443) + список доменов — deploy/nginx/nginx.conf.template ---
# Домены, которые обслуживает nginx (через пробел, все — в server_name). # Домены, которые обслуживает nginx (через пробел, все — в server_name).
NGINX_SERVER_NAMES=example.com www.example.com NGINX_SERVER_NAMES=example.com www.example.com
@@ -112,7 +122,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) --- # --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой # install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно. # установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.21 VIDCONF_VERSION=0.0.26
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного # --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг, # `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,

View File

@@ -3,6 +3,144 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/). проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [0.0.26] — 2026-08-04
Мобильная комната: свайп шторки настроек и полноэкранный режим.
### Исправлено
- Шторка «Настройки» на мобильном не закрывалась свайпом вниз почти никогда:
обработчики висели только на ручке-волоске (`.room-sheet-handle`,
40×4px) — палец в неё практически невозможно попасть, и палец почти
всегда приземлялся на панель, где обработчиков не было вовсе. Теперь
свайп закрывает шторку при жесте по любому месту панели, но только
когда её содержимое проскроллено в самый верх (`scrollTop === 0` на
начало жеста) — иначе свайп вниз листает список устройств, как и должен
стандартный bottom sheet.
### Добавлено
- Полноэкранный режим комнаты — на мобильном кнопка «Экран» в тулбаре (как
на десктопе), при активации топбар и тулбар уходят из потока и лежат
оверлеем поверх сцены: показываются по тапу/клику по сцене вне элементов
управления и сами прячутся через несколько секунд бездействия. На
десктопе — та же логика (топбар/тулбар тоже прячутся в полноэкранном
режиме), дополнительный способ вернуть их — навести мышь в нижнюю полосу
экрана.
### Изменено
- Кнопка настроек устройств в тулбаре на мобильном подписана «Настройки»
вместо «Устройства».
- Раскладка кнопок мобильного тулбара, когда они не помещаются в один ряд
(7 и больше — с полноэкранным режимом и «Очередью» у организатора это
обычный случай), стала равномерной сеткой на 4 колонки (7 → 4+3,
8 → 4+4) вместо переноса «как получится» через `flex-wrap`.
- `deploy/render-templates.sh` умеет читать значения из файла, заданного
переменной `ENV_FILE`, а не только из корневого `.env` (см. коммит
`3847476`, вошёл в этот релиз) — для локальных стендов, где корневой
`.env` указывает на боевые адреса.
## [0.0.25] — 2026-08-03
Два дефекта мини-окна конференции (Document PiP), оба видны только в Chrome.
### Исправлено
- В Chrome пропадал звук других участников после возврата сцены из
мини-окна в основное. `RoomAudioRenderer` жил внутри `RoomStage` и
рендерился в обеих её ветках, поэтому скрытые `<audio>` с чужими
аудиотреками физически переезжали в документ PiP-окна и обратно.
После такого переезда Chrome теряет аудиовыход у remote-трека: пакеты
продолжают приходить (`packetsReceived` растёт), а
`totalSamplesDuration` и `totalAudioEnergy` замирают, и трек молчит
даже в свежесозданном `<audio>` со свежим `MediaStream`. Тот же цикл
detach/attach в пределах одного документа безвреден — дело именно в
переезде между документами. Рендерер вынесен в `RoomPage`: один
экземпляр, всегда в основном документе, в PiP-окне аудиоэлементов нет
вовсе. В Safari бага не было — там Document PiP не используется
(video-фолбэк), сцена из основного окна не уезжает.
- В мини-окне не действовало закрепление участника, а демонстрация
экрана слетала на говорящего от любой чужой реплики. Мини-плеер
намеренно ходил с упрощёнными правилами выбора фокуса (без
`holdScreenShare`, без приоритета говорящего с камерой, без
антидребезга) и с собственным локальным состоянием закрепления.
Теперь `pickStageFocus` получает одинаковые правила в обоих вариантах
сцены, а закрепление переезжает между окнами тем же мостиком через
`RoomPage`, что и фокус. Сознательно оставлено одно отличие —
фолбэк «показать себя» (`localKey`) только у мини-плеера: это защита
от дефекта 0.0.11, когда мини-окно открывалось на самом пользователе.
- Закрепление сбрасывалось при каждом монтировании сцены: на первом
рендере `useTracks` отдаёт пустой набор треков, и правило «закреплённый
вышел из комнаты» принимало это за уход участника.
### Изменено
- Кнопка-булавка на плитке подписана «Закрепить» вместо «Закрепить в
основном окне» — закрепление больше не ограничено основным окном.
## [0.0.24] — 2026-08-03
Три артефакта вёрстки, вылезающие за границы блоков (эмодзи-поповер чата,
заголовок брендовой панели, чат в Firefox) + аудит похожих мест.
### Исправлено
- Заголовок «Ваша инфраструктура» на странице входа вылезал за край
брендовой панели на широком окне с узкой панелью (`.layout` — flex
42/58) — старый фикс уменьшал кегль только по ширине ОКНА (`@media`),
а не панели. Кегль `.brand-headline` теперь считается через container
query (`container-type: inline-size` + `cqw`) — тот же приём, что у
аватара участника в комнате. Тем же механизмом обрезались плашки
статистики («AI-саммари») — `.brand-stats` теперь переносит их на
мобильном и десктопе одинаково.
- Эмодзи-поповер в чате комнаты: последняя (5-я) колонка вылезала за
правый край поповера. Причина — гонка CSS-специфичности: правило
круглой кнопки «Отправить» (`.chat-input-row button`, 42×42px)
продолжало красить размер и кнопкам эмодзи внутри поповера (та же
гонка чинилась для цвета в 0.0.19, но не для размера). Добавлены явные
`width`/`height: auto` нужной специфичности + `max-width` на попап как
общая страховка.
- Чат комнаты вылезал за границы панели в Firefox: `.chat-messages`
(flex-колонка) без `min-height: 0` не сжималась в Firefox, `textarea`
поля ввода без `min-width: 0` упиралась в автоматическую минимальную
ширину (Firefox считает её от атрибута `cols`, жёстче Chrome/Safari).
## [0.0.23] — 2026-08-02
Документация по сайзингу под медиа-нагрузку + видимость TURN-аллокаций в логах.
### Добавлено
- `docs/deploy/hardware-sizing.md` — таблица «профиль нагрузки → CPU/RAM/
полоса» для медиа (видеоконференции), на реальных боевых замерах
28.07 и 31.07.2026, с формулой для расчёта под свой сценарий.
### Исправлено
- coturn: включено verbose-логирование — дефолтный уровень не писал
построчно `ALLOCATE`/`CreatePermission`/`Refresh` даже при рабочем
relay-соединении (найдено на релизе 0.0.22 — звонок через TURN работал,
а `grep -ci allocate` по логам coturn показывал 0).
## [0.0.22] — 2026-08-02
TURN over TLS (5349) — для клиентов из сетей, где наружу открыт только 443.
### Добавлено
- coturn слушает TLS на `5349` (сертификат Let's Encrypt, обновляется
автоматически). Включается одним ключом `TURN_TLS_HOST` в `.env` (домен
сертификата, НЕ IP) — пусто оставляет прежнее поведение без единого следа
в рендеренных конфигах.
- LiveKit объявляет клиентам запись `protocol: tls` в `rtc.turn_servers`
(последней в списке, как самый дорогой фолбэк) — без анонса включённый
TLS был бы бесполезен: именно так уже случалось с обычным TURN до 0.0.14
(сервис работал healthy, но не обслужил ни одной аллокации).
- Новый init-контейнер `coturn-certs-init`: копирует fullchain/privkey из
`/etc/letsencrypt` в отдельный volume под правами `644` — coturn
(`nobody:nogroup`, без root-фазы в entrypoint) не может прочитать
оригинальный ключ (`root`, `0600`), а права на хосте ослаблять нельзя.
- Deploy-hook certbot дополнительно перекопирует сертификат и перезапускает
`coturn` при продлении — без этого TLS-TURN тихо остановился бы
обслуживать новые TLS-хендшейки примерно через 60 дней.
### Не сделано
- TURN over `443` — недостижимо без SNI-мультиплексора: порт уже занят
nginx (Docker port-publish), а coturn слушает в `network_mode: host` и не
может разделить с ним один и тот же сокет.
## [0.0.21] — 2026-08-02 ## [0.0.21] — 2026-08-02
Рычаги нагрузки медиа в админке: потолок качества публикации и лимит плиток. Рычаги нагрузки медиа в админке: потолок качества публикации и лимит плиток.

View File

@@ -264,6 +264,8 @@ VidConf использует единый инсталлятор `install.sh` с
``` ```
Требования к оборудованию и полное описание см. в [docs/deploy/install.md](docs/deploy/install.md) и [docs/architecture/adr/004-ai-tier-matrix.md](docs/architecture/adr/004-ai-tier-matrix.md). Требования к оборудованию и полное описание см. в [docs/deploy/install.md](docs/deploy/install.md) и [docs/architecture/adr/004-ai-tier-matrix.md](docs/architecture/adr/004-ai-tier-matrix.md).
Это требования под AI; сколько CPU/RAM/полосы нужно под саму видео-нагрузку (профиль
«сколько человек и камер») — [docs/deploy/hardware-sizing.md](docs/deploy/hardware-sizing.md).
### Docker Compose профили (низкоуровневый контроль) ### Docker Compose профили (низкоуровневый контроль)

View File

@@ -1 +1 @@
0.0.21 0.0.26

View File

@@ -12,8 +12,8 @@
# затрутся при следующем рендере. # затрутся при следующем рендере.
listening-port=3478 listening-port=3478
# Установить 5349 в проде для TURN over TLS, если будут смонтированы # TLS-порт объявлен всегда; реально слушать TLS coturn начинает только когда
# реальные TLS-сертификаты (см. закомментированные cert/pkey ниже). # заданы cert/pkey ниже (блок TLS-CERT) — без них строка ничего не включает.
tls-listening-port=5349 tls-listening-port=5349
# Диапазон relay-портов для TURN-аллокаций. # Диапазон relay-портов для TURN-аллокаций.
@@ -31,14 +31,30 @@ fingerprint
# Без CLI/telnet admin-интерфейса в этой поставке. # Без CLI/telnet admin-интерфейса в этой поставке.
no-cli no-cli
# Раскомментировать и смонтировать реальные сертификаты, чтобы включить # Блок ниже рендерится ТОЛЬКО когда в .env задан TURN_TLS_HOST (см.
# TURN over TLS на 443: # render-templates.sh) — тогда coturn-certs-init (docker-compose.yml) уже
# cert=/etc/coturn/certs/cert.pem # скопировал fullchain/privkey из /etc/letsencrypt в volume coturn-certs.
# pkey=/etc/coturn/certs/key.pem # Без TURN_TLS_HOST маркеры и всё, что между ними, вырезаются целиком —
# в файле не остаётся ни следа cert/pkey, а не просто закомментированных строк.
# BEGIN-TLS-CERT
cert=/etc/coturn/certs/cert.pem
pkey=/etc/coturn/certs/key.pem
# END-TLS-CERT
log-file=stdout log-file=stdout
simple-log simple-log
# Умеренная verbose-логика (`-v`/`verbose` в терминах coturn, НЕ
# `Verbose`/`-V` — тот режим сам coturn документирует как "very annoying",
# построчный дамп пакетов). Без этого флага дефолтный уровень логов не
# печатает ни строки на ALLOCATE/CreatePermission/Refresh, даже когда TURN
# реально обслуживает relay — проверено на релизе 0.0.22 (`docker logs
# vidconf-coturn-1 | grep -ci allocate` = 0 при живом рабочем звонке,
# подтверждение пришлось брать из логов LiveKit). С этим флагом coturn
# печатает по сессии: create/delete allocation, create permission, refresh —
# достаточно, чтобы дальше проверять относительно скромный вывод.
verbose
# Внешний IP сервера — обязателен для клиентов вне docker-сети # Внешний IP сервера — обязателен для клиентов вне docker-сети
# (network_mode: host здесь не даёт coturn определить публичный IP # (network_mode: host здесь не даёт coturn определить публичный IP
# автоматически). Для локальной разработки (без внешних участников) # автоматически). Для локальной разработки (без внешних участников)

View File

@@ -89,7 +89,7 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media} MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение # Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health. # из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.21} VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.26}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан # Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение, # на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул # проверьте бюджет соединений с БД: каждый воркер держит свой пул
@@ -432,6 +432,49 @@ services:
profiles: ["media"] profiles: ["media"]
logging: *default-logging logging: *default-logging
# Образ coturn/coturn — Dockerfile прописывает `USER nobody:nogroup`, и это
# НЕ runtime-привилегия, которую можно сбросить: Docker exec'ает entrypoint
# сразу от этого uid, root-фазы внутри контейнера нет вовсе (в отличие от
# официального образа nginx, который стартует entrypoint от root и только
# nginx-воркеры позже понижают права по директиве в конфиге — см.
# deploy/nginx/docker-entrypoint-certs.sh). Значит coturn физически не может
# сам прочитать приватный ключ Let's Encrypt (root:root, обычно 0600) —
# никакой volume-опцией это не обойти, не ослабляя права на ключ на хосте.
#
# Решение — по образцу уже существующего `recordings-init`/`llm-models-init`
# в этом файле: отдельный init-контейнер (busybox, дефолтный root) читает
# /etc/letsencrypt (той же ro-монтировкой, что и у nginx) и копирует
# fullchain/privkey в СВОЙ volume под правами 644 — это копия, а не
# оригинал, оригинальный ключ на хосте прав не меняет. Копия достаточно
# открыта, чтобы её прочитал nobody:nogroup внутри coturn.
#
# Если /etc/letsencrypt/live/<домен> не существует (dev, нет реальных
# сертификатов) — команда ниже просто ничего не копирует и завершается
# успешно; coturn стартует как раньше, без TLS (см. TURN_TLS_HOST в
# render-templates.sh — вторая половина того же переключателя).
coturn-certs-init:
image: busybox:1.36
command: >
sh -c '
SRC="/etc/letsencrypt/live/$$NGINX_CERT_NAME";
if [ -f "$$SRC/fullchain.pem" ] && [ -f "$$SRC/privkey.pem" ]; then
cp "$$SRC/fullchain.pem" /certs/cert.pem;
cp "$$SRC/privkey.pem" /certs/key.pem;
chmod 644 /certs/cert.pem /certs/key.pem;
echo "[coturn-certs-init] сертификат $$SRC скопирован в volume coturn-certs";
else
echo "[coturn-certs-init] $$SRC не найден — TLS для coturn не настроен (норма для dev без TURN_TLS_HOST)";
fi
'
environment:
NGINX_CERT_NAME: ${NGINX_CERT_NAME:?NGINX_CERT_NAME не задан в .env}
volumes:
- /etc/letsencrypt:/etc/letsencrypt:ro
- coturn-certs:/certs
restart: "no"
profiles: ["media"]
logging: *default-logging
coturn: coturn:
image: coturn/coturn:latest image: coturn/coturn:latest
restart: unless-stopped restart: unless-stopped
@@ -444,6 +487,10 @@ services:
# (deploy/render-templates.sh, вызывается install.sh). # (deploy/render-templates.sh, вызывается install.sh).
volumes: volumes:
- ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro - ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
- coturn-certs:/etc/coturn/certs:ro
depends_on:
coturn-certs-init:
condition: service_completed_successfully
network_mode: host network_mode: host
# Образ coturn/coturn — минимальный (debian-slim), в нём нет pgrep/ps/nc/ # Образ coturn/coturn — минимальный (debian-slim), в нём нет pgrep/ps/nc/
# curl/wget, поэтому проверка процесса по имени не работает # curl/wget, поэтому проверка процесса по имени не работает
@@ -887,6 +934,11 @@ volumes:
# Загруженные пользователями файлы (аватары) — общий том между # Загруженные пользователями файлы (аватары) — общий том между
# backend (запись при загрузке) и nginx (раздача статики, `location /media/`). # backend (запись при загрузке) и nginx (раздача статики, `location /media/`).
media: media:
# Копия fullchain/privkey Let's Encrypt под правами 644 для coturn
# (nobody:nogroup) — источник в /etc/letsencrypt не трогаем, см.
# coturn-certs-init выше. Обновляется при каждом перезапуске
# coturn-certs-init (deploy-hook certbot делает это при продлении).
coturn-certs:
# Метрики Prometheus (профиль `monitoring`) — переживают пересоздание контейнера. # Метрики Prometheus (профиль `monitoring`) — переживают пересоздание контейнера.
prometheus_data: prometheus_data:
# Дашборды/настройки Grafana (профиль `monitoring`) — переживают пересоздание. # Дашборды/настройки Grafana (профиль `monitoring`) — переживают пересоздание.

View File

@@ -54,10 +54,24 @@ rtc:
# оба рендерятся из одного TURN_STATIC_AUTH_SECRET (deploy/render-templates.sh). # оба рендерятся из одного TURN_STATIC_AUTH_SECRET (deploy/render-templates.sh).
# Логин/пароль LiveKit генерирует сам по механизму TURN REST API. # Логин/пароль LiveKit генерирует сам по механизму TURN REST API.
# #
# UDP и TCP на 3478 — оба порта уже открыты в ufw. TLS (5349) намеренно не # UDP и TCP на 3478 — оба порта уже открыты в ufw.
# объявляем: в turnserver.conf сертификаты не смонтированы, и анонс #
# неработающего `turns:` заставил бы клиента впустую ждать таймаута, # TLS (5349) объявляется ТОЛЬКО когда в .env задан TURN_TLS_HOST (см.
# прежде чем перейти к рабочему кандидату. # render-templates.sh) — до тех пор блок между маркерами вырезается
# целиком, и клиент его не увидит вовсе. Это осознанно: анонс
# неработающего `turns:` (без смонтированных в coturn сертификатов)
# заставил бы клиента впустую ждать TLS-таймаута, прежде чем перейти
# к рабочему кандидату — именно так это и стояло здесь до включения TLS.
#
# Хост для TLS-записи обязан быть ДОМЕНОМ, а не IP (в отличие от udp/tcp
# выше): браузер проверяет TLS-сертификат TURN-сервера по имени хоста,
# а сертификат Let's Encrypt выписан на домен, не на IP — с IP в host
# TLS-хендшейк упадёт на проверке имени, и это будет выглядеть как ещё
# один вариант «coturn healthy, но relay не работает».
#
# TLS-запись стоит ПОСЛЕДНЕЙ: клиент перебирает кандидатов по порядку,
# а TLS через TCP дороже прямого UDP — она должна быть фолбэком, а не
# выбираться первой.
turn_servers: turn_servers:
- host: ${TURN_EXTERNAL_IP} - host: ${TURN_EXTERNAL_IP}
port: 3478 port: 3478
@@ -69,6 +83,13 @@ rtc:
protocol: tcp protocol: tcp
secret: ${TURN_STATIC_AUTH_SECRET} secret: ${TURN_STATIC_AUTH_SECRET}
ttl: 14400 ttl: 14400
# BEGIN-TLS-TURN
- host: ${TURN_TLS_HOST}
port: 5349
protocol: tls
secret: ${TURN_STATIC_AUTH_SECRET}
ttl: 14400
# END-TLS-TURN
# Redis обязателен для сервиса egress (см. deploy/egress/) — он использует # Redis обязателен для сервиса egress (см. deploy/egress/) — он использует
# его как pub/sub и key-value хранилище состояния запущенных записей; # его как pub/sub и key-value хранилище состояния запущенных записей;

View File

@@ -9,27 +9,57 @@
# запускайте ПЕРЕД `docker compose up` (и после каждого изменения .env, # запускайте ПЕРЕД `docker compose up` (и после каждого изменения .env,
# влияющего на эти конфиги) — из корня репозитория: # влияющего на эти конфиги) — из корня репозитория:
# ./deploy/render-templates.sh # ./deploy/render-templates.sh
#
# Источник значений можно подменить переменной ENV_FILE:
# ENV_FILE=.env.local ./deploy/render-templates.sh
# Это нужно для локальных стендов: корневой `.env` рабочего чекаута обычно
# держит боевые значения (LIVEKIT_NODE_IP/TURN_EXTERNAL_IP смотрят на прод), и
# рендерить конфиги из него для локального запуска нельзя. Без этой переменной
# путь был жёстко зашит, и локальные сессии повторяли логику скрипта вручную
# через envsubst — расхождение с реальным рендером ждало своего часа.
# По умолчанию — как раньше, `<корень репозитория>/.env`, поэтому install.sh
# и сервер ничего не замечают (свою переменную ENV_FILE install.sh не
# экспортирует, так что она сюда не протекает).
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/../.env" # Относительный путь разрешаем от текущей директории вызова (`ENV_FILE=.env.local`
# из корня репозитория), абсолютный — как есть.
ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/../.env}"
if [ ! -f "$ENV_FILE" ]; then if [ ! -f "$ENV_FILE" ]; then
echo "render-templates.sh: не найден $ENV_FILE — сначала запустите ./install.sh или скопируйте .env.example в .env" >&2 echo "render-templates.sh: не найден $ENV_FILE — сначала запустите ./install.sh или скопируйте .env.example в .env" >&2
echo "render-templates.sh: другой файл значений можно задать так: ENV_FILE=.env.local $0" >&2
exit 1 exit 1
fi fi
echo "[render] источник значений: $ENV_FILE"
# Читаем только нужные ключи через grep/cut (НЕ `source .env`) — .env содержит # Читаем только нужные ключи через grep/cut (НЕ `source .env`) — .env содержит
# значения вроде `SMTP_FROM=VidConf <no-reply@vidconf.example>`, где `<` — # значения вроде `SMTP_FROM=VidConf <no-reply@vidconf.example>`, где `<` —
# валидный литерал для docker-compose/pydantic, но невалидный bash-синтаксис # валидный литерал для docker-compose/pydantic, но невалидный bash-синтаксис
# (интерпретируется как редирект) при попытке `source` файла целиком. # (интерпретируется как редирект) при попытке `source` файла целиком.
#
# `|| true` в конце обязателен: под `set -e -o pipefail` (см. выше) сборка
# `"$(env_var VAR)"` для ключа, которого в файле нет ВООБЩЕ (не просто
# пустое значение, а отсутствующая строка) иначе завершает весь скрипт
# ошибкой grep ДО того, как сработает дружелюбная проверка `:?` ниже —
# найдено на TURN_TLS_HOST (новый необязательный ключ, есть не во всех
# существующих .env).
env_var() { env_var() {
grep -E "^${1}=" "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- grep -E "^${1}=" "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || true
} }
TURN_STATIC_AUTH_SECRET="$(env_var TURN_STATIC_AUTH_SECRET)" TURN_STATIC_AUTH_SECRET="$(env_var TURN_STATIC_AUTH_SECRET)"
TURN_REALM="$(env_var TURN_REALM)" TURN_REALM="$(env_var TURN_REALM)"
TURN_EXTERNAL_IP="$(env_var TURN_EXTERNAL_IP)" TURN_EXTERNAL_IP="$(env_var TURN_EXTERNAL_IP)"
# TURN_TLS_HOST — единственный переключатель TURN over TLS (5349) во всём
# проекте: непустой = TLS смонтирован и объявляется клиентам, пустой = TLS
# отсутствует везде (dev по умолчанию). Поэтому НЕ обязателен (без `:?`) —
# в отличие от TURN_EXTERNAL_IP, который должен быть IP хоста, TURN_TLS_HOST
# обязан быть ДОМЕНОМ сертификата (иначе браузер не пройдёт TLS-валидацию
# по имени хоста для `turns:`, см. комментарий в livekit.yaml.template).
TURN_TLS_HOST="$(env_var TURN_TLS_HOST)"
LIVEKIT_API_KEY="$(env_var LIVEKIT_API_KEY)" LIVEKIT_API_KEY="$(env_var LIVEKIT_API_KEY)"
LIVEKIT_NODE_IP="$(env_var LIVEKIT_NODE_IP)" LIVEKIT_NODE_IP="$(env_var LIVEKIT_NODE_IP)"
LIVEKIT_USE_EXTERNAL_IP="$(env_var LIVEKIT_USE_EXTERNAL_IP)" LIVEKIT_USE_EXTERNAL_IP="$(env_var LIVEKIT_USE_EXTERNAL_IP)"
@@ -43,17 +73,31 @@ REDIS_PASSWORD="$(env_var REDIS_PASSWORD)"
: "${LIVEKIT_USE_EXTERNAL_IP:?LIVEKIT_USE_EXTERNAL_IP не задан в .env (true/false)}" : "${LIVEKIT_USE_EXTERNAL_IP:?LIVEKIT_USE_EXTERNAL_IP не задан в .env (true/false)}"
: "${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env (redis запускается с --requirepass, см. docker-compose.yml)}" : "${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env (redis запускается с --requirepass, см. docker-compose.yml)}"
export TURN_STATIC_AUTH_SECRET TURN_REALM TURN_EXTERNAL_IP LIVEKIT_API_KEY LIVEKIT_NODE_IP LIVEKIT_USE_EXTERNAL_IP REDIS_PASSWORD export TURN_STATIC_AUTH_SECRET TURN_REALM TURN_EXTERNAL_IP TURN_TLS_HOST LIVEKIT_API_KEY LIVEKIT_NODE_IP LIVEKIT_USE_EXTERNAL_IP REDIS_PASSWORD
# Вырезает блок между парой маркеров-комментариев (не только их самих), если
# TURN_TLS_HOST пуст — так рендер отражает реальное наличие TLS-сертификатов,
# а не просто закомментированные "на будущее" строки.
strip_tls_block_if_disabled() {
local file="$1" begin_marker="$2" end_marker="$3"
if [ -z "$TURN_TLS_HOST" ]; then
sed -i.bak "/${begin_marker}/,/${end_marker}/d" "$file" && rm -f "$file.bak"
else
sed -i.bak "/${begin_marker}/d; /${end_marker}/d" "$file" && rm -f "$file.bak"
fi
}
envsubst '${TURN_STATIC_AUTH_SECRET} ${TURN_REALM} ${TURN_EXTERNAL_IP}' \ envsubst '${TURN_STATIC_AUTH_SECRET} ${TURN_REALM} ${TURN_EXTERNAL_IP}' \
< "$SCRIPT_DIR/coturn/turnserver.conf.template" > "$SCRIPT_DIR/coturn/turnserver.conf" < "$SCRIPT_DIR/coturn/turnserver.conf.template" > "$SCRIPT_DIR/coturn/turnserver.conf"
echo "[render] deploy/coturn/turnserver.conf готов" strip_tls_block_if_disabled "$SCRIPT_DIR/coturn/turnserver.conf" "# BEGIN-TLS-CERT" "# END-TLS-CERT"
echo "[render] deploy/coturn/turnserver.conf готов$([ -n "$TURN_TLS_HOST" ] && echo " (TLS включён)" || echo " (TLS выключен — TURN_TLS_HOST пуст)")"
# TURN_EXTERNAL_IP и TURN_STATIC_AUTH_SECRET нужны и здесь: с 0.0.14 LiveKit # TURN_EXTERNAL_IP и TURN_STATIC_AUTH_SECRET нужны и здесь: с 0.0.14 LiveKit
# анонсирует клиентам внешний coturn (секция `rtc.turn_servers`), и секрет # анонсирует клиентам внешний coturn (секция `rtc.turn_servers`), и секрет
# обязан совпадать с `static-auth-secret` в turnserver.conf выше. # обязан совпадать с `static-auth-secret` в turnserver.conf выше.
envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD} ${TURN_EXTERNAL_IP} ${TURN_STATIC_AUTH_SECRET}' \ envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD} ${TURN_EXTERNAL_IP} ${TURN_STATIC_AUTH_SECRET} ${TURN_TLS_HOST}' \
< "$SCRIPT_DIR/livekit/livekit.yaml.template" > "$SCRIPT_DIR/livekit/livekit.yaml" < "$SCRIPT_DIR/livekit/livekit.yaml.template" > "$SCRIPT_DIR/livekit/livekit.yaml"
strip_tls_block_if_disabled "$SCRIPT_DIR/livekit/livekit.yaml" "# BEGIN-TLS-TURN" "# END-TLS-TURN"
echo "[render] deploy/livekit/livekit.yaml готов" echo "[render] deploy/livekit/livekit.yaml готов"
envsubst '${REDIS_PASSWORD}' \ envsubst '${REDIS_PASSWORD}' \

View File

@@ -191,6 +191,14 @@ chmod 600 .env # секреты внутри — только root может
собирает образы, поднимает `postgres`/`redis`, применяет миграции Alembic + собирает образы, поднимает `postgres`/`redis`, применяет миграции Alembic +
seed, поднимает остальной стек (`up -d --wait`). seed, поднимает остальной стек (`up -d --wait`).
По умолчанию `render-templates.sh` читает корневой `.env`. Другой файл
значений задаётся переменной окружения — это нужно, когда рядом с рабочим
`.env` (боевые адреса) поднимается локальный стенд:
```bash
ENV_FILE=.env.local ./deploy/render-templates.sh
```
**Не запускайте `docker compose` вручную без `--env-file .env`** — без него **Не запускайте `docker compose` вручную без `--env-file .env`** — без него
compose не подхватывает корневой `.env` (файл на уровень выше compose не подхватывает корневой `.env` (файл на уровень выше
`deploy/docker-compose.yml`) и подставляет небезопасные дефолты из самого `deploy/docker-compose.yml`) и подставляет небезопасные дефолты из самого
@@ -237,7 +245,22 @@ docker compose -f deploy/docker-compose.yml --env-file .env restart nginx
```bash ```bash
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'EOF' cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'EOF'
#!/bin/bash #!/bin/bash
# certbot renew копирует новый сертификат в /etc/letsencrypt/live/<домен>/,
# но и nginx, и coturn держат СВОИ копии (docker-entrypoint-certs.sh и
# coturn-certs-init в deploy/docker-compose.yml) — обе копируются только
# при СТАРТЕ/пересоздании соответствующего контейнера. Reload недостаточен
# ни для того, ни для другого — нужен restart.
docker restart vidconf-nginx-1 docker restart vidconf-nginx-1
# TURN over TLS (см. docs/deploy/DEPLOYMENT.md §8) — только если включён
# (TURN_TLS_HOST задан в .env). Без coturn-certs-init coturn продолжит
# держать в памяти старый сертификат ещё ~60 дней, до следующего продления,
# и TLS-хендшейк начнёт падать с ошибкой валидации сертификата у клиентов.
cd /opt/vidconf || exit 1
if grep -qE '^TURN_TLS_HOST=.+' .env; then
docker compose -f deploy/docker-compose.yml --env-file .env up -d coturn-certs-init
docker restart vidconf-coturn-1
fi
EOF EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
@@ -277,7 +300,8 @@ docker compose -f deploy/docker-compose.yml --env-file .env --profile monitoring
docker compose -f deploy/docker-compose.yml --env-file .env ps docker compose -f deploy/docker-compose.yml --env-file .env ps
# 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000, # 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000,
# плюс 3478 tcp+udp, если включили TURN — раздел 8) # плюс 3478 tcp+udp и 49160:49200/udp, если включили TURN, плюс 5349/tcp,
# если включили TURN over TLS — раздел 8)
ss -ltnp ss -ltnp
# 3. Redis требует пароль (НЕ должен пускать без него) # 3. Redis требует пароль (НЕ должен пускать без него)
@@ -358,15 +382,107 @@ state: connected`, но собеседник не видит видео/не с
`docker logs vidconf-coturn-1 --since 10m 2>&1 | grep -ci allocate`. `docker logs vidconf-coturn-1 --since 10m 2>&1 | grep -ci allocate`.
Ноль при живом звонке из-за NAT означает, что до coturn не дошли — Ноль при живом звонке из-за NAT означает, что до coturn не дошли —
смотрите ufw и `TURN_EXTERNAL_IP`. смотрите ufw и `TURN_EXTERNAL_IP`.
⚠️ **Эта проверка работает только с `verbose` в
`deploy/coturn/turnserver.conf.template`** (включён по умолчанию). Без
этого флага coturn пишет в лог только служебные строки старта
(листенеры, `Total auth threads`) и НИКОГДА не логирует
ALLOCATE/CreatePermission/Refresh — `grep -ci allocate` даёт `0` даже
когда relay реально обслуживает звонок. Это не гипотеза: на релизе
0.0.22 именно так и обнаружили — рабочий relay-звонок (LiveKit
`connectionType: turn`, реальные relay-кандидаты в `49160-49200`) при
дефолтном `simple-log` без `verbose` дал `grep -ci allocate` = `0`,
подтверждение пришлось брать из логов LiveKit. Если ваш конфиг старее
и `verbose` в нём нет — добавьте флаг, перерендерите
(`./deploy/render-templates.sh`) и пересоздайте `coturn`, прежде чем
доверять этой проверке.
**TURN over TLS (порт 5349 или 443) — не настроен.** Это самый надёжный ### TURN over TLS (порт 5349)
фолбэк (проходит там, где режут UDP и нестандартные порты), но требует
смонтировать в coturn TLS-сертификат: раскомментировать `cert`/`pkey` в Самый надёжный фолбэк: в жёстких корпоративных сетях наружу часто разрешён
`deploy/coturn/turnserver.conf.template`, добавить volume с только `443/tcp`, и TLS-соединение на нестандартный порт (5349) выглядит для
`/etc/letsencrypt` (nginx его уже монтирует, coturn — нет), открыть порт и firewall как обычный HTTPS. UDP/TCP на 3478 такие сети режут целиком.
не забыть про перезапуск coturn при обновлении сертификата. Пока этого нет,
`turns:` намеренно не анонсируется: анонс неработающего адреса заставил бы **443 вместо 5349 невозможен без доп. усложнений**: `443/tcp` на хосте уже
клиента ждать таймаута перед переходом к рабочему кандидату. занят nginx (Docker port-publish биндит хостовый сокет), а coturn слушает в
`network_mode: host`оба не могут забрать один и тот же порт без
SNI-мультиплексора перед ними. Такой мультиплексор — отдельная, более
сложная система; в этом проекте её нет, и заводить её только ради 443 не
оправдано, пока 5349 проходит через те же firewall, что и 443.
**Включение — один флаг, `TURN_TLS_HOST` в `.env`:**
```bash
# Домен сертификата (НЕ IP — см. предупреждение ниже), тот же, что в
# NGINX_CERT_NAME:
TURN_TLS_HOST=vidconf.ru
```
Пусто (dev-дефолт) — TLS выключен полностью и без следов: `render-templates.sh`
вырезает cert/pkey из `turnserver.conf` и запись `protocol: tls` из
`rtc.turn_servers` в `livekit.yaml` (маркеры `BEGIN-TLS-*`/`END-TLS-*` в
`.template`-файлах). Непустое значение включает оба сразу — TLS без анонса
клиентам (и наоборот) не бывает, ровно один переключатель на всё.
⚠️ **`TURN_TLS_HOST` обязан быть ДОМЕНОМ, не IP** — в отличие от
`TURN_EXTERNAL_IP`, который остаётся IP-адресом для udp/tcp-записей. Браузер
проверяет TLS-сертификат TURN-сервера по имени хоста в `turns:`-URL, а
Let's Encrypt выписывает сертификат на домен. С IP в этом поле TLS-хендшейк
упадёт на проверке имени сертификата — внешне это будет выглядеть как ещё
один вариант «coturn healthy, а relay не работает», только на новом порту.
**Права на приватный ключ.** coturn (`nobody:nogroup` внутри контейнера, без
root-фазы в entrypoint — не то что у nginx, где `docker-entrypoint-certs.sh`
выполняется от root) физически не может прочитать
`/etc/letsencrypt/live/<домен>/privkey.pem` (root, обычно `0600`). Решение —
одноразовый init-контейнер `coturn-certs-init` (busybox, дефолтный root,
образец — уже существующий `recordings-init`/`llm-models-init` в этом же
compose-файле): копирует `fullchain.pem`/`privkey.pem` в свой volume
`coturn-certs` под правами `644`. Это копия, не оригинал — права на ключ на
хосте не меняются. coturn просто монтирует `coturn-certs:/etc/coturn/certs:ro`
и ждёт (`depends_on: condition: service_completed_successfully`), пока init
отработает.
**Обновление сертификата.** certbot продлевает Let's Encrypt раз в ~60 дней
и обновляет файлы в `/etc/letsencrypt`, но и nginx, и coturn держат свои
копии, которые перечитываются только при (пере)старте контейнера — reload
недостаточен ни для одного из них. Deploy-hook (см. шаг 5 выше,
`/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh`) после `nginx`
дополнительно пересоздаёт `coturn-certs-init` (перекопировать свежий
сертификат в volume) и перезапускает `coturn`. Без этого шага TLS-TURN
тихо остановится обслуживать новые TLS-хендшейки примерно через два
месяца — coturn будет держать в памяти сертификат, у которого истёк срок
действия, и клиенты начнут получать ошибку валидации сертификата при
попытке TLS-хендшейка.
⚠️ Перезапуск `coturn` (и hook, и ручной после включения TLS) может задеть
активные звонки, идущие через relay, — как и с `livekit` (см. выше),
выбирайте окно или закладывайтесь на автопродление certbot (раз в ~60 дней,
непредсказуемое время суток).
Порядок включения:
1. `TURN_TLS_HOST=<домен>` в `.env`.
2. `./deploy/render-templates.sh` (перерендерит `turnserver.conf` и
`livekit.yaml` с TLS-блоками).
3. Открыть `5349/tcp` в ufw (IPv4 и IPv6).
4. `docker compose … up -d --force-recreate coturn-certs-init coturn`
пересоздать (не просто restart: новый volume/depends_on).
5. Проверить, что TLS реально отвечает:
`openssl s_client -connect <домен>:5349 -servername <домен>` — должен
показать сертификат Let's Encrypt (`issuer=Let's Encrypt`), а не ошибку
соединения.
6. `docker compose … up -d --force-recreate livekit` — подхватить новую
запись `rtc.turn_servers`. ⚠️ Разрывает активные конференции.
7. Обновить deploy-hook certbot (см. шаг 5) и проверить
`certbot renew --dry-run`.
8. Провести звонок, принудительно загнав клиента в relay-режим (ICE
transport policy `relay` в браузере), и убедиться, что аллокации в
`docker logs vidconf-coturn-1` растут именно через TLS-соединение, а
обычный TURN на 3478 продолжает работать для остальных клиентов
(с `verbose`, см. предупреждение в п.4 выше, каждая аллокация видна
отдельной строкой `ALLOCATE processed, success` — можно отличить
TLS-сессию от обычной по времени и по тому, что порт входящего
соединения — 5349).
--- ---

View File

@@ -1,5 +1,9 @@
# Нагрузочное тестирование SFU (LiveKit): методика и ёмкость # Нагрузочное тестирование SFU (LiveKit): методика и ёмкость
> Цифры здесь — с dev-Mac (см. предупреждение ниже), для реальных прод-замеров
> и готовой таблицы «профиль нагрузки → железо» см.
> [hardware-sizing.md](hardware-sizing.md).
Оценивает, Оценивает,
сколько одновременных издателей аудио+видео и подписчиков выдерживает сколько одновременных издателей аудио+видео и подписчиков выдерживает
LiveKit SFU в текущей конфигурации compose (`deploy/livekit/livekit.yaml`), LiveKit SFU в текущей конфигурации compose (`deploy/livekit/livekit.yaml`),

View File

@@ -61,4 +61,6 @@ VidConf использует **5 пресетов инсталлятора** (н
- **[LLM Setup](llm-setup.md)** — ручная установка/скачивание моделей - **[LLM Setup](llm-setup.md)** — ручная установка/скачивание моделей
- **[Deploy: Мониторинг](monitoring.md)** — Prometheus/Grafana, алерты - **[Deploy: Мониторинг](monitoring.md)** — Prometheus/Grafana, алерты
- **[Deploy: Масштабирование](scaling.md)** — горизонтальное масштабирование - **[Deploy: Масштабирование](scaling.md)** — горизонтальное масштабирование
- **[Deploy: Ёмкость](capacity.md)** — калькулятор нагрузки и IOPS - **[Deploy: Ёмкость](capacity.md)** — калькулятор нагрузки и IOPS (синтетический тест)
- **[Deploy: Профиль нагрузки → железо](hardware-sizing.md)** — сайзинг под саму
видео-нагрузку (участники/камеры/полоса), на реальных замерах с прода

View File

@@ -0,0 +1,243 @@
# Профиль нагрузки → рекомендуемое железо (медиа)
Отвечает на вопрос «сколько CPU, RAM и полосы нужно под ожидаемую видео-нагрузку»
для тех, кто разворачивает VidConf у себя. Разговор именно про **медиа**
(конференции, LiveKit SFU) — сайзинг под AI (транскрибация/суммаризация) описан
отдельно: [install.md](install.md), [hardware-profiles.md](hardware-profiles.md),
[ADR-004](../architecture/adr/004-ai-tier-matrix.md). Базовый пресет инсталлятора
без AI (`4 vCPU / 8 ГБ RAM / 40 ГБ диска`) рассчитан именно на медиа-нагрузку —
этот документ объясняет, какую конференцию такое железо реально держит и когда
его уже мало.
От синтетического нагрузочного теста [capacity.md](capacity.md) (SFU на dev-Mac,
формула по CPU) этот документ отличается источником цифр: здесь — **измерения на
боевом сервере с реальными людьми**, не эмуляция.
⚠️ Все числа ниже — **ориентировочные**, при указанных допущениях. Реальная
нагрузка зависит от поведения людей: сколько включат камеру, будет ли демонстрация
экрана, как долго говорят несколько человек одновременно. Используйте таблицу как
отправную точку для выбора железа, не как гарантию.
## Точка привязки к реальности
Единственные цифры ниже, которые не расчёт, а прямое измерение на боевом сервере
(`4 CPU, 8 ГБ RAM, Ubuntu 24.04`, тот же узел, где сейчас работает `vidconf.ru`):
| Дата | Версия | Участников | Камер | Подписок на видео | Исходящий трафик LiveKit | CPU LiveKit |
|---|---|---|---|---|---|---|
| 28.07.2026 | 0.0.11, **до** `adaptiveStream` | 19 | 17 | ~306 | 140169 Мбит/с устойчиво (пик 240) | 1.62 ядра из 4 |
| 31.07.2026 | 0.0.13, **после** `adaptiveStream`+`dynacast` | 30 | 27 | 783 | **70.6 Мбит/с** | 1.83 ядра из 4 |
Второе измерение и есть якорь для формулы ниже — оно снято на конфигурации,
максимально близкой к дефолтной (пагинация сетки участников, `adaptiveStream`,
`dynacast`), без ручной настройки под тест.
**Почему не пиковые 240 Мбит/с.** Пик — кратковременный всплеск, а не режим, в
котором сервер работал устойчиво; 1.62 ядра CPU намерены именно под устойчивые
140169 Мбит/с. Если посчитать коэффициент «Мбит/с на ядро» по пиковому числу,
получится оптимистичнее примерно в полтора раза, чем в реальности — расчёт по
такому коэффициенту недооценит нужное железо.
**Что показывает разница двух строк.** Во второй нагрузка выше (участников ×1.6,
подписок ×2.6), а трафик почти вдвое **ниже**, при том что CPU почти не
изменился. Это эффект `adaptiveStream`: клиент подписывается на трек, но получает
битрейт под фактический размер плитки на экране, а невидимые (не помещающиеся на
текущую страницу сетки) треки почти не занимают полосы. Всё, что дальше в этом
документе, посчитано **для конфигурации с `adaptiveStream`** (релиз ≥0.0.13, в
проекте включён с этой версии по умолчанию) — без него числа нужно умножать в
разы, см. следующий раздел.
## Почему нельзя считать «все видят всех»
SFU (LiveKit) пересылает пакеты, а не микширует их. Наивная формула трафика —
`N × (N1) × битрейт` (каждый участник получает поток от каждого) — при 80
участниках даёт единицы **гигабит в секунду**: недостижимо на одном сервере и не
имеет отношения к тому, что видит пользователь на экране.
Реальная модель другая: клиент подписан не на всех, а на **видимые плитки**, и
получает под каждую подписку битрейт по фактическому размеру плитки — благодаря
пагинации сетки (с релиза 0.0.11) и `adaptiveStream`/`dynacast` (с 0.0.13).
Отсюда рабочая формула:
```
подписок на видео = камер × (участников 1)
```
Она подтверждена обоими измерениями выше **точно**: 17 × 18 = 306, 27 × 29 = 783.
Это формула для типичного «видят всех камер» размещения (сетка без ручного
скрытия участников) — при включённом лимите плиток (см. ниже) число подписок не
растёт дальше лимита, даже если камер больше.
**Насколько наивная формула хуже.** Гипотетически, если бы все 75 участников
большого собрания (профиль ниже) были источником видео и каждый получал полный
поток от каждого — `75 × 74 × 1.5 Мбит/с` (типичный битрейт публикации без
адаптации) — это **9.48 Гбит/с**: недостижимо ни на одном разумном сервере.
Модель выше на сопоставимом масштабе (профиль «Большое собрание», 15 камер из
75) даёт около 182 Мбит/с**в 50 с лишним раз меньше**. Разница — не оптимизация
в мелочах, а другая по порядку величины задача, и именно поэтому SFU вообще
годится для конференций на десятки участников.
Аудио в этой формуле — единицы процентов трафика: микрофоны обычно включены у
25 человек одновременно, замьюченный трек полосу не занимает, DTX/RED включены
по умолчанию. Дальше считаем аудио отдельным слагаемым, не путая с видео.
## Формула
```
1. подписок = камер × (участников 1)
(если включён лимит плиток в настройках инстанса — camер заменить на min(камер, лимит))
2. видео = подписок × битрейт_на_подписку
битрейт_на_подписку зависит от того, помещаются ли все плитки на одну страницу:
- крупная плитка / говорящий в фокусе (мало плиток на экране) → 450 кбит/с
- мелкая плитка сетки, все участники на одной странице → 150 кбит/с
- сетка с пагинацией (участников больше лимита плиток) → 100 кбит/с
(часть подписок физически не на экране — почти не потребляет полосы)
3. аудио = активных_микрофонов × участников × 40 кбит/с
4. исходящая полоса сервера = видео + аудио ← главный параметр, см. ниже
5. ядер CPU (LiveKit) ≈ max(2, ⌈исходящая_полоса_Мбит/с ÷ 90⌉)
+ 12 ядра на остальной стек (backend, БД, Redis, coturn, nginx, ОС)
6. RAM ≈ 4 ГБ база (без AI-профилей) — на этом масштабе RAM не была узким
местом ни на одном реальном или синтетическом тесте, планировать по CPU и полосе
```
**Откуда коэффициенты.**
- 450 / 150 кбит/с — измерение одного трека клиентом в крупной и мелкой плитке
(релиз 0.0.13), округлено вверх от 453 и 147 для запаса.
- 100 кбит/с — обратный расчёт по якорному замеру 31.07.2026:
70.6 Мбит/с ÷ 783 подписки ≈ 90 кбит/с в среднем, округлено вверх. Число ниже,
чем «мелкая плитка» (147), потому что в комнате на 30 участников часть из 783
подписок физически не помещалась на текущую страницу сетки — `adaptiveStream`
почти обнулил их битрейт, а среднее по всем подпискам это отражает.
- 90 Мбит/с на ядро — из 28.07.2026: 140169 Мбит/с устойчиво ÷ 1.62 ядра =
86104 Мбит/с/ядро, округлено вниз (консервативно, в пользу большего числа
ядер).
- Минимум 2 ядра и запас 12 ядра на остальной стек — эмпирический пол: на
обоих реальных замерах LiveKit не опускался ниже 1.6 ядра независимо от
трафика, а весь остальной стек (backend, Postgres, Redis, coturn, nginx) на
боевом сервере устойчиво укладывается в разницу между занятым LiveKit и 4
доступными ядрами.
⚠️ **Формула по полосе — не единственная граница.** Между двумя замерами
подписок стало в 2.6 раза больше, трафик упал вдвое, а CPU почти не изменился
(1.62 → 1.83 ядра) — то есть процессор тратится в первую очередь на
обработку пакетов/подписок, а не на байты. На сценариях с очень большим числом
мелких подписок (много участников, лимит плиток не выставлен) реальный CPU
может обогнать то, что предсказывает формула по полосе быстрее, чем ожидается
— держите эмпирический пол (2 ядра LiveKit минимум под любую активную
конференцию) и не полагайтесь только на деление на 90.
## Профили нагрузки
Все профили — при `adaptiveStream`+`dynacast` (по умолчанию с 0.0.13) и без
AI-профилей (`transcribe`/`llm`). Допущения по камерам/микрофонам — решение,
не измерение; подставьте свои, если знаете реальный сценарий.
| Профиль | Сценарий | Камер | Подписок | Полоса (видео+аудио) | CPU (LiveKit) | RAM | Узкое место |
|---|---|---|---|---|---|---|---|
| Малая команда | 10 параллельных созвонов по 5 чел., 60% с камерой, 2 микрофона в каждом | 3×10 | 12×10=120 | ~58 Мбит/с | 2 ядра | 4 ГБ | нет — запас большой |
| Совещание | 1 конференция × 25 чел., 70% с камерой, 4 микрофона | 18 | 432 | ~69 Мбит/с | 2 ядра | 4 ГБ | полоса — близко к нашему якорю (70.6 Мбит/с на 30 чел.) |
| Большое собрание | 1 конференция × 75 чел., 20% с камерой (камер меньше лимита плиток), 5 микрофонов | 15 | 1110 | ~182 Мбит/с | 3 ядра | 46 ГБ | полоса и её цена у хостера |
| Смешанная нагрузка | «Малая команда» + «Совещание» одновременно на одном сервере | — | — | ~127 Мбит/с | 2 ядра | 4 ГБ | суммируется линейно |
Малая команда и совещание укладываются в базовый пресет инсталлятора без AI
(`4 vCPU / 8 ГБ`) с большим запасом — это ровно тот масштаб, что подтверждён
якорным замером (30 чел./27 камер на этом же железе, CPU занят на 46%). Большое
собрание уже требует железа **больше** базового пресета — 3 ядра под сам
LiveKit плюс 12 под остальной стек означают, что 4 vCPU становятся тесными.
### Когда профиль недостижим — и как его спасти
«Большое собрание, все 75 человек с камерой» без ограничений: подписок
75 × 74 = 5550, полоса по коэффициенту пагинации (100 кбит/с) — уже **~555
Мбит/с** только видео, плюс аудио. Такой канал недостижим на типичном железе
и его аренде — это не вопрос выбора сервера мощнее, это упирается в канал
и его стоимость у хостера.
Спасает **лимит плиток на экране** (настройка инстанса «Максимум плиток на
странице», 25/16/9/4, с релиза 0.0.21): он ограничивает число подписок сверху
независимо от числа камер — `min(камер, лимит) × (участников 1)`. При лимите
16 и том же собрании: 16 × 74 = 1184 подписки × 150 кбит/с (все 16 видны
одновременно, без пагинации) ≈ **178 Мбит/с** только видео — втрое меньше, чем
без лимита, но всё ещё требует железа заметно больше базового пресета (3+ ядра
LiveKit по формуле). Лимит делает профиль реалистичным, не дешёвым. Тот же
эффект даёт «Потолок качества публикации» (720p/360p/180p, тот же релиз) —
режет битрейт публикации у источника, а не только у подписчика.
Если и это не помогает — речь уже не про один сервер, а про горизонтальное
масштабирование LiveKit-кластера, вне рамок этого документа.
## Входящая полоса и канал клиента — отдельная история
Таблица выше — **исходящая** полоса сервера (каждому подписчику отдельная
копия), она и есть главный параметр: растёт с числом участников и подписок.
**Входящая** полоса (от клиентов к серверу) на порядок меньше: она равна сумме
битрейтов публикуемых потоков — `камер × ~0.31.5 Мбит/с` (зависит от «Потолка
качества публикации», 0.0.21) — и **не** умножается на число зрителей. Для
собрания на 75 человек с 15 камерами это 4.522.5 Мбит/с входящих — заметно
меньше 182 Мбит/с исходящих, узким местом почти никогда не становится.
Отдельно — канал **самого клиента**, не сервера. Офис, откуда заходит половина
участников совещания, может упереться в свой исходящий/входящий канал раньше,
чем сервер упрётся в свой. Серверный сайзинг эту часть не решает — это забота
сетевой инфраструктуры на стороне участников.
## Оговорка про канал и его стоимость
На реалистичных профилях (кроме экстремальных, см. выше) узким местом
оказывается почти всегда не CPU — оба реальных замера показали комфортный
запас (1.61.83 ядра из 4) — а **исходящая полоса и её стоимость у хостера**.
Большинство тарифов VPS считают трафик либо лимитом с доплатой за перебор,
либо по 95-му перцентилю канала; при планировании крупных конференций
сверяйтесь с тарифом хостера на трафик/канал, а не только с числом ядер и
объёмом RAM.
## Как посчитать под свой сценарий
1. Оцените участников (N), долю с камерой, число одновременно активных
микрофонов.
2. `подписок = камер × (N 1)`; если планируете включить лимит плиток —
`min(камер, лимит) × (N 1)`.
3. Выберите битрейт на подписку по разделу «Формула» (450 / 150 / 100 кбит/с)
в зависимости от того, помещаются ли все камеры на одну страницу.
4. `видео = подписок × битрейт`, `аудио = микрофонов × N × 40 кбит/с`,
`полоса = видео + аудио`.
5. `ядер CPU ≈ max(2, ⌈полоса ÷ 90⌉) + 12` на остальной стек.
6. RAM — 4 ГБ база, не растёт заметно с этим масштабом участников (растёт с
выбранным уровнем AI, см. [ADR-004](../architecture/adr/004-ai-tier-matrix.md),
если он используется).
7. Сверьте полосу с тарифом хостера на трафик/канал — часто это упрётся раньше
железа.
## Что может измениться
⚠️ Коэффициент «90 Мбит/с на ядро» и оба якорных замера сняты **до** перевода
LiveKit с диапазона UDP-портов на `rtc.udp_port` (задача «Сеть LiveKit», релиз
0.0.20, задеплоено 02.08.2026) — до этой правки медиатрафик на хосте шёл через
процессы `docker-proxy` (userland-прокси Docker). Сама оптимизация меняет путь
пакетов на хосте, а не логику LiveKit, поэтому полоса из таблиц, скорее всего,
не изменится, а запас по CPU/сети хоста на практике может оказаться больше
указанного здесь. Новый нагрузочный тест с реальными участниками после этой
оптимизации пока не проводился — числа в этом документе консервативны и не
переоценивают требуемое железо, но при появлении нового замера на текущей
сети коэффициенты стоит пересчитать.
## Смотрите также
- [capacity.md](capacity.md) — синтетический нагрузочный тест SFU (`lk load-test`
на dev-Mac) и формула по CPU для верхней оценки ёмкости узла; используйте вместе
с этим документом, если нужна методика для собственного повторного теста.
- [install.md](install.md), [hardware-profiles.md](hardware-profiles.md),
[ADR-004](../architecture/adr/004-ai-tier-matrix.md) — сайзинг под AI
(транскрибация/суммаризация), отдельно от медиа.
- [monitoring.md](monitoring.md) — как снять реальные цифры со своего сервера
(CPU/RAM/сеть по контейнерам, Grafana).
- [DEPLOYMENT.md](DEPLOYMENT.md) — TURN, порты, что открыть в файрволе под
медиа-трафик.

View File

@@ -11,7 +11,7 @@ interface DeviceSettingsDialogProps extends StageViewProps {
onClose: () => void onClose: () => void
} }
/** Свайп ручки шторки вниз дальше этого порога (px) закрывает панель, меньше — она возвращается на место. */ /** Свайп панели вниз дальше этого порога (px) закрывает шторку, меньше — она возвращается на место. */
const SHEET_DISMISS_THRESHOLD_PX = 80 const SHEET_DISMISS_THRESHOLD_PX = 80
/** Человекочитаемая подпись пункта списка устройств — `label` пуст, пока нет разрешения на медиа. */ /** Человекочитаемая подпись пункта списка устройств — `label` пуст, пока нет разрешения на медиа. */
@@ -30,7 +30,13 @@ function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string):
* `deviceId` при следующем подключении. * `deviceId` при следующем подключении.
* *
* На мобильной ширине (`useIsCompactViewport`) рендерится шторкой снизу вместо * На мобильной ширине (`useIsCompactViewport`) рендерится шторкой снизу вместо
* модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз за ручку. * модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз по ЛЮБОМУ
* месту панели (не только за ручку-волосок `.room-sheet-handle` — та её не
* пережила бы: 40×4px, попасть пальцем почти нереально). Свайп срабатывает,
* только если содержимое панели проскроллено в самый верх (`scrollTop === 0`
* на touchstart, см. `dragEligibleRef`) — иначе палец должен листать список
* устройств, а не закрывать окно; решение фиксируется на весь жест, а не
* пересчитывается на каждый touchmove, как в любом стандартном bottom sheet.
* Десктоп не меняется. * Десктоп не меняется.
* *
* Там же, и только там, первой секцией идёт «Вид» (режим показа участников и * Там же, и только там, первой секцией идёт «Вид» (режим показа участников и
@@ -61,6 +67,12 @@ export function DeviceSettingsDialog({
useModalDismiss(onClose) useModalDismiss(onClose)
const dragStartYRef = useRef<number | null>(null) const dragStartYRef = useRef<number | null>(null)
// Жест начат, когда контент панели был проскроллен в самый верх — свайп по
// панели, у которой ещё есть что скроллить, должен листать содержимое, а не
// закрывать шторку. Решается ОДИН раз в touchstart и держится весь жест
// (даже если внутри него направление сменится) — так же, как в любом
// стандартном bottom sheet.
const dragEligibleRef = useRef(false)
const [dragOffset, setDragOffset] = useState(0) const [dragOffset, setDragOffset] = useState(0)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
@@ -92,18 +104,21 @@ export function DeviceSettingsDialog({
} }
} }
function handleHandleTouchStart(e: React.TouchEvent<HTMLDivElement>) { function handlePanelTouchStart(e: React.TouchEvent<HTMLDivElement>) {
dragEligibleRef.current = e.currentTarget.scrollTop === 0
if (!dragEligibleRef.current) return
dragStartYRef.current = e.touches[0].clientY dragStartYRef.current = e.touches[0].clientY
setIsDragging(true) setIsDragging(true)
} }
function handleHandleTouchMove(e: React.TouchEvent<HTMLDivElement>) { function handlePanelTouchMove(e: React.TouchEvent<HTMLDivElement>) {
if (dragStartYRef.current === null) return if (!dragEligibleRef.current || dragStartYRef.current === null) return
const delta = e.touches[0].clientY - dragStartYRef.current const delta = e.touches[0].clientY - dragStartYRef.current
if (delta > 0) setDragOffset(delta) if (delta > 0) setDragOffset(delta)
} }
function handleHandleTouchEnd() { function handlePanelTouchEnd() {
if (!dragEligibleRef.current) return
if (dragOffset > SHEET_DISMISS_THRESHOLD_PX) { if (dragOffset > SHEET_DISMISS_THRESHOLD_PX) {
onClose() onClose()
return return
@@ -124,20 +139,16 @@ export function DeviceSettingsDialog({
<div <div
className={isCompact ? 'room-sheet-panel' : 'room-modal-panel'} className={isCompact ? 'room-sheet-panel' : 'room-modal-panel'}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onTouchStart={isCompact ? handlePanelTouchStart : undefined}
onTouchMove={isCompact ? handlePanelTouchMove : undefined}
onTouchEnd={isCompact ? handlePanelTouchEnd : undefined}
style={ style={
isCompact && dragOffset isCompact && dragOffset
? { transform: `translateY(${dragOffset}px)`, transition: isDragging ? 'none' : undefined } ? { transform: `translateY(${dragOffset}px)`, transition: isDragging ? 'none' : undefined }
: undefined : undefined
} }
> >
{isCompact && ( {isCompact && <div className="room-sheet-handle" />}
<div
className="room-sheet-handle"
onTouchStart={handleHandleTouchStart}
onTouchMove={handleHandleTouchMove}
onTouchEnd={handleHandleTouchEnd}
/>
)}
<div className="room-modal-head"> <div className="room-modal-head">
<h2 id="device-settings-title">{isCompact ? 'Настройки' : 'Настройки устройств'}</h2> <h2 id="device-settings-title">{isCompact ? 'Настройки' : 'Настройки устройств'}</h2>

View File

@@ -126,8 +126,10 @@ function TileBody({
const showSharingChip = Boolean( const showSharingChip = Boolean(
onStopSharing && trackReference.source === Track.Source.ScreenShare && trackReference.participant.isLocal, onStopSharing && trackReference.source === Track.Source.ScreenShare && trackReference.participant.isLocal,
) )
// Кнопка закрепления — только там, где сцена умеет закрепление (основное // Кнопка закрепления — только там, где сцена её даёт (основное окно передаёт
// окно передаёт `onTogglePin`; в мини-плеере плитка одна, закреплять нечего). // `onTogglePin`; в мини-плеере своего тулбара нет и плитка одна, поэтому
// булавки там нет — само закрепление, сделанное в основном окне, с 0.0.25
// действует и в мини-плеере, см. `initialPinnedKey` в `RoomStage`).
// Ключ плитки берём из её собственного трека: в карусели/гриде плитки // Ключ плитки берём из её собственного трека: в карусели/гриде плитки
// рендерятся шаблоном без пропсов, снаружи «какая это плитка» не передать. // рендерятся шаблоном без пропсов, снаружи «какая это плитка» не передать.
const tileKey = stageTrackKey(trackReference) const tileKey = stageTrackKey(trackReference)
@@ -200,10 +202,8 @@ function TileBody({
type="button" type="button"
className={`room-pin-toggle${isPinned ? ' is-pinned' : ''}`} className={`room-pin-toggle${isPinned ? ' is-pinned' : ''}`}
aria-pressed={isPinned} aria-pressed={isPinned}
title={isPinned ? 'Открепить' : 'Закрепить в основном окне'} title={isPinned ? 'Открепить' : 'Закрепить'}
aria-label={ aria-label={isPinned ? `Открепить: ${displayName}` : `Закрепить: ${displayName}`}
isPinned ? `Открепить: ${displayName}` : `Закрепить в основном окне: ${displayName}`
}
onClick={(e) => { onClick={(e) => {
// Иначе клик долетит до самой плитки (`onParticipantClick` // Иначе клик долетит до самой плитки (`onParticipantClick`
// у `ParticipantTile`) — булавка не должна означать «клик по плитке». // у `ParticipantTile`) — булавка не должна означать «клик по плитке».

View File

@@ -4,7 +4,6 @@ import { Track, type Participant } from 'livekit-client'
import { import {
CarouselLayout, CarouselLayout,
FocusLayoutContainer, FocusLayoutContainer,
RoomAudioRenderer,
isTrackReference, isTrackReference,
useRoomContext, useRoomContext,
useSpeakingParticipants, useSpeakingParticipants,
@@ -44,9 +43,11 @@ const STAGE_TRACK_SOURCES = [
] ]
/** /**
* Удержание фокуса основного окна при смене говорящего, мс. * Удержание фокуса при смене говорящего, мс. Действует в ОБОИХ вариантах
* сцены — и в основном окне, и в мини-плеере (до 0.0.25 в PiP удержания не
* было вовсе, фокус там переключался мгновенно).
* *
* Основное окно следует за спикером (`followSpeaker`, задача 3.2), и без * Сцена следует за спикером (`followSpeaker`, задача 3.2), и без
* удержания короткие реплики («ага», «угу») уводили бы большую плитку на * удержания короткие реплики («ага», «угу») уводили бы большую плитку на
* секунду и возвращали обратно. Источник говорящих (`useSpeakingParticipants` * секунду и возвращали обратно. Источник говорящих (`useSpeakingParticipants`
* поверх `RoomEvent.ActiveSpeakersChanged`) сам по себе не дребезжит, но * поверх `RoomEvent.ActiveSpeakersChanged`) сам по себе не дребезжит, но
@@ -59,6 +60,10 @@ const STAGE_TRACK_SOURCES = [
* фокус с задержкой, которая на глаз читается как плавность, а не как тормоз. * фокус с задержкой, которая на глаз читается как плавность, а не как тормоз.
* Меньше (~0.6 с) — короткие «ага» всё ещё пролезают, больше (~2 с) — заметно * Меньше (~0.6 с) — короткие «ага» всё ещё пролезают, больше (~2 с) — заметно
* запаздывает переход на нового докладчика. * запаздывает переход на нового докладчика.
*
* В мини-плеере удержание тем более уместно: там плитка ОДНА, и мгновенное
* переключение читается не как «камера следует за разговором», а как мигание
* всего окна целиком.
*/ */
const SPEAKER_HOLD_MS = 1200 const SPEAKER_HOLD_MS = 1200
@@ -69,8 +74,9 @@ const SPEAKER_HOLD_MS = 1200
* применять уже нечего (cleanup эффекта гасит таймер, а новое значение * применять уже нечего (cleanup эффекта гасит таймер, а новое значение
* сравнивается по ссылке с текущим). * сравнивается по ссылке с текущим).
* *
* `holdMs <= 0` — удержания нет, значение отдаётся как есть (режим PiP: там * `holdMs <= 0` — удержания нет, значение отдаётся как есть. Сейчас этим
* фокус обязан следовать за говорящим мгновенно, поведение не менялось). * режимом никто не пользуется (обе сцены удерживают состав), но параметр
* оставлен: он и делает функцию пригодной для повторного использования.
*/ */
function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant[] { function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant[] {
const [steady, setSteady] = useState(speakers) const [steady, setSteady] = useState(speakers)
@@ -148,9 +154,9 @@ function PipMicToggle() {
* `tiles` скрывать нечего (карусели нет), переключатель там заблокирован — * `tiles` скрывать нечего (карусели нет), переключатель там заблокирован —
* см. `StageViewOptions`. * см. `StageViewOptions`.
* *
* ФОКУС ПЕРЕЖИВАЕТ ПЕРЕЕЗД В МИНИ-ПЛЕЕР. Сцена в мини-плеере — ОТДЕЛЬНЫЙ * ФОКУС И ЗАКРЕПЛЕНИЕ ПЕРЕЖИВАЮТ ПЕРЕЕЗД В МИНИ-ПЛЕЕР. Сцена в мини-плеере —
* экземпляр этого компонента (портал в PiP-окно), и своё состояние фокуса он * ОТДЕЛЬНЫЙ экземпляр этого компонента (портал в PiP-окно), и своё состояние
* начинал с нуля: демонстрации нет, никто прямо сейчас не говорит — и * фокуса он начинал с нуля: демонстрации нет, никто прямо сейчас не говорит — и
* `pickStageFocus` доходил до последнего фолбэка `localKey`, то есть мини-окно * `pickStageFocus` доходил до последнего фолбэка `localKey`, то есть мини-окно
* открывалось на самом пользователе вместо того, что он видел крупно. В Safari * открывалось на самом пользователе вместо того, что он видел крупно. В Safari
* бага не было видно: там Document PiP не используется, а video-фолбэк * бага не было видно: там Document PiP не используется, а video-фолбэк
@@ -159,6 +165,13 @@ function PipMicToggle() {
* `RoomPage` → `initialFocusKey` следующего экземпляра. Работает в обе стороны * `RoomPage` → `initialFocusKey` следующего экземпляра. Работает в обе стороны
* — возврат из мини-плеера тоже не сбрасывает фокус. * — возврат из мини-плеера тоже не сбрасывает фокус.
* *
* Ровно тем же мостиком с 0.0.25 ездит и ЗАКРЕПЛЕНИЕ (`initialPinnedKey` /
* `onPinnedKeyChange`): раньше `pinnedKey` был чисто локальным `useState`, и
* закрепление, сделанное в основном окне, в мини-плеер не попадало вовсе.
* Отдельный «общий» источник правды здесь не нужен: экземпляр сцены в каждый
* момент ровно один (пока открыт Document PiP, основное окно показывает
* заглушку — см. `RoomPage`), поэтому состояние достаточно передать по эстафете.
*
* Раскладка — вертикальная колонка миниатюр слева от основной сцены (не * Раскладка — вертикальная колонка миниатюр слева от основной сцены (не
* горизонтальная лента, см. design/mockups/room.html после правки: узкая * горизонтальная лента, см. design/mockups/room.html после правки: узкая
* колонка сбоку, скролл по вертикали). Это штатное поведение самого * колонка сбоку, скролл по вертикали). Это штатное поведение самого
@@ -188,12 +201,21 @@ function PipMicToggle() {
* показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида; * показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида;
* режимы показа и скрытие остальных на мини-плеер не влияют вовсе. * режимы показа и скрытие остальных на мини-плеер не влияют вовсе.
* *
* Фокус следует за активным спикером в ОБОИХ вариантах (`followSpeaker` у * ВЫБОР ФОКУСА ОДИНАКОВ В ОБОИХ ВАРИАНТАХ (с 0.0.25). До этого мини-плеер был
* `pickStageFocus`; для основного окна — с 0.0.6, задача 3.2), но по-разному: * намеренно «упрощён»: без удержания говорящего, без удержания демонстрации
* PiP переключается мгновенно и всегда показывает говорящего, а основное окно * экрана (`holdScreenShare`), без приоритета говорящего с включённой камерой и
* ждёт `SPEAKER_HOLD_MS` (не дёргается на коротких репликах), не уводит из * без закрепления. На практике это читалось как поломка: в мини-окне
* фокуса живую демонстрацию экрана (`holdScreenShare`) и умеет закрепление * демонстрация экрана слетала от любой чужой реплики, а закрепление,
* участника (`pinnedKey`, задача 3.1) — кнопка-булавка на плитке. * сделанное в основном окне, не действовало. Теперь `pickStageFocus`
* получает одни и те же правила независимо от варианта — разным остаётся
* ровно одно: `localKey` (см. ниже) и то, что PiP рисует одну плитку вместо
* раскладки.
*
* Единственное сознательное отличие — `localKey`: у мини-плеера есть
* последний фолбэк «показать себя», у основного окна его нет (там фолбэк —
* первый трек по порядку, поведение не менялось). Строка из того же сюжета,
* что и `initialFocusKey`: без неё свежеоткрытое мини-окно на пустой комнате
* выбирало произвольного участника.
*/ */
export function RoomStage({ export function RoomStage({
variant = 'full', variant = 'full',
@@ -203,6 +225,8 @@ export function RoomStage({
onHideOthers, onHideOthers,
initialFocusKey = null, initialFocusKey = null,
onFocusKeyChange, onFocusKeyChange,
initialPinnedKey = null,
onPinnedKeyChange,
onPinFocus, onPinFocus,
raisedHandIdentities, raisedHandIdentities,
conferenceId, conferenceId,
@@ -221,6 +245,10 @@ export function RoomStage({
initialFocusKey?: string | null initialFocusKey?: string | null
/** Сообщать наружу текущий фокус, чтобы его пережил переезд сцены в мини-плеер и обратно. */ /** Сообщать наружу текущий фокус, чтобы его пережил переезд сцены в мини-плеер и обратно. */
onFocusKeyChange?: (key: string | null) => void onFocusKeyChange?: (key: string | null) => void
/** Чем инициализировать закрепление при монтировании — тот же мостик через `RoomPage`, что и у фокуса. */
initialPinnedKey?: string | null
/** Сообщать наружу закрепление, чтобы оно пережило переезд сцены в мини-плеер и обратно. */
onPinnedKeyChange?: (key: string | null) => void
/** /**
* Участника только что закрепили (не открепили) в режиме без крупной * Участника только что закрепили (не открепили) в режиме без крупной
* плитки — сцена сама переключиться не может (режим живёт в `RoomPage`), * плитки — сцена сама переключиться не может (режим живёт в `RoomPage`),
@@ -245,9 +273,9 @@ export function RoomStage({
// (`Room.activeSpeakers`, обновляются по `RoomEvent.ActiveSpeakersChanged`, // (`Room.activeSpeakers`, обновляются по `RoomEvent.ActiveSpeakersChanged`,
// событие шлётся лишь при РЕАЛЬНОЙ смене состава/порядка говорящих — не // событие шлётся лишь при РЕАЛЬНОЙ смене состава/порядка говорящих — не
// дребезжит на каждый чих, в отличие от сырого `participant.isSpeaking`). // дребезжит на каждый чих, в отличие от сырого `participant.isSpeaking`).
// Основное окно поверх этого ещё и удерживает состав (см. `useSteadySpeakers` // Поверх этого сцена ещё и удерживает состав (см. `useSteadySpeakers` и
// и `SPEAKER_HOLD_MS`), PiP берёт значение как есть. // `SPEAKER_HOLD_MS`) — в обоих вариантах одинаково.
const speakingParticipants = useSteadySpeakers(useSpeakingParticipants(), variant === 'pip' ? 0 : SPEAKER_HOLD_MS) const speakingParticipants = useSteadySpeakers(useSpeakingParticipants(), SPEAKER_HOLD_MS)
const cameraTracks = tracks.filter((t) => t.source === Track.Source.Camera) const cameraTracks = tracks.filter((t) => t.source === Track.Source.Camera)
const screenShareTracks = tracks.filter((t) => isTrackReference(t) && t.source === Track.Source.ScreenShare) const screenShareTracks = tracks.filter((t) => isTrackReference(t) && t.source === Track.Source.ScreenShare)
@@ -289,8 +317,10 @@ export function RoomStage({
const [focusKey, setFocusKey] = useState<string | null>(initialFocusKey) const [focusKey, setFocusKey] = useState<string | null>(initialFocusKey)
// Закрепление живёт в состоянии сцены (задача 3.1): ключ `identity:source` // Закрепление живёт в состоянии сцены (задача 3.1): ключ `identity:source`
// плитки, которую пользователь закрепил булавкой; `null` — закрепления нет. // плитки, которую пользователь закрепил булавкой; `null` — закрепления нет.
// Только для основного окна — в PiP плитка одна и закреплять нечего. // Стартовое значение приходит от предыдущего экземпляра сцены (тот же
const [pinnedKey, setPinnedKey] = useState<string | null>(null) // мостик через `RoomPage`, что и у фокуса), поэтому закрепление, сделанное
// в основном окне, действует и в мини-плеере.
const [pinnedKey, setPinnedKey] = useState<string | null>(initialPinnedKey)
const [prevPinnedKey, setPrevPinnedKey] = useState<string | null>(null) const [prevPinnedKey, setPrevPinnedKey] = useState<string | null>(null)
const cameraKeys = cameraTracks.map(stageTrackKey) const cameraKeys = cameraTracks.map(stageTrackKey)
@@ -299,13 +329,23 @@ export function RoomStage({
// камера есть у КАЖДОГО участника хотя бы плейсхолдером, — ни среди // камера есть у КАЖДОГО участника хотя бы плейсхолдером, — ни среди
// демонстраций) — закрепление снимаем, чтобы сцена не осталась в подвешенном // демонстраций) — закрепление снимаем, чтобы сцена не осталась в подвешенном
// состоянии и булавка не «висела» на исчезнувшем ключе. // состоянии и булавка не «висела» на исчезнувшем ключе.
//
// `tracksKnown` — обязательная охрана, а не перестраховка: на ПЕРВОМ рендере
// нового экземпляра сцены `useTracks` отдаёт ПУСТОЙ массив (реальный состав
// приезжает следующим рендером, из подписки на события комнаты). Без этой
// проверки пустой набор читается как «все вышли», и закрепление, приехавшее
// через `initialPinnedKey`, обнулялось сразу при монтировании — то есть
// мини-плеер терял его каждый раз (найдено живой отладкой при 0.0.25).
// Пустых наборов при живой комнате не бывает: камера есть у каждого
// участника хотя бы плейсхолдером.
const tracksKnown = cameraKeys.length > 0 || screenShareKeys.length > 0
const pinnedAlive = pinnedKey !== null && (cameraKeys.includes(pinnedKey) || screenShareKeys.includes(pinnedKey)) const pinnedAlive = pinnedKey !== null && (cameraKeys.includes(pinnedKey) || screenShareKeys.includes(pinnedKey))
const tracksChanged = tracks !== prevTracks const tracksChanged = tracks !== prevTracks
const speakingChanged = speakingParticipants !== prevSpeakingParticipants const speakingChanged = speakingParticipants !== prevSpeakingParticipants
const pinnedChanged = pinnedKey !== prevPinnedKey const pinnedChanged = pinnedKey !== prevPinnedKey
if (pinnedKey !== null && !pinnedAlive) { if (pinnedKey !== null && tracksKnown && !pinnedAlive) {
setPinnedKey(null) setPinnedKey(null)
} }
@@ -328,14 +368,12 @@ export function RoomStage({
cameraKeys, cameraKeys,
screenShareKeys, screenShareKeys,
speakingCameraKeys, speakingCameraKeys,
// Приоритет «говорящий с камерой выше говорящего без камеры» — только cameraKeysWithVideo: cameraTracks.filter(hasLiveVideo).map(stageTrackKey),
// основному окну: PiP по договорённости ведёт себя ровно как раньше.
cameraKeysWithVideo: variant === 'pip' ? [] : cameraTracks.filter(hasLiveVideo).map(stageTrackKey),
prevKeys, prevKeys,
prevFocusKey: focusKey, prevFocusKey: focusKey,
pinnedKey: pinnedAlive ? pinnedKey : null, pinnedKey: pinnedAlive ? pinnedKey : null,
followSpeaker: true, followSpeaker: true,
holdScreenShare: variant !== 'pip', holdScreenShare: true,
// Только для PiP — в основном окне фолбэк на «первый трек» не менялся. // Только для PiP — в основном окне фолбэк на «первый трек» не менялся.
localKey: variant === 'pip' ? `${room.localParticipant.identity}:${Track.Source.Camera}` : null, localKey: variant === 'pip' ? `${room.localParticipant.identity}:${Track.Source.Camera}` : null,
}) })
@@ -352,6 +390,11 @@ export function RoomStage({
onFocusKeyChange?.(focusKey) onFocusKeyChange?.(focusKey)
}, [focusKey, onFocusKeyChange]) }, [focusKey, onFocusKeyChange])
// То же самое для закрепления — см. `initialPinnedKey`.
useEffect(() => {
onPinnedKeyChange?.(pinnedKey)
}, [pinnedKey, onPinnedKeyChange])
const focusTrack = tracks.find((t) => stageTrackKey(t) === focusKey) ?? screenShareTracks[0] ?? cameraTracks[0] const focusTrack = tracks.find((t) => stageTrackKey(t) === focusKey) ?? screenShareTracks[0] ?? cameraTracks[0]
const focusTrackKey = focusTrack ? stageTrackKey(focusTrack) : null const focusTrackKey = focusTrack ? stageTrackKey(focusTrack) : null
// При активной демонстрации карусель — ВСЕ камеры (включая демонстратора) И // При активной демонстрации карусель — ВСЕ камеры (включая демонстратора) И
@@ -394,13 +437,16 @@ export function RoomStage({
// Мини-плеер показывает ТОЛЬКО активное окно — без карусели/ // Мини-плеер показывает ТОЛЬКО активное окно — без карусели/
// грида, одна плитка на весь контейнер (см. `.room-single-tile`, // грида, одна плитка на весь контейнер (см. `.room-single-tile`,
// `styles/room.css`). `focusTrack` уже вычислен выше тем же `pickStageFocus` // `styles/room.css`). `focusTrack` уже вычислен выше тем же `pickStageFocus`
// (с `followSpeaker: true` для этого варианта) — переиспользуем как есть. // и по тем же правилам, что и в основном окне (закрепление, удержание
// демонстрации, антидребезг говорящего) — переиспользуем как есть.
// Булавки на плитке здесь нет намеренно: своего тулбара у мини-окна нет,
// закрепление делается в основном окне и приезжает сюда через
// `initialPinnedKey`.
if (variant === 'pip') { if (variant === 'pip') {
return ( return (
<section className="stage room-single-tile"> <section className="stage room-single-tile">
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />} {focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
<PipMicToggle /> <PipMicToggle />
<RoomAudioRenderer />
</section> </section>
) )
} }
@@ -496,7 +542,6 @@ export function RoomStage({
<span>Показать остальных ({sideTracks.length})</span> <span>Показать остальных ({sideTracks.length})</span>
</button> </button>
)} )}
<RoomAudioRenderer />
</section> </section>
) )
} }

View File

@@ -17,6 +17,7 @@ import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react' import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider' import { useToast } from '@/components/ui/ToastProvider'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport' import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat' import type { HandQueueEntry } from '@/hooks/useChat'
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions' import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
import { HandQueueMenu } from '@/components/room/HandQueueMenu' import { HandQueueMenu } from '@/components/room/HandQueueMenu'
@@ -68,6 +69,8 @@ interface RoomToolbarProps extends StageViewProps {
onLowerHand: () => void onLowerHand: () => void
/** Опустить ЧУЖУЮ руку по identity — только организатору (панель очереди, `HandQueueMenu`). */ /** Опустить ЧУЖУЮ руку по identity — только организатору (панель очереди, `HandQueueMenu`). */
onLowerHandById: (identity: string) => void onLowerHandById: (identity: string) => void
/** Тулбар в оверлее полноэкранного режима — см. докстринг `RoomTopbar.overlayVisible`, тот же механизм. */
overlayVisible?: boolean
} }
/** /**
@@ -77,16 +80,20 @@ interface RoomToolbarProps extends StageViewProps {
* панели чата, стилизованные по design/mockups/room.html. * панели чата, стилизованные по design/mockups/room.html.
* *
* Кнопка «Вид» (режимы показа и скрытие остальных) рендерится ТОЛЬКО на * Кнопка «Вид» (режимы показа и скрытие остальных) рендерится ТОЛЬКО на
* широком экране — условным рендерингом, а не скрытием через CSS: тулбар на * широком экране — условным рендерингом, а не скрытием через CSS: демонстрация
* мобильном и так ужат до пяти «безусловных» кнопок (демонстрация/ * и мини-плеер скрыты на узком экране через CSS (см. `styles/room.css`), а те
* полноэкранный режим/мини-плеер скрыты на узком экране через CSS, см. * же настройки показа сцены доступны секцией «Вид» в шторке настроек
* `styles/room.css`), а те же настройки там доступны секцией «Вид» в шторке * (`DeviceSettingsDialog`). «Рука» — сознательное исключение из этой
* настроек (`DeviceSettingsDialog`). «Рука» — сознательное исключение из этой
* экономии: поднять руку посреди разговора — действие со временем жизни в * экономии: поднять руку посреди разговора — действие со временем жизни в
* секунды, прятать его в шторку настроек означало бы делать его практически * секунды, прятать его в шторку настроек означало бы делать его практически
* недоступным с телефона. «Очередь» показывается только организатору — * недоступным с телефона. «Очередь» показывается только организатору —
* встречается редко, но по той же причине оставлена в тулбаре, а не в * встречается редко, но по той же причине оставлена в тулбаре, а не в
* шторке: организатору с телефона тоже нужно видеть очередь сразу. * шторке: организатору с телефона тоже нужно видеть очередь сразу.
* Полноэкранный режим на мобильном ОСТАЁТСЯ в тулбаре (не спрятан в шторку,
* как демонстрация/мини-плеер) — по решению оператора вход в него должен
* быть по аналогии с десктопом; если из-за этого кнопки не помещаются в один
* ряд, `mobileButtonCount`/`.tb-wrap-grid` ниже раскладывают их равномерной
* сеткой, а не как получится через `flex-wrap`.
*/ */
export function RoomToolbar({ export function RoomToolbar({
chatVisible, chatVisible,
@@ -108,11 +115,29 @@ export function RoomToolbar({
onLayoutModeChange, onLayoutModeChange,
hideOthers, hideOthers,
onHideOthersChange, onHideOthersChange,
overlayVisible,
}: RoomToolbarProps) { }: RoomToolbarProps) {
const toast = useToast() const toast = useToast()
const isCompact = useIsCompactViewport() const isCompact = useIsCompactViewport()
const isOrganizer = useIsOrganizer()
const { localParticipant } = useLocalParticipant() const { localParticipant } = useLocalParticipant()
const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity) const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity)
// Сколько кнопок реально видно на мобильном (демонстрация/мини-окно там
// скрыты через CSS всегда, «Вид» не рендерится вовсе — см. докстринг) —
// считаем в JS, а не через CSS-селекторы вида `:nth-child`: у «Очереди»
// (только у организатора) в DOM есть своя обёртка `.tb-menu-wrap`, из-за
// которой позиция остальных кнопок «плывёт», и подсчёт по структуре DOM
// был бы хрупким. От порога зависит `.tb-wrap-grid` в room.css — ниже он
// переключает перенос на 2 строки с «как поместится» (flex-wrap) на
// равномерную сетку 4 колонки (7 → 4+3, 8 → 4+4).
const mobileButtonCount =
3 /* микрофон, камера, рука */ +
(isOrganizer ? 1 : 0) /* очередь */ +
1 /* настройки */ +
(fullscreenSupported ? 1 : 0) +
(chatVisible ? 1 : 0) +
1 /* выйти */
const mic = useTrackToggle({ source: Track.Source.Microphone }) const mic = useTrackToggle({ source: Track.Source.Microphone })
const camera = useTrackToggle({ source: Track.Source.Camera }) const camera = useTrackToggle({ source: Track.Source.Camera })
const screenShare = useTrackToggle({ const screenShare = useTrackToggle({
@@ -129,7 +154,9 @@ export function RoomToolbar({
}) })
return ( return (
<footer className="room-toolbar"> <footer
className={`room-toolbar${overlayVisible ? ' is-visible' : ''}${mobileButtonCount >= 7 ? ' tb-wrap-grid' : ''}`}
>
<button <button
type="button" type="button"
{...mic.buttonProps} {...mic.buttonProps}
@@ -214,7 +241,10 @@ export function RoomToolbar({
<span className="icon-shell"> <span className="icon-shell">
<Settings className="lucide" aria-hidden="true" /> <Settings className="lucide" aria-hidden="true" />
</span> </span>
<span className="label">Устройства</span> {/* На мобильном подпись шире по смыслу («Настройки»): там же в шторке
секция «Вид», а отдельной кнопки под неё в тулбаре нет. На десктопе
панель — только настройки устройств, подпись это отражает. */}
<span className="label">{isCompact ? 'Настройки' : 'Устройства'}</span>
</button> </button>
{fullscreenSupported && ( {fullscreenSupported && (

View File

@@ -15,6 +15,12 @@ interface RoomTopbarProps {
/** Slug/номер конференции из адреса — для инвайт-чипа (копирование ссылки). */ /** Slug/номер конференции из адреса — для инвайт-чипа (копирование ссылки). */
slug?: string slug?: string
number?: string number?: string
/**
* Полноэкранный режим (`RoomPage.tsx`) прячет топбар в оверлей и показывает
* его только по этому флагу — вне полноэкранного режима не влияет ни на что
* (CSS-правило само по себе действует лишь под `.room-fullscreen-overlay`).
*/
overlayVisible?: boolean
} }
/** /**
@@ -26,7 +32,7 @@ interface RoomTopbarProps {
* тёмных токенов темы `room` (см. `--color-room-tile*`), без новых * тёмных токенов темы `room` (см. `--color-room-tile*`), без новых
* цветов и форм. * цветов и форм.
*/ */
export function RoomTopbar({ title, slug, number }: RoomTopbarProps) { export function RoomTopbar({ title, slug, number, overlayVisible }: RoomTopbarProps) {
const participants = useParticipants() const participants = useParticipants()
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
@@ -44,7 +50,7 @@ export function RoomTopbar({ title, slug, number }: RoomTopbarProps) {
} }
return ( return (
<header className="room-topbar"> <header className={`room-topbar${overlayVisible ? ' is-visible' : ''}`}>
<div className="room-title-block"> <div className="room-title-block">
<h1>{title ?? 'Конференция без названия'}</h1> <h1>{title ?? 'Конференция без названия'}</h1>
<p> <p>

View File

@@ -62,8 +62,10 @@ export interface PickStageFocusInput {
/** Ключ, что был в фокусе на предыдущем рендере; `null` — фокус ещё не выбирался. */ /** Ключ, что был в фокусе на предыдущем рендере; `null` — фокус ещё не выбирался. */
prevFocusKey: string | null prevFocusKey: string | null
/** /**
* Ключ трека, ЗАКРЕПЛЁННОГО пользователем в основном окне (кнопка-булавка на * Ключ трека, ЗАКРЕПЛЁННОГО пользователем (кнопка-булавка на плитке
* плитке, состояние живёт в `RoomStage.tsx`); `null` — закрепления нет. * основного окна; состояние живёт в `RoomStage.tsx` и переезжает в
* мини-плеер через `RoomPage`, см. там `initialPinnedKey`); `null` —
* закрепления нет.
* Закрепление держит фокус вопреки говорящим, но уступает ЛЮБОЙ активной * Закрепление держит фокус вопреки говорящим, но уступает ЛЮБОЙ активной
* демонстрации экрана (формулировка оператора: «перебивается только чьей-либо * демонстрации экрана (формулировка оператора: «перебивается только чьей-либо
* демонстрацией экрана») — а когда демонстрация закончилась, фокус * демонстрацией экрана») — а когда демонстрация закончилась, фокус
@@ -79,19 +81,22 @@ export interface PickStageFocusInput {
* не удерживать текущий). С 0.0.6 включено и для мини-плеера (PiP), и для * не удерживать текущий). С 0.0.6 включено и для мини-плеера (PiP), и для
* основного окна — решение оператора (этап 3, задача 3.2). Защита от * основного окна — решение оператора (этап 3, задача 3.2). Защита от
* дребезга — на стороне вызывающего: источник «говорящих» — throttled * дребезга — на стороне вызывающего: источник «говорящих» — throttled
* `useSpeakingParticipants()` поверх `RoomEvent.ActiveSpeakersChanged`, а в * `useSpeakingParticipants()` поверх `RoomEvent.ActiveSpeakersChanged` плюс
* основном окне ещё и удержание в ~1.2 с (см. `useSteadySpeakers` в * удержание в ~1.2 с (см. `useSteadySpeakers` в `RoomStage.tsx`, с 0.0.25 —
* `RoomStage.tsx`), не сырой дребезжащий `participant.isSpeaking`. * в обеих сценах), не сырой дребезжащий `participant.isSpeaking`.
* По умолчанию `false` — фокус удерживается (см. правило 5). * По умолчанию `false` — фокус удерживается (см. правило 5).
*/ */
followSpeaker?: boolean followSpeaker?: boolean
/** /**
* Живая демонстрация экрана в фокусе НЕ уступает заговорившему участнику * Живая демонстрация экрана в фокусе НЕ уступает заговорившему участнику
* (правило 3). Нужно основному окну: там демонстрация — это содержательный * (правило 3). Демонстрация — это содержательный центр разговора, и уводить
* центр разговора, и уводить её из большого окна на каждую реплику нельзя. * её из фокуса на каждую реплику нельзя.
* Мини-плеер (PiP) показывает ровно одну плитку и намеренно ведёт себя иначе *
* — всегда показывает того, кто говорит, поэтому там `false` (поведение * С 0.0.25 включено в ОБЕИХ сценах. До этого мини-плеер (PiP) намеренно
* PiP не менялось с 0.0.4). * ходил с `false` — «одна плитка, всегда показываем говорящего»; на практике
* это выглядело как поломка: демонстрация в мини-окне пропадала, стоило
* кому-то сказать слово. По умолчанию всё ещё `false` — это поведение
* функции без явного запроса удержания.
*/ */
holdScreenShare?: boolean holdScreenShare?: boolean
/** /**
@@ -132,16 +137,14 @@ function pickSpeakerKey(
* фокус безусловно переходит на него (последний из новых, если появилось * фокус безусловно переходит на него (последний из новых, если появилось
* сразу несколько), даже если до этого в фокусе была камера или другая * сразу несколько), даже если до этого в фокусе была камера или другая
* демонстрация. Так же ведут себя типовые UI конференций (Google Meet). * демонстрация. Так же ведут себя типовые UI конференций (Google Meet).
* 2. Закрепление (`pinnedKey`, только основное окно): закреплённый участник * 2. Закрепление (`pinnedKey`): закреплённый участник
* забирает фокус у говорящих и у удержания предыдущего фокуса, но уступает * забирает фокус у говорящих и у удержания предыдущего фокуса, но уступает
* ЛЮБОЙ активной демонстрации экрана. Поэтому правило и стоит выше * ЛЮБОЙ активной демонстрации экрана. Поэтому правило и стоит выше
* удержания (правило 5): как только демонстрация закончилась и * удержания (правило 5): как только демонстрация закончилась и
* `screenShareKeys` опустел, фокус возвращается на закреплённого, а не * `screenShareKeys` опустел, фокус возвращается на закреплённого, а не
* остаётся на том, кто был в фокусе до демонстрации. * остаётся на том, кто был в фокусе до демонстрации.
* 3. `holdScreenShare` (только основное окно): демонстрация, уже стоящая в * 3. `holdScreenShare`: демонстрация, уже стоящая в фокусе, не уступает
* фокусе, не уступает заговорившему — иначе большое окно уводило бы шэр на * заговорившему — иначе окно уводило бы шэр на каждую реплику.
* каждую реплику. В PiP шаг пропускается (там одна плитка и она всегда
* показывает говорящего).
* 4. `followSpeaker`: если сейчас есть говорящий — фокус СРАЗУ переходит на * 4. `followSpeaker`: если сейчас есть говорящий — фокус СРАЗУ переходит на
* него, даже если текущий фокус ещё жив; среди одновременно говорящих * него, даже если текущий фокус ещё жив; среди одновременно говорящих
* предпочитаем того, у кого включена камера (`cameraKeysWithVideo`). * предпочитаем того, у кого включена камера (`cameraKeysWithVideo`).

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { PictureInPicture2 } from 'lucide-react' import { PictureInPicture2 } from 'lucide-react'
import { LiveKitRoom, usePersistentUserChoices } from '@livekit/components-react' import { LiveKitRoom, RoomAudioRenderer, usePersistentUserChoices } from '@livekit/components-react'
import type { RoomOptions } from 'livekit-client' import type { RoomOptions } from 'livekit-client'
import '@livekit/components-styles' import '@livekit/components-styles'
import '@/styles/room.css' import '@/styles/room.css'
@@ -22,6 +22,12 @@ import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
import { buildPublishDefaults } from '@/lib/publishQualityCap' import { buildPublishDefaults } from '@/lib/publishQualityCap'
import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode' import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode'
/** Сколько мс держать топбар/тулбар видимыми в полноэкранном режиме без взаимодействия, прежде чем спрятать их снова. */
const FULLSCREEN_CONTROLS_AUTO_HIDE_MS = 4000
/** Полоса у нижнего края экрана (px) — наведение мыши в неё в полноэкранном режиме на десктопе показывает тулбар без клика. */
const FULLSCREEN_FOOTER_HOVER_ZONE_PX = 72
interface RoomJoinState { interface RoomJoinState {
livekitUrl: string livekitUrl: string
token: string token: string
@@ -175,6 +181,73 @@ export function RoomPage() {
const pip = useRoomPiP(roomRootRef) const pip = useRoomPiP(roomRootRef)
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
// Топбар и тулбар в полноэкранном режиме — оверлей поверх сцены (см.
// `.room-fullscreen-overlay` в room.css), а не часть потока: показываются
// по тапу/клику по сцене вне элементов управления и прячутся сами через
// FULLSCREEN_CONTROLS_AUTO_HIDE_MS бездействия. На десктопе есть ещё второй
// способ показать их — навести мышь в нижнюю полосу экрана (без клика),
// как в большинстве видеоплееров; клик остаётся основным способом на
// тач-устройствах, где наведения не бывает. Обычный (не полноэкранный)
// режим этот стейт не использует вовсе.
const [fullscreenControlsVisible, setFullscreenControlsVisible] = useState(true)
// Счётчик «попроси показать и отсчитать заново» — растёт на каждый клик/
// наведение, даже если панель УЖЕ видима (иначе непрерывное наведение не
// продлевало бы таймер: setState(true) поверх уже true не меняет состояние
// и не перезапускает эффект ниже).
const [fullscreenControlsTick, setFullscreenControlsTick] = useState(0)
// Показ при входе в полноэкранный режим — «подгонка состояния во время
// рендера» (см. тот же приём у `chatSeenCount` выше), а не setState в теле
// эффекта (react-hooks/set-state-in-effect): само планирование таймера
// авто-скрытия — ниже, отдельным эффектом, и не вызывает setState
// синхронно в своём теле.
const [prevFullscreenActive, setPrevFullscreenActive] = useState(fullscreen.active)
if (fullscreen.active !== prevFullscreenActive) {
setPrevFullscreenActive(fullscreen.active)
if (fullscreen.active) {
setFullscreenControlsVisible(true)
setFullscreenControlsTick((tick) => tick + 1)
}
}
useEffect(() => {
if (!fullscreen.active || !fullscreenControlsVisible) return
const timer = setTimeout(() => setFullscreenControlsVisible(false), FULLSCREEN_CONTROLS_AUTO_HIDE_MS)
return () => clearTimeout(timer)
}, [fullscreen.active, fullscreenControlsVisible, fullscreenControlsTick])
// Наведение мыши в нижнюю полосу экрана — только десктопный способ показать
// элементы управления без клика; на тач-устройствах `mousemove` в таком виде
// не приходит, слушатель им не вредит и не мешает.
useEffect(() => {
if (!fullscreen.active) return
function handleMouseMove(e: MouseEvent) {
if (window.innerHeight - e.clientY <= FULLSCREEN_FOOTER_HOVER_ZONE_PX) {
setFullscreenControlsVisible(true)
setFullscreenControlsTick((tick) => tick + 1)
}
}
window.addEventListener('mousemove', handleMouseMove)
return () => window.removeEventListener('mousemove', handleMouseMove)
}, [fullscreen.active])
// Клик/тап по сцене переключает видимость — но не по элементам управления
// внутри неё (кнопки плиток, булавка закрепления, «Показать остальных» и
// т.п.: `closest` поднимается от места клика и гасит переключение, если
// по дороге встретился интерактивный элемент) и не по открытой панели чата
// (`.chat-panel` — та рендерится внутри того же `.room-main`, но клики по
// тексту сообщений не должны прятать/показывать тулбар).
const handleStageAreaClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!fullscreen.active) return
const target = e.target as HTMLElement
if (target.closest('button, a, input, select, textarea, [role="dialog"], .chat-panel')) return
setFullscreenControlsVisible((visible) => !visible)
setFullscreenControlsTick((tick) => tick + 1)
},
[fullscreen.active],
)
// Вид сцены живёт здесь, а не в `RoomStage`: переключатели — в тулбаре и в // Вид сцены живёт здесь, а не в `RoomStage`: переключатели — в тулбаре и в
// шторке настроек, а сцена их только читает (общий предок). // шторке настроек, а сцена их только читает (общий предок).
// //
@@ -208,6 +281,12 @@ export function RoomPage() {
// открывал мини-окно на самом пользователе. Подробнее — докстринг `RoomStage`. // открывал мини-окно на самом пользователе. Подробнее — докстринг `RoomStage`.
const [stageFocusKey, setStageFocusKey] = useState<string | null>(null) const [stageFocusKey, setStageFocusKey] = useState<string | null>(null)
// Закрепление участника (булавка на плитке) — по той же причине и тем же
// мостиком, что и `stageFocusKey`: экземпляр `RoomStage` при открытии
// мини-плеера пересоздаётся, и до 0.0.25 закрепление, сделанное в основном
// окне, в мини-окно не попадало вовсе (там был свой чистый `useState`).
const [stagePinnedKey, setStagePinnedKey] = useState<string | null>(null)
// Сохранённый выбор устройств — читаем через собственный вызов // Сохранённый выбор устройств — читаем через собственный вызов
// usePersistentUserChoices (независимый от того, что использует // usePersistentUserChoices (независимый от того, что использует
// DeviceSettingsDialog: там свой вызов хука со своим состоянием). ВАЖНО: // DeviceSettingsDialog: там свой вызов хука со своим состоянием). ВАЖНО:
@@ -275,7 +354,7 @@ export function RoomPage() {
} }
return ( return (
<div data-theme="room" ref={roomRootRef}> <div data-theme="room" ref={roomRootRef} className={fullscreen.active ? 'room-fullscreen-overlay' : undefined}>
<LiveKitRoom <LiveKitRoom
serverUrl={joinState.livekitUrl} serverUrl={joinState.livekitUrl}
token={joinState.token} token={joinState.token}
@@ -285,9 +364,29 @@ export function RoomPage() {
options={roomOptions} options={roomOptions}
onDisconnected={handleDisconnected} onDisconnected={handleDisconnected}
> >
{/* Звук комнаты рендерится ЗДЕСЬ, а не внутри `RoomStage`, и ровно
одним экземпляром на всю страницу. `RoomAudioRenderer` — это набор
скрытых `<audio>`, к которым LiveKit привязывает чужие аудиотреки
(`track.attach(el)`). Пока он жил в сцене, открытие мини-плеера
переносило эти элементы в ДРУГОЙ документ (Document PiP — отдельное
окно со своим `document`), а возврат — обратно, и после возврата
звук чужих участников пропадал: Chrome теряет аудиовыход у
remote-трека, переехавшего между документами. Замерено: пакеты
продолжают приходить (`packetsReceived` растёт), а
`totalSamplesDuration`/`totalAudioEnergy` замирают, и трек молчит
даже в свежесозданном `<audio>` со свежим `MediaStream`. Тот же
цикл detach/attach В ПРЕДЕЛАХ ОДНОГО документа безвреден — дело
именно в переезде. Здесь элементы живут в основном документе
непрерывно, весь цикл «открыл мини-окно → вернул» их не касается. */}
<RoomAudioRenderer />
<div data-lk-theme="default" className="room-shell"> <div data-lk-theme="default" className="room-shell">
<RoomTopbar title={joinState.title ?? null} slug={slug} number={joinState.number} /> <RoomTopbar
<div className="room-main"> title={joinState.title ?? null}
slug={slug}
number={joinState.number}
overlayVisible={fullscreenControlsVisible}
/>
<div className="room-main" onClick={handleStageAreaClick}>
{pip.mode === 'document' ? ( {pip.mode === 'document' ? (
// Сцена сейчас рисуется в PiP-окне (через createPortal ниже) — // Сцена сейчас рисуется в PiP-окне (через createPortal ниже) —
// основное окно вместо неё показывает заглушку с возвратом. // основное окно вместо неё показывает заглушку с возвратом.
@@ -306,6 +405,8 @@ export function RoomPage() {
onHideOthers={() => setHideOthers(true)} onHideOthers={() => setHideOthers(true)}
initialFocusKey={stageFocusKey} initialFocusKey={stageFocusKey}
onFocusKeyChange={setStageFocusKey} onFocusKeyChange={setStageFocusKey}
initialPinnedKey={stagePinnedKey}
onPinnedKeyChange={setStagePinnedKey}
onPinFocus={handlePinFocus} onPinFocus={handlePinFocus}
raisedHandIdentities={raisedHandIdentities} raisedHandIdentities={raisedHandIdentities}
conferenceId={joinState.conferenceId} conferenceId={joinState.conferenceId}
@@ -342,6 +443,7 @@ export function RoomPage() {
onLayoutModeChange={handleLayoutModeChange} onLayoutModeChange={handleLayoutModeChange}
hideOthers={hideOthers} hideOthers={hideOthers}
onHideOthersChange={setHideOthers} onHideOthersChange={setHideOthers}
overlayVisible={fullscreenControlsVisible}
/> />
</div> </div>
{settingsOpen && ( {settingsOpen && (
@@ -358,10 +460,17 @@ export function RoomPage() {
`variant="pip"` — мини-плеер `variant="pip"` — мини-плеер
показывает только активное окно (одну плитку), без карусели/грида показывает только активное окно (одну плитку), без карусели/грида
основного окна. `initialFocusKey` — то, что было крупно в основном основного окна. `initialFocusKey` — то, что было крупно в основном
окне: без него мини-окно открывалось на самом пользователе. */} окне: без него мини-окно открывалось на самом пользователе;
`initialPinnedKey` — закрепление оттуда же. */}
{pip.pipWindow && {pip.pipWindow &&
createPortal( createPortal(
<RoomStage variant="pip" initialFocusKey={stageFocusKey} onFocusKeyChange={setStageFocusKey} />, <RoomStage
variant="pip"
initialFocusKey={stageFocusKey}
onFocusKeyChange={setStageFocusKey}
initialPinnedKey={stagePinnedKey}
onPinnedKeyChange={setStagePinnedKey}
/>,
pip.pipWindow.document.body, pip.pipWindow.document.body,
)} )}
<ForcedMuteWatcher event={chat.lastForcedMute} /> <ForcedMuteWatcher event={chat.lastForcedMute} />

View File

@@ -16,6 +16,11 @@
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border-right: 1px solid var(--color-border); border-right: 1px solid var(--color-border);
/* Запрос по ширине САМОЙ панели, а не окна: `.layout` — flex 42/58, и на
широком окне с узкой панелью `vw` (см. .brand-headline) не отражает
реальную доступную ширину — тот же паттерн, что у `.room-tile-avatar`
(container-type:size + cqmin, room.css) для аватара участника. */
container-type: inline-size;
} }
.brand-panel::after { .brand-panel::after {
content: ""; content: "";
@@ -41,6 +46,12 @@
.brand-headline { .brand-headline {
font: var(--text-display-lg); font: var(--text-display-lg);
font-family: var(--font-display); font-family: var(--font-display);
/* `cqw` — от ширины `.brand-panel` (её `container-type: inline-size` выше),
а не окна: длинное слово «инфраструктура» иначе не помещается именно
тогда, когда окно широкое, а панель (42% от него) — узкая. Раньше кегль
уменьшался только в @media по ширине ОКНА (узкие экраны) — не спасало
от этого случая. */
font-size: clamp(20px, 8cqw, 34px);
color: var(--color-ink-700); color: var(--color-ink-700);
margin: 0 0 var(--space-4); margin: 0 0 var(--space-4);
max-width: 460px; max-width: 460px;
@@ -51,7 +62,11 @@
max-width: 420px; max-width: 420px;
margin: 0; margin: 0;
} }
.brand-stats { display: flex; gap: var(--space-4); z-index: 1; margin-top: var(--space-8); } /* `flex-wrap` не только на мобильном медиа-запросе (см. ниже) — та же
природа бага, что у заголовка: узкая ПАНЕЛЬ (а не узкое окно) не даёт
двум плашкам поместиться в ряд, а `overflow:hidden` у `.brand-panel`
обрезал вторую вместо переноса. */
.brand-stats { display: flex; flex-wrap: wrap; gap: var(--space-4); z-index: 1; margin-top: var(--space-8); }
.stat-glass { .stat-glass {
background: var(--color-surface-glass); background: var(--color-surface-glass);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
@@ -248,10 +263,10 @@
right: -90px; right: -90px;
bottom: -90px; bottom: -90px;
} }
/* clamp() — на узких экранах слово «инфраструктура» иначе вылезает за /* Кегль теперь считает `.brand-headline` сама (cqw от ширины панели, см.
край брендовой панели (см. .brand-panel padding ниже и её overflow:hidden, базовое правило) — здесь снимаем только `max-width:460px`: в сложенной
обрезающий текст без переноса вместо уменьшения кегля). */ колонкой раскладке панель может стать шире 460px, а дизайн этого хочет. */
.brand-headline { max-width: none; font-size: clamp(22px, 6.2vw, 34px); } .brand-headline { max-width: none; }
.brand-sub { max-width: none; } .brand-sub { max-width: none; }
.brand-stats { flex-wrap: wrap; } .brand-stats { flex-wrap: wrap; }
.stat-glass { flex: 1 1 140px; min-width: 0; } .stat-glass { flex: 1 1 140px; min-width: 0; }

View File

@@ -497,8 +497,14 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
.chat-empty { font: var(--text-body); color: var(--color-room-text-tertiary); margin: auto; text-align: center; } .chat-empty { font: var(--text-body); color: var(--color-room-text-tertiary); margin: auto; text-align: center; }
/* `min-height: 0` обязателен — тот же приём, что у `.stage-side` (комментарий
выше): без него `.chat-panel` (flex-колонка) в Firefox не сжимает
`.chat-messages` до высоты `flex:1`, а даёт ей вырасти по контенту
(список сообщений) и вылезти за пределы панели — Chrome в этой ситуации
более снисходителен, Firefox — нет. */
.chat-messages { .chat-messages {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: var(--space-5); padding: var(--space-5);
display: flex; display: flex;
@@ -540,6 +546,13 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
} }
.chat-input-row textarea { .chat-input-row textarea {
flex: 1; flex: 1;
/* Firefox даёт `<textarea>` большую автоматическую минимальную ширину,
завязанную на атрибут `cols` (умолчание 20 символов моноширинной
метрики), и как flex-item без `min-width:0` отказывается сжиматься
ниже нее — панель шириной 320px раздувается вправо. Chrome/Safari
считают минимальную ширину textarea мягче, поэтому баг был виден
только в Firefox. */
min-width: 0;
resize: none; resize: none;
background: var(--color-room-tile); background: var(--color-room-tile);
border: 1px solid var(--color-room-tile-border); border: 1px solid var(--color-room-tile-border);
@@ -592,13 +605,18 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
.chat-emoji-wrap .chat-emoji-trigger:disabled { opacity: 0.5; cursor: default; } .chat-emoji-wrap .chat-emoji-trigger:disabled { opacity: 0.5; cursor: default; }
/* 5 колонок × 6 строк — ровно 30 эмодзи в EMOJI_OPTIONS (ChatPanel.tsx), без /* 5 колонок × 6 строк — ровно 30 эмодзи в EMOJI_OPTIONS (ChatPanel.tsx), без
неполной последней строки. */ неполной последней строки. `max-width` — страховка на случай совсем узкого
viewport: фикс-ширина 220px без потолка сама по себе не переполняется при
текущей раскладке (триггер у левого края панели, попап растёт вправо в
свободное место — проверено геометрией и вживую), но фиксированный размер
совсем без ограничителя — плохая практика сама по себе. */
.chat-emoji-popover { .chat-emoji-popover {
position: absolute; position: absolute;
bottom: calc(100% + var(--space-2)); bottom: calc(100% + var(--space-2));
left: 0; left: 0;
z-index: 50; z-index: 50;
width: 220px; width: 220px;
max-width: calc(100vw - 2 * var(--space-4));
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(5, 1fr);
gap: 4px; gap: 4px;
@@ -609,14 +627,30 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
box-shadow: var(--shadow-room-panel); box-shadow: var(--shadow-room-panel);
} }
.chat-emoji-popover .chat-emoji-option { .chat-emoji-popover .chat-emoji-option {
/* Настоящая причина переполнения (найдена по факту, не по догадке —
`getBoundingClientRect` показал кнопки 42×42px при колонке ~36px):
`.chat-input-row button` (специфичность 0,1,1) задаёт ВСЕМ кнопкам
формы `width/height: 42px` — это правило круглой кнопки «Отправить»,
а кнопки эмодзи в поповере тоже лежат внутри `.chat-input-row`
(см. комментарий выше про гонку специфичности, она чинилась для
цвета в 0.0.19, но не для размера). Без явного `width`/`height` здесь
побеждает тот 42px, сетка на 5 колонок раздувается за 220px попапа,
и последняя колонка уезжает вправо за рамку. `min-width: 0` сам по
себе НЕ помогает — конфликт не в авто-минимуме грида, а в explicit
width, который обязательно нужно перебить явно. */
width: auto;
height: auto;
min-width: 0;
aspect-ratio: 1; aspect-ratio: 1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden;
background: none; background: none;
border: none; border: none;
font-size: 20px; font-size: 20px;
line-height: 1; line-height: 1;
white-space: nowrap;
border-radius: var(--radius-md); border-radius: var(--radius-md);
cursor: pointer; cursor: pointer;
} }
@@ -769,10 +803,16 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
* Тот же диалог (`DeviceSettingsDialog`), что модалка выше, — только контейнер * Тот же диалог (`DeviceSettingsDialog`), что модалка выше, — только контейнер
* снизу вместо центра экрана: `.room-sheet-overlay`/`.room-sheet-panel` вместо * снизу вместо центра экрана: `.room-sheet-overlay`/`.room-sheet-panel` вместо
* `.room-modal-overlay`/`.room-modal-panel`, разметка полей (`.room-field` и * `.room-modal-overlay`/`.room-modal-panel`, разметка полей (`.room-field` и
* ниже) общая. Ручка `.room-sheet-handle` — свайп вниз для закрытия (JS считает * ниже) общая. Свайп вниз для закрытия JS на самой панели (не на ручке —
* смещение пальца и сам решает, закрывать или вернуть панель на место; * см. докстринг `DeviceSettingsDialog`) считает смещение пальца и сам решает,
* `transition` тут — только пружина возврата, во время самого драга * закрывать или вернуть панель на место; `transition` тут — только пружина
* компонент подставляет инлайновый `transition: none`). * возврата, во время самого драга компонент подставляет инлайновый
* `transition: none`. `overscroll-behavior-y: contain` — чтобы при свайпе
* вниз от самого верха списка устройств iOS/Chrome не показывали заодно
* ещё и нативный эффект растяжения скролла поверх нашей анимации панели.
* Ручка `.room-sheet-handle` — теперь чисто визуальная подсказка (сама
* панель ловит жест где угодно), `touch-action: none` на ней оставлен —
* безвредно и снимает системные жесты с этой узкой полоски.
*/ */
.room-sheet-overlay { .room-sheet-overlay {
position: fixed; position: fixed;
@@ -793,6 +833,7 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
width: 100%; width: 100%;
max-height: 80vh; max-height: 80vh;
overflow-y: auto; overflow-y: auto;
overscroll-behavior-y: contain;
transition: transform 160ms ease-out; transition: transform 160ms ease-out;
} }
.room-sheet-handle { .room-sheet-handle {
@@ -1047,22 +1088,79 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
в самой конференции, а вот чип «№ … / Пригласить» с телефона нужен чаще в самой конференции, а вот чип «№ … / Пригласить» с телефона нужен чаще
(скопировать ссылку и позвать участника), поэтому он остаётся. (скопировать ссылку и позвать участника), поэтому он остаётся.
Тулбар: демонстрация экрана, полноэкранный режим и мини-окно с телефона Тулбар: демонстрация экрана и мини-окно с телефона практически не нужны —
практически не нужны, а все 8 кнопок в ширину экрана физически не скрыты. Полноэкранный режим ОСТАЁТСЯ виден, как на десктопе (решение
помещаются — скрываем их и уплотняем оставшиеся пять оператора 03.08: вход в него — по аналогии с десктопным приложением, не
(микрофон/камера/устройства/чат/выход). `flex-wrap` + `min-width: 0` — спрятан в шторку настроек) — вместе с «Рукой» (0.0.16) и опциональными
страховка на совсем узких экранах, чтобы футер ни при каких подписях не «Очередью» (только организатору)/«Чатом» на мобильном может набраться до
вылезал за ширину окна. */ 78 кнопок разом, в один ряд по 360px они уже не помещаются.
`.room-toolbar.tb-wrap-grid` — класс считает JS (`RoomToolbar.tsx`, по
фактическому числу видимых на мобильном кнопок, а не селекторами
`:nth-child` — организатору достаётся ДОПОЛНИТЕЛЬНАЯ обёртка
`.tb-menu-wrap` вокруг кнопки «Очередь», из-за неё позиция по DOM «плывёт»)
— переключает раскладку на равномерную сетку 4 колонки: 7 кнопок ложатся
4+3, 8 — 4+4, а не как получится через `flex-wrap` (например 6+1, если
просто позволить браузеру перенести лишние). До 6 кнопок раскладка —
обычный flex, они влезают в 360px одним рядом без переноса вовсе. */
@media (max-width: 600px) { @media (max-width: 600px) {
.room-topbar { padding: var(--space-3) var(--space-4); justify-content: flex-end; } .room-topbar { padding: var(--space-3) var(--space-4); justify-content: flex-end; }
.room-title-block { display: none; } .room-title-block { display: none; }
.tb-btn--screenshare, .tb-btn--screenshare,
.tb-btn--fullscreen,
.tb-btn--pip { display: none; } .tb-btn--pip { display: none; }
.room-toolbar { padding: var(--space-3) var(--space-2); gap: 2px; flex-wrap: wrap; } .room-toolbar { padding: var(--space-3) var(--space-2); gap: 2px; flex-wrap: wrap; }
.room-toolbar.tb-wrap-grid { display: grid; grid-template-columns: repeat(4, 1fr); justify-items: center; }
.tb-btn { min-width: 0; padding: 6px 6px; } .tb-btn { min-width: 0; padding: 6px 6px; }
.tb-btn .icon-shell { width: 40px; height: 40px; } .tb-btn .icon-shell { width: 40px; height: 40px; }
.tb-btn span.label { font-size: 11px; } .tb-btn span.label { font-size: 11px; }
} }
/*
* ---------- Полноэкранный режим: топбар и тулбар прячутся в оверлей ----------
* Работает на ЛЮБОЙ ширине экрана (не только мобильной) — как только
* `document.fullscreenElement`, корневой контейнер комнаты получает класс
* `.room-fullscreen-overlay` (`RoomPage.tsx`, вместе с `useFullscreen`).
* Топбар и тулбар выходят из flow-потока `.room-shell` — единственный
* оставшийся в потоке `.room-main` (`flex: 1`) сам растягивается на всю
* высоту, отдельного правила не нужно — и ложатся оверлеем поверх сцены
* сверху/снизу. Появляются по клику/тапу вне элементов управления
* (`RoomPage.tsx`, `handleStageAreaClick`) или — на десктопе — при наведении
* мыши в нижнюю полосу экрана (`FULLSCREEN_FOOTER_HOVER_ZONE_PX`), прячутся
* по повторному клику или сами через `FULLSCREEN_CONTROLS_AUTO_HIDE_MS`
* бездействия.
*
* z-index 150 — выше сцены, но НИЖЕ модалки/шторки настроек (200): если из
* уже открытого оверлея снова открыть «Настройки», диалог должен лечь
* поверх тулбара, а не под него.
*
* Кнопка «Экран» (выход) на мобильном видна и без полноэкранного режима
* (см. блок выше — по аналогии с десктопом), поэтому здесь её отдельно
* возвращать не нужно.
*/
.room-fullscreen-overlay .room-topbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 150;
background: rgba(20, 22, 26, 0.92);
backdrop-filter: blur(8px);
transform: translateY(-100%);
transition: transform 200ms ease-out;
}
.room-fullscreen-overlay .room-topbar.is-visible { transform: translateY(0); }
.room-fullscreen-overlay .room-toolbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 150;
background: rgba(20, 22, 26, 0.92);
backdrop-filter: blur(8px);
padding-bottom: calc(var(--space-3) + env(safe-area-inset-bottom));
transform: translateY(100%);
transition: transform 200ms ease-out;
}
.room-fullscreen-overlay .room-toolbar.is-visible { transform: translateY(0); }

View File

@@ -412,6 +412,10 @@ ensure_default NGINX_CERT_NAME "localhost"
ensure_default LIVEKIT_USE_EXTERNAL_IP "false" ensure_default LIVEKIT_USE_EXTERNAL_IP "false"
ensure_default LIVEKIT_NODE_IP "127.0.0.1" ensure_default LIVEKIT_NODE_IP "127.0.0.1"
ensure_default TURN_EXTERNAL_IP "127.0.0.1" ensure_default TURN_EXTERNAL_IP "127.0.0.1"
# Пусто = TURN over TLS выключен (см. docs/deploy/DEPLOYMENT.md §8) — не
# генерируем и не требуем здесь, только гарантируем, что ключ явно есть в
# .env (для discoverability), а не отсутствует молча.
ensure_default TURN_TLS_HOST ""
# Профили compose и модели по пресету. GPU-профили — ТОЛЬКО для пресета 5 # Профили compose и модели по пресету. GPU-профили — ТОЛЬКО для пресета 5
# (max): в текущей матрице уровней (backend/services/ai_tiers.py, ADR-004) # (max): в текущей матрице уровней (backend/services/ai_tiers.py, ADR-004)