Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0162cc8a6d | |||
| 5d39e0f076 | |||
| 43cc1ae3e8 | |||
| 82204553e2 | |||
| 3290eb5d07 | |||
| f627d827af | |||
| 5e6c4f8bfd | |||
| 270926cc96 | |||
| e018837a1d | |||
| 705f160912 | |||
| 7a5e9d2d8a | |||
| eb4e5ea83f | |||
| b528785249 | |||
| a53ba7c827 | |||
| 71f150d1b6 | |||
| 7549b53ec9 | |||
| 6d65b620fe | |||
| 32949ebc66 | |||
| b8220f682d | |||
| 29a3e78836 | |||
| 3fb3a5d42c | |||
| 975763a3a6 |
16
.env.example
16
.env.example
@@ -81,6 +81,20 @@ LIVEKIT_NODE_IP=127.0.0.1
|
||||
# с точкой монтирования тома в обоих сервисах.
|
||||
RECORDINGS_DIR=/recordings
|
||||
|
||||
# --- Производительность backend ---
|
||||
# Число процессов uvicorn. Один процесс означает, что любой блокирующий вызов
|
||||
# в обработчике останавливает весь event loop: параллельные входы в конференцию
|
||||
# и WS-чат всех участников встают в очередь. Дефолт 2 рассчитан на 4-ядерный
|
||||
# сервер, где ядра делятся с LiveKit (медиа важнее API).
|
||||
UVICORN_WORKERS=2
|
||||
# Пул соединений с БД НА КАЖДЫЙ воркер. Общий расход инстанса —
|
||||
# UVICORN_WORKERS × (DB_POOL_SIZE + DB_MAX_OVERFLOW), плюс соединения Celery
|
||||
# и alembic. Держите сумму заметно ниже max_connections у Postgres (по
|
||||
# умолчанию 100), иначе вместо понятной ошибки приложения получите отказ БД.
|
||||
DB_POOL_SIZE=10
|
||||
DB_MAX_OVERFLOW=10
|
||||
DB_POOL_TIMEOUT=10
|
||||
|
||||
# --- Email (рассылка саммари + .ics-приглашения) ---
|
||||
# `console` — дефолт для dev (письмо только логируется, ссылка подтверждения
|
||||
# email берётся из логов); `smtp` — реальная отправка через aiosmtplib.
|
||||
@@ -98,7 +112,7 @@ SMTP_TIMEOUT_S=30
|
||||
# --- Версия инстанса (релиз v0.0.1) ---
|
||||
# install.sh копирует значение из корневого файла VERSION при каждой
|
||||
# установке/обновлении — руками менять не нужно.
|
||||
VIDCONF_VERSION=0.0.10
|
||||
VIDCONF_VERSION=0.0.15
|
||||
|
||||
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
||||
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
||||
|
||||
151
CHANGELOG.md
151
CHANGELOG.md
@@ -3,6 +3,157 @@
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
|
||||
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
|
||||
|
||||
## [0.0.15] — 2026-07-30
|
||||
|
||||
Шесть доработок UI комнаты конференции.
|
||||
|
||||
### Добавлено
|
||||
- Выбор эмодзи в чате — поповер с небольшим собственным набором популярных
|
||||
эмодзи (без библиотеки-пикера), вставка в позицию курсора.
|
||||
- Кнопка микрофона в мини-окне (Document PiP) — раньше в PiP не было
|
||||
никакого способа замьютить себя, не разворачивая основное окно.
|
||||
- Закрепление участника доступно из любого режима показа («Стандарт»,
|
||||
«Плитки», «Живые плитки») — раньше булавка рендерилась только в
|
||||
«Стандарте». Само закрепление автоматически переключает вид на «Стандарт»
|
||||
(закреплённого иначе негде показать крупно); открепление вид назад не
|
||||
меняет.
|
||||
|
||||
### Изменено
|
||||
- Участник заходит в конференцию с выключенными микрофоном и камерой,
|
||||
включает их сам уже в комнате.
|
||||
- В топбаре комнаты вместо технического `room_name` LiveKit (вида
|
||||
`hC-Dmos9KEM`) показывается название конференции; при его отсутствии —
|
||||
«Конференция без названия». Номер и ссылка-приглашение не задеты.
|
||||
|
||||
## [0.0.14] — 2026-07-28
|
||||
|
||||
TURN-фолбэк для участников из сетей с жёстким NAT.
|
||||
|
||||
### Исправлено
|
||||
- LiveKit теперь анонсирует клиентам внешний TURN-сервер (`rtc.turn_servers`
|
||||
в конфигурации, UDP и TCP на 3478). Раньше coturn поднимался и был healthy,
|
||||
но клиенты о нём не знали: внешний TURN в конфиге объявлен не был, встроенный
|
||||
выключен, а фронтенд `iceServers` не задаёт. За всё время работы в логах
|
||||
coturn не было ни одной аллокации — то есть relay не использовался никогда,
|
||||
и участники из сетей, где прямое UDP-соединение не проходит, теряли связь
|
||||
(`PEER_CONNECTION_DISCONNECTED`). На нагрузочном тесте 28.07 все такие
|
||||
разрывы пришлись на внешних участников и ни одного — на офисных.
|
||||
- Credentials TURN генерируются по механизму TURN REST API из общего
|
||||
`TURN_STATIC_AUTH_SECRET`, то есть тот же секрет, что и у coturn.
|
||||
|
||||
### Изменено
|
||||
- `deploy/render-templates.sh` подставляет `TURN_EXTERNAL_IP` и
|
||||
`TURN_STATIC_AUTH_SECRET` также в конфигурацию LiveKit.
|
||||
- Раздел 8 руководства по развёртыванию переписан: TURN больше не «план на
|
||||
будущее», а рабочая конфигурация. Отдельно описано правило ufw для
|
||||
relay-диапазона `49160:49200/udp` — без него TURN отвечает на запросы, но
|
||||
релей не работает, причём в логах coturn при этом тишина.
|
||||
|
||||
TURN over TLS (5349/443) по-прежнему не настроен: требует монтирования
|
||||
сертификата в coturn. Неработающий `turns:` намеренно не анонсируется, иначе
|
||||
клиент ждал бы таймаута перед переходом к рабочему кандидату.
|
||||
|
||||
## [0.0.13] — 2026-07-28
|
||||
|
||||
Снижение нагрузки на сеть: клиент перестаёт получать полное качество всех
|
||||
чужих камер независимо от того, какого размера плитка на экране.
|
||||
|
||||
### Изменено
|
||||
- Включены `adaptiveStream` и `dynacast` в опциях комнаты. Оба флага в LiveKit
|
||||
выключены по умолчанию, из-за чего каждый участник был подписан на полное
|
||||
качество всех чужих треков, а каждый паблишер слал все слои симулкаста, даже
|
||||
когда их никто не смотрит. Теперь качество подписки выбирается по фактическому
|
||||
размеру плитки, а неотрисованные треки уходят в паузу. На локальном стенде
|
||||
(9 участников, паблишеры 720p) входящий поток одного клиента упал с
|
||||
11 110 до 778 кбит/с.
|
||||
|
||||
Вместе с этим начинает экономить уже написанный код, который до сих пор не
|
||||
давал выигрыша: «скрыть остальных» не рендерит карусель (исходящий трафик
|
||||
LiveKit 0.76 → 0.03 Мбит/с), пагинация сетки участников рендерит только
|
||||
текущую страницу, а пауза чужого видео в свёрнутой вкладке работает лишь
|
||||
при включённом `adaptiveStream`.
|
||||
- Контейнеры больше не пересобирают окружение Python при запуске: во все
|
||||
вызовы `uv run` в прод-путях (CMD образа, `command`/`entrypoint`/`healthcheck`
|
||||
сервисов, миграции и seed в `install.sh`) добавлен `--no-sync`. Раньше
|
||||
окружение, собранное на этапе build с `--no-dev`, при каждом старте
|
||||
синхронизировалось заново и подтягивало dev-группу: ~26 МБ загрузок,
|
||||
замедленный старт и ruff/mypy/pytest в рантайме. Healthcheck'и делали то же
|
||||
самое каждые 15 секунд всю жизнь контейнера. Размер `/app/.venv` после
|
||||
запуска: 574 → 456 МБ. Локальная разработка не затронута — там dev-группа
|
||||
нужна и ставится как раньше.
|
||||
|
||||
## [0.0.12] — 2026-07-28
|
||||
|
||||
Разблокировка backend под нагрузкой: вход в конференцию перестаёт отваливаться,
|
||||
когда участники включают микрофоны.
|
||||
|
||||
### Исправлено
|
||||
- Обработчик webhook `track_published` больше не запускает Track Egress внутри
|
||||
своей транзакции. Раньше каждый опубликованный микрофон уходил в сетевой
|
||||
вызов, а на инстансе без профиля `transcribe` (egress-сервиса в деплое нет)
|
||||
этот вызов висел 20–25 секунд, всё это время удерживая соединение с БД. На
|
||||
нагрузочном тесте с 19 участниками пул соединений выгребался за секунды, и
|
||||
вход в конференцию начинал отвечать 500. Теперь запуск уходит в фоновую
|
||||
задачу со своей сессией, а webhook отвечает сразу — LiveKit перестаёт копить
|
||||
очередь доставки и терять события.
|
||||
- Track Egress не запускается вовсе, если транскрибация выключена в настройках
|
||||
инстанса — тот же guard, что уже был в обработчике `room_finished`.
|
||||
- Запуск Track Egress ограничен таймаутом (по умолчанию 3 секунды) вместо
|
||||
ожидания собственного таймаута LiveKit.
|
||||
|
||||
### Изменено
|
||||
- Пул соединений с БД задаётся явно (`DB_POOL_SIZE`, `DB_MAX_OVERFLOW`,
|
||||
`DB_POOL_TIMEOUT`) вместо дефолта SQLAlchemy 5 + 10. Считайте бюджет на весь
|
||||
инстанс: каждый воркер держит свой пул, и сумма должна оставаться заметно
|
||||
ниже `max_connections` у Postgres.
|
||||
- Backend запускается с несколькими процессами uvicorn (`UVICORN_WORKERS`,
|
||||
по умолчанию 2). Одиночный процесс означал, что любой блокирующий вызов
|
||||
останавливает и параллельные запросы, и WS-чат всех участников. Дефолт 2, а
|
||||
не по числу ядер: на четырёхъядерном сервере ядра делятся с LiveKit.
|
||||
|
||||
## [0.0.11] — 2026-07-28
|
||||
|
||||
Выбор режима показа участников в конференции, скрытие остальных и круглая
|
||||
иконка участника при выключенной камере.
|
||||
|
||||
### Добавлено
|
||||
- Выбор режима показа участников на сцене вместо автоматической раскладки:
|
||||
«Стандарт» (крупная плитка плюс карусель остальных сбоку, как было),
|
||||
«Плитки» (все участники равными плитками) и «Живые плитки» (плитками —
|
||||
только те, у кого включена камера, остальные в карусели). Кто именно
|
||||
показан крупно в «Стандарте», по-прежнему решает прежняя логика фокуса
|
||||
(новая демонстрация → закреплённый → говорящий с видео → говорящий).
|
||||
Выбор сохраняется между заходами в комнату.
|
||||
- Скрытие остальных участников — на сцене остаётся только основная плитка.
|
||||
Скрыть можно из меню «Вид», из шторки настроек на мобильном и кнопкой над
|
||||
колонкой миниатюр; вернуть — кнопкой «Показать остальных (N)», которая
|
||||
видна всё время, пока кто-то скрыт. В режиме «Плитки» скрывать нечего,
|
||||
переключатель там заблокирован.
|
||||
- Переключатель вида: кнопка «Вид» с поповером в тулбаре на широком экране;
|
||||
на мобильном — секция «Вид» в шторке настроек, чтобы не добавлять шестую
|
||||
кнопку в уже ужатый тулбар.
|
||||
|
||||
### Изменено
|
||||
- Демонстрация экрана временно перебивает выбранный режим: пока идёт шэр,
|
||||
сцена ведёт себя как «Стандарт», а по его завершении возвращается к
|
||||
выбранному виду.
|
||||
- Равномерная сетка плиток стала пригодна для телефона: 2 колонки от 360px
|
||||
ширины и портретная раскладка 2×3 вместо двух плиток на страницу, которые
|
||||
давал набор раскладок библиотеки. Лишние участники уходят на следующую
|
||||
страницу — листается свайпом и стрелками.
|
||||
|
||||
### Исправлено
|
||||
- Иконка участника при выключенной камере больше не превращается в эллипс:
|
||||
размер считается от узкой стороны плитки (container-запросы), а не как
|
||||
проценты от разных сторон. В крупной плитке иконка занимает примерно её
|
||||
половину, в миниатюрах карусели — всю узкую сторону с отступом 5px; на
|
||||
большой плитке фокуса иконка больше не упирается в прежний потолок 96px.
|
||||
- Мини-плеер в Chrome открывался на самом пользователе вместо того, что он
|
||||
видел крупно: сцена в PiP-окне — отдельный экземпляр компонента и выбирала
|
||||
фокус с нуля, доходя до фолбэка «показать себя». Теперь ключ фокуса
|
||||
переживает переезд в мини-окно и обратно. В Safari (video-фолбэк вместо
|
||||
Document PiP) поведение не менялось.
|
||||
|
||||
## [0.0.10] — 2026-07-28
|
||||
|
||||
Разбивка метрик по контейнерам в Grafana и плашка с характеристиками сервера.
|
||||
|
||||
@@ -37,4 +37,20 @@ EXPOSE 8000
|
||||
HEALTHCHECK --interval=10s --timeout=5s --retries=10 --start-period=15s \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1
|
||||
|
||||
CMD ["uv", "run", "uvicorn", "main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# Число воркеров — из окружения (`UVICORN_WORKERS`, см. docker-compose.yml).
|
||||
# Один процесс означает, что любой блокирующий вызов в обработчике
|
||||
# останавливает весь event loop: параллельные запросы и WS-чат всех
|
||||
# участников встают в очередь. Дефолт 2, а не «по числу ядер»: медиа
|
||||
# важнее API, и на 4-ядерном сервере LiveKit в пике забирает 1.6 ядра.
|
||||
#
|
||||
# `sh -c` нужен ради подстановки переменной (exec-форма её не делает),
|
||||
# `exec` — чтобы uvicorn получил PID 1 и корректно принимал SIGTERM.
|
||||
#
|
||||
# `--no-sync`: окружение уже собрано выше (`uv sync --frozen --no-dev`), и
|
||||
# пересобирать его в рантайме незачем. Без флага `uv run` перед каждым
|
||||
# запуском заново синхронизирует venv И ПОДТЯГИВАЕТ dev-группу (ruff, mypy,
|
||||
# pytest — ~30 МБ загрузок на каждый старт контейнера, dev-инструменты в
|
||||
# проде и раздутый venv). То же касается healthcheck'ов в
|
||||
# deploy/docker-compose.yml, которые дёргают `uv run` каждые 15 секунд.
|
||||
CMD ["sh", "-c", \
|
||||
"exec uv run --no-sync uvicorn main:create_app --factory --host 0.0.0.0 --port 8000 --workers ${UVICORN_WORKERS:-2}"]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
from typing import Annotated, Any, cast
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, status
|
||||
from livekit import api
|
||||
from sqlalchemy import CursorResult
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
@@ -22,6 +22,7 @@ router = APIRouter(prefix="/api/v1/livekit", tags=["livekit"])
|
||||
@router.post("/webhook")
|
||||
async def receive_webhook(
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> dict[str, str]:
|
||||
@@ -30,6 +31,11 @@ async def receive_webhook(
|
||||
Дедупликация по `event.id`: `INSERT ... ON CONFLICT DO NOTHING` в
|
||||
`livekit_webhook_events` в одной транзакции с эффектами обработчика —
|
||||
при конфликте (дубль) эффекты пропускаются, но ответ всё равно 200.
|
||||
|
||||
Всё, что требует сети (запуск Track Egress), уходит в `background` и
|
||||
выполняется уже после ответа: обработчик держит соединение с БД и
|
||||
открытую транзакцию, а LiveKit при медленном ответе копит очередь
|
||||
доставки и в итоге дропает события (см. `services.egress.run_track_egress`).
|
||||
"""
|
||||
settings = get_settings()
|
||||
raw_body = await request.body()
|
||||
@@ -57,7 +63,7 @@ async def receive_webhook(
|
||||
await session.commit()
|
||||
return {"status": "duplicate"}
|
||||
|
||||
dispatcher = WebhookDispatcher(session)
|
||||
dispatcher = WebhookDispatcher(session, schedule=background.add_task)
|
||||
await dispatcher.dispatch(event)
|
||||
await session.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -20,6 +20,24 @@ class Settings(BaseSettings):
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
plugins_config_path: str = "../config/plugins.yaml"
|
||||
|
||||
# --- Пул соединений с БД ---
|
||||
# Дефолт SQLAlchemy (5 + 10) на нагрузочном тесте 28.07.2026 выгребался
|
||||
# за секунды: 226 ошибок `QueuePool limit of size 5 overflow 10 reached`
|
||||
# и 37 ответов 500 на путях входа в конференцию.
|
||||
#
|
||||
# ⚠️ Бюджет соединений считается на ВЕСЬ инстанс, а не на процесс: каждый
|
||||
# воркер uvicorn (`UVICORN_WORKERS`) держит собственный пул, плюс
|
||||
# соединения нужны Celery-воркерам и alembic при миграциях. При
|
||||
# `max_connections=100` у Postgres и двух воркерах 2 × (10 + 10) = 40
|
||||
# оставляет запас. Поднимая значения на более крупном сервере, поднимайте
|
||||
# и `max_connections` — иначе вместо понятной ошибки приложения получите
|
||||
# отказ Postgres, который диагностируется куда хуже.
|
||||
db_pool_size: int = 10
|
||||
db_max_overflow: int = 10
|
||||
# 10 секунд вместо дефолтных 30 — сознательно: пусть запрос падает быстро
|
||||
# и показывает проблему, а не висит полминуты, делая вид, что всё живо.
|
||||
db_pool_timeout: int = 10
|
||||
|
||||
# --- Версия инстанса (релиз v0.0.1) ---
|
||||
# install.sh копирует значение из файла `VERSION` (корень репозитория) в
|
||||
# `.env` при каждой установке/обновлении — здесь только чтение готового
|
||||
@@ -59,6 +77,13 @@ class Settings(BaseSettings):
|
||||
# (см. `deploy/docker-compose.yml`); в тестах переопределяется на `tmp_path`.
|
||||
recordings_dir: str = "/recordings"
|
||||
|
||||
# Таймаут запуска Track Egress. Когда egress-сервиса в деплое нет (профиль
|
||||
# `transcribe` не поднят), LiveKit ждёт ответа воркера через Redis до
|
||||
# собственного таймаута psrpc — на тесте 28.07.2026 это давало по 20–25
|
||||
# секунд на каждый вызов. Ждать столько бессмысленно: если egress жив, он
|
||||
# отвечает за доли секунды.
|
||||
egress_start_timeout_s: float = 3.0
|
||||
|
||||
# --- Email (SMTP-бэкенд) ---
|
||||
# `console` — дефолт для dev (письмо только логируется); `smtp` — реальная
|
||||
# отправка через aiosmtplib. Секреты SMTP — только в `.env` (инвариант №6),
|
||||
|
||||
@@ -13,7 +13,16 @@ from core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine: AsyncEngine = create_async_engine(settings.database_url, pool_pre_ping=True)
|
||||
engine: AsyncEngine = create_async_engine(
|
||||
settings.database_url,
|
||||
pool_pre_ping=True,
|
||||
# Параметры пула — в настройках (`core/config.py`, там же расчёт бюджета
|
||||
# соединений на инстанс). Дефолт SQLAlchemy 5 + 10 под нагрузкой
|
||||
# выгребался за секунды.
|
||||
pool_size=settings.db_pool_size,
|
||||
max_overflow=settings.db_max_overflow,
|
||||
pool_timeout=settings.db_pool_timeout,
|
||||
)
|
||||
|
||||
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
@@ -5,14 +5,24 @@
|
||||
транскодирования в `.ogg` на общий volume `recordings_dir`. Финализация
|
||||
результата (итоговый `location`/ошибка) приходит асинхронно через webhook
|
||||
`egress_ended` — здесь только сам запуск и `egress_id`/`started_at` из ответа.
|
||||
|
||||
Запуск вынесен из тела webhook-обработчика в фоновую задачу
|
||||
(`run_track_egress`) — см. докстринг этой функции.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core.config import get_settings
|
||||
from core.db import async_session_maker
|
||||
from repositories.conferences import AudioTrackRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -37,6 +47,10 @@ async def start_track_egress(room_name: str, track_sid: str, filepath: str) -> E
|
||||
api_secret=settings.livekit_api_secret,
|
||||
)
|
||||
try:
|
||||
# Без таймаута вызов висит до собственного таймаута psrpc LiveKit
|
||||
# (20–25 с, когда egress-воркера в деплое нет). `TimeoutError`
|
||||
# ловит вызывающая сторона наравне с прочими ошибками запуска.
|
||||
async with asyncio.timeout(settings.egress_start_timeout_s):
|
||||
info = await lkapi.egress.start_track_egress(
|
||||
api.TrackEgressRequest(
|
||||
room_name=room_name,
|
||||
@@ -56,3 +70,65 @@ async def start_track_egress(room_name: str, track_sid: str, filepath: str) -> E
|
||||
else datetime.now(UTC)
|
||||
)
|
||||
return EgressStartResult(egress_id=info.egress_id, started_at=started_at)
|
||||
|
||||
|
||||
async def run_track_egress(
|
||||
*,
|
||||
room_name: str,
|
||||
track_sid: str,
|
||||
filepath: str,
|
||||
session_id: uuid.UUID,
|
||||
participant_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Запустить Track Egress и записать строку трека — ФОНОВАЯ задача вебхука.
|
||||
|
||||
Почему не внутри обработчика. `POST /api/v1/livekit/webhook` держит
|
||||
соединение с БД и открытую транзакцию всё время своей работы (INSERT в
|
||||
`livekit_webhook_events` сделан, commit — после обработчика). Пока здесь
|
||||
жил сетевой вызов к egress, каждое событие `track_published` занимало
|
||||
соединение на 20–25 секунд, и на нагрузочном тесте 28.07.2026 пул
|
||||
выгребался за секунды: 226 ошибок `QueuePool limit`, 37 ответов 500 на
|
||||
путях входа в конференцию, 33 `dropped webhook` со стороны LiveKit
|
||||
(очередь доставки росла до 56 секунд).
|
||||
|
||||
Поэтому обработчик отвечает 200 сразу, а сюда попадает только то, что
|
||||
требует сети. Своя сессия БД (`async_session_maker`) обязательна: сессия
|
||||
запроса к этому моменту уже закрыта вместе с ответом.
|
||||
|
||||
Идемпотентность сохраняется: `AudioTrackRepository.create` — это
|
||||
`INSERT ... ON CONFLICT DO NOTHING` по `uq_session_track`, а проверка
|
||||
«трек уже пишется» осталась в обработчике.
|
||||
"""
|
||||
try:
|
||||
result = await start_track_egress(room_name, track_sid, filepath)
|
||||
except Exception as exc: # noqa: BLE001 — недоступность egress не должна ронять фон
|
||||
# Деплой-профиль (блок D): egress — необязательный сервис профиля
|
||||
# `transcribe`; без него запись просто не стартует для этого трека.
|
||||
# Строку `session_audio_tracks` не создаём — у нас нет `egress_id`,
|
||||
# по которому её мог бы финализировать `egress_ended`.
|
||||
logger.warning(
|
||||
"track_published: не удалось запустить egress для трека %s сеанса %s: %s",
|
||||
track_sid,
|
||||
session_id,
|
||||
exc,
|
||||
)
|
||||
return
|
||||
|
||||
async with async_session_maker() as session:
|
||||
await AudioTrackRepository(session).create(
|
||||
session_id=session_id,
|
||||
participant_id=participant_id,
|
||||
track_sid=track_sid,
|
||||
egress_id=result.egress_id,
|
||||
file_path=filepath,
|
||||
started_at=result.started_at,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"track_published: сеанс=%s участник=%s трек=%s egress=%s",
|
||||
session_id,
|
||||
participant_id,
|
||||
track_sid,
|
||||
result.egress_id,
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from livekit.protocol.egress import EgressStatus
|
||||
@@ -26,7 +27,7 @@ from repositories.conferences import (
|
||||
ConferenceRepository,
|
||||
ConferenceSessionRepository,
|
||||
)
|
||||
from services.egress import start_track_egress
|
||||
from services.egress import run_track_egress
|
||||
from services.instance_settings import InstanceSettingsService
|
||||
from services.pipeline_producer import enqueue_pipeline
|
||||
|
||||
@@ -53,13 +54,27 @@ def _egress_ns_to_datetime(nanoseconds: int) -> datetime | None:
|
||||
|
||||
|
||||
class WebhookDispatcher:
|
||||
"""Диспатчит `WebhookEvent` на обработчик по типу события."""
|
||||
"""Диспатчит `WebhookEvent` на обработчик по типу события.
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
`schedule` — планировщик фоновых задач: вызывается как
|
||||
`schedule(coro_func, **kwargs)` и обязан вернуть управление немедленно,
|
||||
не дожидаясь выполнения. В приложении это `BackgroundTasks.add_task`
|
||||
FastAPI (задача стартует после отправки ответа), в тестах — вызовы
|
||||
просто записываются. Через него уходит запуск Track Egress: сетевому
|
||||
вызову не место внутри транзакции вебхука (см. `_on_track_published`).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
schedule: Callable[..., object],
|
||||
) -> None:
|
||||
self._conferences = ConferenceRepository(session)
|
||||
self._sessions = ConferenceSessionRepository(session)
|
||||
self._audio_tracks = AudioTrackRepository(session)
|
||||
self._instance_settings = InstanceSettingsService(session)
|
||||
self._schedule = schedule
|
||||
|
||||
async def dispatch(self, event: WebhookEvent) -> None:
|
||||
"""Обработать одно webhook-событие; неизвестный тип события — no-op."""
|
||||
@@ -161,15 +176,32 @@ class WebhookDispatcher:
|
||||
)
|
||||
|
||||
async def _on_track_published(self, event: WebhookEvent) -> None:
|
||||
"""Запустить Track Egress для опубликованного аудиотрека микрофона (ADR-002).
|
||||
"""Запланировать Track Egress для опубликованного аудиотрека микрофона (ADR-002).
|
||||
|
||||
Видео/скриншеринг и т.п. — no-op (диаризация не нужна: транскрибируем
|
||||
только речь, трек = спикер). Идемпотентно: если строка трека уже
|
||||
существует (гонка повторной доставки), egress повторно не запускается.
|
||||
|
||||
Сам запуск уходит в фоновую задачу (`services.egress.run_track_egress`):
|
||||
здесь остаются только быстрые проверки по БД, потому что обработчик
|
||||
выполняется внутри открытой транзакции вебхука. Обоснование с цифрами —
|
||||
в докстринге `run_track_egress`.
|
||||
"""
|
||||
if event.track.type != TrackType.AUDIO or event.track.source != TrackSource.MICROPHONE:
|
||||
return
|
||||
|
||||
# Транскрибация выключена — записывать нечего. Тот же guard, что и в
|
||||
# `_on_room_finished`: без него на инстансе без профиля `transcribe`
|
||||
# (egress-контейнера в деплое нет) каждый микрофон превращался в
|
||||
# заведомо безнадёжный сетевой вызов длиной в 20–25 секунд.
|
||||
cfg = await self._instance_settings.get()
|
||||
if not cfg.transcriber.enabled:
|
||||
logger.debug(
|
||||
"livekit webhook track_published: транскрибация выключена — трек %s пропущен",
|
||||
event.track.sid,
|
||||
)
|
||||
return
|
||||
|
||||
conference = await self._conferences.get_by_slug(event.room.name)
|
||||
if conference is None:
|
||||
logger.warning(
|
||||
@@ -216,37 +248,13 @@ class WebhookDispatcher:
|
||||
filepath = (
|
||||
f"{settings.recordings_dir}/{session_record.id}/{participant.id}_{event.track.sid}.ogg"
|
||||
)
|
||||
try:
|
||||
result = await start_track_egress(event.room.name, event.track.sid, filepath)
|
||||
except Exception as exc: # noqa: BLE001 — недоступность egress не должна ронять webhook
|
||||
# Деплой-профиль (блок D): egress — необязательный сервис профиля
|
||||
# `transcribe`; без него запись просто не стартует для этого трека
|
||||
# (риск «Потерян webhook track_published»).
|
||||
# Строку `session_audio_tracks` не создаём — у нас нет `egress_id`,
|
||||
# по которому её мог бы финализировать `egress_ended`.
|
||||
logger.warning(
|
||||
"livekit webhook track_published: не удалось запустить egress для трека %s "
|
||||
"сеанса %s: %s",
|
||||
event.track.sid,
|
||||
session_record.id,
|
||||
exc,
|
||||
)
|
||||
return
|
||||
|
||||
await self._audio_tracks.create(
|
||||
self._schedule(
|
||||
run_track_egress,
|
||||
room_name=event.room.name,
|
||||
track_sid=event.track.sid,
|
||||
filepath=filepath,
|
||||
session_id=session_record.id,
|
||||
participant_id=participant.id,
|
||||
track_sid=event.track.sid,
|
||||
egress_id=result.egress_id,
|
||||
file_path=filepath,
|
||||
started_at=result.started_at,
|
||||
)
|
||||
logger.info(
|
||||
"livekit webhook track_published: сеанс=%s участник=%s трек=%s egress=%s",
|
||||
session_record.id,
|
||||
participant.id,
|
||||
event.track.sid,
|
||||
result.egress_id,
|
||||
)
|
||||
|
||||
async def _on_egress_ended(self, event: WebhookEvent) -> None:
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
цикл закреплённой/незакреплённой конференции — на фикстурах payload'ов LiveKit.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
@@ -13,10 +17,13 @@ from unittest.mock import AsyncMock, Mock
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from google.protobuf.json_format import ParseDict
|
||||
from livekit.protocol.webhook import WebhookEvent
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import services.egress as egress_module
|
||||
import services.webhook_handlers as webhook_handlers_module
|
||||
from core.config import get_settings
|
||||
from core.security import hash_password
|
||||
@@ -54,6 +61,52 @@ def _sign(body: bytes) -> str:
|
||||
return jwt.encode(payload, settings.livekit_api_secret, algorithm="HS256")
|
||||
|
||||
|
||||
def _parse_fixture_event(name: str, **placeholders: str) -> WebhookEvent:
|
||||
"""Разобрать фикстуру в `WebhookEvent` — для тестов диспатчера без HTTP-слоя."""
|
||||
return ParseDict(json.loads(_load_fixture(name, **placeholders)), WebhookEvent())
|
||||
|
||||
|
||||
async def _set_transcriber_enabled(session: AsyncSession, *, enabled: bool) -> None:
|
||||
"""Выставить `instance_settings.transcriber.enabled`.
|
||||
|
||||
Пишется тем же `db_session` (savepoint), что и обработчик webhook (подмена
|
||||
`get_session` в фикстуре `app`) — видна обработчику без реального коммита
|
||||
в dev-БД. Тесты, которым важен `track_published`, обязаны выставлять флаг
|
||||
ЯВНО: значение в dev-БД непредсказуемо, а обработчик с версии 0.0.12
|
||||
выходит на выключенной транскрибации раньше всех остальных проверок.
|
||||
"""
|
||||
value: dict[str, object] = {
|
||||
"enabled": enabled,
|
||||
"provider": "faster_whisper_cpu" if enabled else "null",
|
||||
"model": "small" if enabled else None,
|
||||
"language": "ru",
|
||||
"options": {},
|
||||
}
|
||||
await session.execute(
|
||||
pg_insert(InstanceSetting)
|
||||
.values(key="transcriber", value=value)
|
||||
.on_conflict_do_update(index_elements=["key"], set_={"value": value})
|
||||
)
|
||||
|
||||
|
||||
def _use_test_session_in_background(
|
||||
monkeypatch: pytest.MonkeyPatch, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Заставить фоновую задачу egress работать с тестовой (savepoint) сессией.
|
||||
|
||||
`run_track_egress` намеренно берёт СВОЮ сессию (`async_session_maker`):
|
||||
в бою сессия запроса к моменту фоновой задачи уже закрыта. В тестах такое
|
||||
подключение шло бы мимо откатываемой транзакции и не увидело бы ни
|
||||
конференции, ни участника — поэтому подменяем фабрику на тестовую сессию.
|
||||
"""
|
||||
|
||||
@asynccontextmanager
|
||||
async def _maker() -> AsyncGenerator[AsyncSession, None]:
|
||||
yield db_session
|
||||
|
||||
monkeypatch.setattr(egress_module, "async_session_maker", _maker)
|
||||
|
||||
|
||||
async def _post_webhook(client: httpx.AsyncClient, body: bytes) -> httpx.Response:
|
||||
return await client.post(
|
||||
WEBHOOK_URL,
|
||||
@@ -319,8 +372,10 @@ async def test_track_published_by_guest_starts_egress_and_creates_track_row(
|
||||
mock_start = AsyncMock(
|
||||
return_value=EgressStartResult(egress_id="EG_guest_track", started_at=started_at)
|
||||
)
|
||||
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start)
|
||||
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
|
||||
_use_test_session_in_background(monkeypatch, db_session)
|
||||
|
||||
await _set_transcriber_enabled(db_session, enabled=True)
|
||||
conference = await _make_conference(db_session, generate_slug())
|
||||
guest = await _make_guest(db_session, conference)
|
||||
await db_session.commit()
|
||||
@@ -382,8 +437,10 @@ async def test_track_published_survives_egress_unavailable(
|
||||
) -> None:
|
||||
"""Недоступность egress не должна ронять webhook (блок D): 200 + warning, без строки трека."""
|
||||
mock_start = AsyncMock(side_effect=RuntimeError("egress service unavailable"))
|
||||
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start)
|
||||
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
|
||||
_use_test_session_in_background(monkeypatch, db_session)
|
||||
|
||||
await _set_transcriber_enabled(db_session, enabled=True)
|
||||
conference = await _make_conference(db_session, generate_slug())
|
||||
user = await _make_user(db_session, "webhook-track-egress-down@example.com")
|
||||
await db_session.commit()
|
||||
@@ -417,13 +474,169 @@ async def test_track_published_survives_egress_unavailable(
|
||||
assert tracks == []
|
||||
|
||||
|
||||
async def test_track_published_skips_egress_when_transcription_disabled(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Транскрибация выключена → egress не дёргается вовсе (релиз 0.0.12).
|
||||
|
||||
Симметрично guard'у в `_on_room_finished`. Без него на инстансе без профиля
|
||||
`transcribe` (egress-контейнера в деплое нет) каждый микрофонный трек
|
||||
превращался в заведомо безнадёжный вызов длиной в таймаут psrpc LiveKit —
|
||||
20–25 секунд внутри открытой транзакции вебхука. На нагрузочном тесте
|
||||
28.07.2026 это выгребало пул соединений и роняло вход в конференцию в 500.
|
||||
"""
|
||||
mock_start = AsyncMock()
|
||||
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
|
||||
_use_test_session_in_background(monkeypatch, db_session)
|
||||
|
||||
await _set_transcriber_enabled(db_session, enabled=False)
|
||||
conference = await _make_conference(db_session, generate_slug())
|
||||
user = await _make_user(db_session, "webhook-track-transcriber-off@example.com")
|
||||
await db_session.commit()
|
||||
|
||||
joined = _load_fixture(
|
||||
"participant_joined.json",
|
||||
event_id=f"evt-{uuid.uuid4()}",
|
||||
room_name=conference.slug,
|
||||
identity=str(user.id),
|
||||
)
|
||||
assert (await _post_webhook(client, joined)).status_code == 200
|
||||
|
||||
track_sid = "TR_transcriber_off"
|
||||
published = _load_fixture(
|
||||
"track_published.json",
|
||||
event_id=f"evt-{uuid.uuid4()}",
|
||||
room_name=conference.slug,
|
||||
identity=str(user.id),
|
||||
track_sid=track_sid,
|
||||
)
|
||||
assert (await _post_webhook(client, published)).status_code == 200
|
||||
|
||||
mock_start.assert_not_awaited()
|
||||
tracks = (
|
||||
await db_session.scalars(
|
||||
select(SessionAudioTrack).where(SessionAudioTrack.track_sid == track_sid)
|
||||
)
|
||||
).all()
|
||||
assert tracks == []
|
||||
|
||||
|
||||
async def test_track_published_does_not_call_egress_inside_transaction(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Запуск egress уходит в фон, а не выполняется внутри обработчика (релиз 0.0.12).
|
||||
|
||||
Проверяется не время ответа (в тестах ASGI-транспорт дожидается фоновых
|
||||
задач), а сама суть: пока открыта транзакция вебхука, сетевого вызова не
|
||||
происходит — обработчик только планирует задачу. Именно это разгружает пул
|
||||
соединений: до правки вызов жил внутри транзакции и держал соединение
|
||||
20–25 секунд, когда egress-сервиса в деплое нет.
|
||||
"""
|
||||
mock_start = AsyncMock(
|
||||
return_value=EgressStartResult(egress_id="EG_bg", started_at=datetime.now(UTC))
|
||||
)
|
||||
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
|
||||
|
||||
scheduled: list[tuple[object, dict[str, object]]] = []
|
||||
|
||||
def _schedule(func: object, **kwargs: object) -> None:
|
||||
scheduled.append((func, kwargs))
|
||||
|
||||
await _set_transcriber_enabled(db_session, enabled=True)
|
||||
conference = await _make_conference(db_session, generate_slug())
|
||||
user = await _make_user(db_session, "webhook-track-background@example.com")
|
||||
await db_session.commit()
|
||||
|
||||
dispatcher = webhook_handlers_module.WebhookDispatcher(db_session, schedule=_schedule)
|
||||
joined_event = _parse_fixture_event(
|
||||
"participant_joined.json",
|
||||
event_id=f"evt-{uuid.uuid4()}",
|
||||
room_name=conference.slug,
|
||||
identity=str(user.id),
|
||||
)
|
||||
await dispatcher.dispatch(joined_event)
|
||||
|
||||
track_sid = "TR_background"
|
||||
published_event = _parse_fixture_event(
|
||||
"track_published.json",
|
||||
event_id=f"evt-{uuid.uuid4()}",
|
||||
room_name=conference.slug,
|
||||
identity=str(user.id),
|
||||
track_sid=track_sid,
|
||||
)
|
||||
await dispatcher.dispatch(published_event)
|
||||
|
||||
# Сеть не тронута: обработчик только запланировал задачу.
|
||||
mock_start.assert_not_awaited()
|
||||
assert len(scheduled) == 1
|
||||
func, kwargs = scheduled[0]
|
||||
assert func is egress_module.run_track_egress
|
||||
assert kwargs["room_name"] == conference.slug
|
||||
assert kwargs["track_sid"] == track_sid
|
||||
|
||||
session_record = await db_session.scalar(
|
||||
select(ConferenceSession).where(ConferenceSession.conference_id == conference.id)
|
||||
)
|
||||
assert session_record is not None
|
||||
assert kwargs["session_id"] == session_record.id
|
||||
|
||||
# А вот запущенная задача действительно ходит в egress и пишет строку.
|
||||
_use_test_session_in_background(monkeypatch, db_session)
|
||||
await egress_module.run_track_egress(**kwargs) # type: ignore[arg-type]
|
||||
mock_start.assert_awaited_once()
|
||||
|
||||
track_row = await db_session.scalar(
|
||||
select(SessionAudioTrack).where(SessionAudioTrack.track_sid == track_sid)
|
||||
)
|
||||
assert track_row is not None
|
||||
assert track_row.egress_id == "EG_bg"
|
||||
|
||||
|
||||
async def test_start_track_egress_gives_up_on_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Запуск egress не ждёт дольше `egress_start_timeout_s` (релиз 0.0.12).
|
||||
|
||||
Когда egress-воркера нет, LiveKit держит вызов до собственного таймаута
|
||||
psrpc — на тесте 28.07.2026 это было 20–25 секунд на каждый микрофонный
|
||||
трек. Живой egress отвечает за доли секунды, ждать столько незачем.
|
||||
"""
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, "egress_start_timeout_s", 0.05, raising=False)
|
||||
|
||||
closed = False
|
||||
|
||||
class _HangingEgress:
|
||||
async def start_track_egress(self, _request: object) -> object:
|
||||
await asyncio.sleep(5)
|
||||
raise AssertionError("вызов должен был прерваться по таймауту")
|
||||
|
||||
class _HangingApi:
|
||||
def __init__(self, *_args: object, **_kwargs: object) -> None:
|
||||
self.egress = _HangingEgress()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
nonlocal closed
|
||||
closed = True
|
||||
|
||||
# Строковая форма: `api` в `services.egress` — реэкспорт из livekit SDK,
|
||||
# обращение к нему атрибутом mypy считает неявным экспортом.
|
||||
monkeypatch.setattr("services.egress.api.LiveKitAPI", _HangingApi)
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
await egress_module.start_track_egress("room", "TR_hang", "/recordings/x.ogg")
|
||||
|
||||
# Клиент закрывается и на неуспешном пути — иначе утекали бы соединения.
|
||||
assert closed is True
|
||||
|
||||
|
||||
async def test_track_published_video_is_noop(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""№9 плана (часть 1): video-трек — no-op, egress не запускается."""
|
||||
mock_start = AsyncMock()
|
||||
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start)
|
||||
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
|
||||
_use_test_session_in_background(monkeypatch, db_session)
|
||||
|
||||
await _set_transcriber_enabled(db_session, enabled=True)
|
||||
conference = await _make_conference(db_session, generate_slug())
|
||||
user = await _make_user(db_session, "webhook-track-video@example.com")
|
||||
await db_session.commit()
|
||||
@@ -462,8 +675,10 @@ async def test_track_published_repeated_webhook_creates_single_row(
|
||||
mock_start = AsyncMock(
|
||||
return_value=EgressStartResult(egress_id="EG_repeat", started_at=datetime.now(UTC))
|
||||
)
|
||||
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start)
|
||||
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
|
||||
_use_test_session_in_background(monkeypatch, db_session)
|
||||
|
||||
await _set_transcriber_enabled(db_session, enabled=True)
|
||||
conference = await _make_conference(db_session, generate_slug())
|
||||
user = await _make_user(db_session, "webhook-track-repeat@example.com")
|
||||
await db_session.commit()
|
||||
@@ -508,6 +723,8 @@ async def test_egress_ended_finalizes_track_success_and_failure(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""№10 плана (часть 1): `egress_ended` — 'recorded' на успехе, 'failed' на ошибке."""
|
||||
_use_test_session_in_background(monkeypatch, db_session)
|
||||
await _set_transcriber_enabled(db_session, enabled=True)
|
||||
conference = await _make_conference(db_session, generate_slug())
|
||||
user = await _make_user(db_session, "webhook-egress-ended@example.com")
|
||||
await db_session.commit()
|
||||
@@ -527,7 +744,7 @@ async def test_egress_ended_finalizes_track_success_and_failure(
|
||||
# Успешная запись.
|
||||
ok_started = datetime.now(UTC)
|
||||
monkeypatch.setattr(
|
||||
webhook_handlers_module,
|
||||
egress_module,
|
||||
"start_track_egress",
|
||||
AsyncMock(return_value=EgressStartResult(egress_id="EG_ok", started_at=ok_started)),
|
||||
)
|
||||
@@ -559,7 +776,7 @@ async def test_egress_ended_finalizes_track_success_and_failure(
|
||||
|
||||
# Ошибка записи.
|
||||
monkeypatch.setattr(
|
||||
webhook_handlers_module,
|
||||
egress_module,
|
||||
"start_track_egress",
|
||||
AsyncMock(
|
||||
return_value=EgressStartResult(egress_id="EG_fail", started_at=datetime.now(UTC))
|
||||
@@ -597,6 +814,10 @@ async def test_room_finished_enqueues_pipeline(
|
||||
mock_enqueue = Mock()
|
||||
monkeypatch.setattr(webhook_handlers_module, "enqueue_pipeline", mock_enqueue)
|
||||
|
||||
# Флаг выставляется явно: `_on_room_finished` ставит задачу в очередь только
|
||||
# при включённой транскрибации, а состояние `instance_settings` в dev-БД
|
||||
# непредсказуемо (тест падал, если в базе оставалось `enabled: false`).
|
||||
await _set_transcriber_enabled(db_session, enabled=True)
|
||||
conference = await _make_conference(db_session, generate_slug())
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
name: vidconf
|
||||
|
||||
# Глубина логов рассчитана на РАЗБОР ИНЦИДЕНТОВ, а не только на просмотр
|
||||
# последних сообщений. При 10 МБ × 3 (прежнее значение) логи LiveKit на
|
||||
# конференции в полсотни человек перезаписывались за часы — а именно по ним
|
||||
# восстанавливаются вещи, которых нет в метриках: сколько камер было включено
|
||||
# одновременно, кого и почему отключило, какие события congestion шли.
|
||||
# 50 МБ × 5 = 250 МБ на контейнер; на сервере с 15 ГБ свободного места это
|
||||
# незаметно, зато ретроспектива живёт неделями.
|
||||
x-logging: &default-logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
max-size: "50m"
|
||||
max-file: "5"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
@@ -82,7 +89,12 @@ services:
|
||||
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
|
||||
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
|
||||
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
|
||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.10}
|
||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.15}
|
||||
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
|
||||
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
|
||||
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
|
||||
# (DB_POOL_SIZE + DB_MAX_OVERFLOW), а у Postgres есть max_connections.
|
||||
UVICORN_WORKERS: ${UVICORN_WORKERS:-2}
|
||||
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
|
||||
# только backend/), поэтому plugins.yaml монтируется отдельно.
|
||||
volumes:
|
||||
@@ -120,7 +132,13 @@ services:
|
||||
context: ../backend
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker", "-B",
|
||||
# `--no-sync` во ВСЕХ вызовах `uv run` в этом файле: окружение собрано на
|
||||
# этапе build образа (`uv sync --frozen --no-dev`, backend/Dockerfile), а
|
||||
# без флага `uv run` синхронизирует venv заново при каждом запуске — и
|
||||
# тянет dev-группу (ruff, mypy, pytest), которой в проде делать нечего.
|
||||
# Для healthcheck'ов это особенно дорого: они дёргаются каждые 15 секунд
|
||||
# всю жизнь контейнера.
|
||||
command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker", "-B",
|
||||
"-Q", "celery,summarize,notify", "--loglevel=info"]
|
||||
env_file:
|
||||
- ../.env
|
||||
@@ -147,7 +165,7 @@ services:
|
||||
# нет HTTP-сервера на 8000. Проверяем воркер через `celery ... inspect
|
||||
# ping`, как рекомендует документация Celery.
|
||||
healthcheck:
|
||||
test: ["CMD", "uv", "run", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5"]
|
||||
test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
@@ -181,7 +199,7 @@ services:
|
||||
build:
|
||||
context: ../backend
|
||||
dockerfile: Dockerfile
|
||||
entrypoint: ["uv", "run", "python", "/download-model.py"]
|
||||
entrypoint: ["uv", "run", "--no-sync", "python", "/download-model.py"]
|
||||
environment:
|
||||
WHISPER_MODEL: ${WHISPER_MODEL:-small}
|
||||
WHISPER_MODELS_ROOT: /models/whisper
|
||||
@@ -216,7 +234,7 @@ services:
|
||||
# `worker`, и `celery inspect ping` без `--destination` опросит ВЕСЬ
|
||||
# кластер — упавший worker-transcriber остался бы "healthy", потому что
|
||||
# ответил бы базовый worker.
|
||||
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker",
|
||||
command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker",
|
||||
"-Q", "transcription", "--pool=solo", "--concurrency=1",
|
||||
"--hostname=worker-transcriber@localhost", "--loglevel=info"]
|
||||
env_file:
|
||||
@@ -247,7 +265,7 @@ services:
|
||||
# HTTP-эндпоинта нет — пинг celery, но именно этого узла (см. --hostname
|
||||
# в command выше), а не первого ответившего в общем кластере.
|
||||
healthcheck:
|
||||
test: ["CMD", "uv", "run", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber@localhost"]
|
||||
test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber@localhost"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
@@ -272,7 +290,7 @@ services:
|
||||
args:
|
||||
WITH_GPU_EXTRA: "true"
|
||||
restart: unless-stopped
|
||||
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker",
|
||||
command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker",
|
||||
"-Q", "transcription", "--pool=solo", "--concurrency=1",
|
||||
"--hostname=worker-transcriber-gpu@localhost", "--loglevel=info"]
|
||||
env_file:
|
||||
@@ -305,7 +323,7 @@ services:
|
||||
whisper-model-init:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test: ["CMD", "uv", "run", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber-gpu@localhost"]
|
||||
test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber-gpu@localhost"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
@@ -653,10 +671,26 @@ services:
|
||||
# Тег закреплён по версии (не `:latest`) — та же причина, что и у llm-образов.
|
||||
image: prom/prometheus:v3.13.1
|
||||
restart: unless-stopped
|
||||
# Первые два флага — дефолт образа; повторяем их явно, потому что
|
||||
# `command` перекрывает CMD целиком. Третий — срок хранения: дефолтных
|
||||
# 15 суток мало, когда нагрузку набирают неделями (наблюдение за
|
||||
# реальными конференциями вместо разового теста), и разбирать её потом
|
||||
# приходится задним числом. 30 суток при нынешних 100 МБ TSDB стоят
|
||||
# копеек — база растёт медленнее, чем кажется.
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--storage.tsdb.retention.time=30d'
|
||||
volumes:
|
||||
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- ./monitoring/alerts.yml:/etc/prometheus/alerts.yml:ro
|
||||
- prometheus_data:/prometheus
|
||||
# node-exporter живёт в host-сети (см. комментарий у него) и по имени
|
||||
# сервиса в docker-сети больше не резолвится. `host-gateway` — штатный
|
||||
# способ дать контейнеру адрес хоста, не завязываясь на конкретный IP
|
||||
# docker-моста.
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
# Loopback-only: админ-доступ по ssh-туннелю, наружу не публикуется.
|
||||
ports:
|
||||
- "127.0.0.1:9090:9090"
|
||||
@@ -706,13 +740,27 @@ services:
|
||||
|
||||
node-exporter:
|
||||
# Метрики железа хоста (CPU, память, диск, сеть, load average) — то,
|
||||
# чего нет ни в одном из приложенческих экспортеров выше. Без
|
||||
# `network_mode: host` (не нужен: читаем /proc,/sys,/ хоста через
|
||||
# bind-mount, а Prometheus достаёт их по имени сервиса во внутренней
|
||||
# сети compose — так безопаснее, не расширяет сетевой доступ контейнера).
|
||||
# чего нет ни в одном из приложенческих экспортеров выше.
|
||||
#
|
||||
# `network_mode: host` ОБЯЗАТЕЛЕН, и вот почему (проверено 2026-07-28,
|
||||
# до этого экспортер работал в bridge-сети и отдавал неверные данные).
|
||||
# Bind-mount'а `/proc` достаточно для CPU, памяти и диска, но НЕ для сети:
|
||||
# `/proc/net` — это симлинк на `self/net`, который резолвится в сетевом
|
||||
# namespace ЧИТАЮЩЕГО процесса. В bridge-сети экспортер видел собственные
|
||||
# `lo` и `eth0` (56 МБ трафика) вместо хостового `enp3s0` (39.8 ГБ), то
|
||||
# есть `node_network_*` показывал трафик контейнера, а не сервера. При
|
||||
# разборе нагрузочного теста 28.07 сетевых метрик хоста не оказалось
|
||||
# вовсе — см. .forcc/LOAD-FINDINGS.md.
|
||||
#
|
||||
# Порт 9100 при этом слушается на хосте. Наружу он не торчит: ufw
|
||||
# пропускает только 22/80/443/3478/7881/51820 и UDP-диапазон LiveKit
|
||||
# (проверено `ufw status`). Prometheus обращается к нему через
|
||||
# `host.docker.internal` (см. `extra_hosts` у сервиса prometheus и
|
||||
# таргет `node` в deploy/monitoring/prometheus.yml).
|
||||
image: prom/node-exporter:v1.8.2
|
||||
restart: unless-stopped
|
||||
pid: host
|
||||
network_mode: host
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
@@ -722,9 +770,8 @@ services:
|
||||
- '--path.sysfs=/host/sys'
|
||||
- '--path.rootfs=/rootfs'
|
||||
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
|
||||
# Не публикуем порт наружу вообще (не 127.0.0.1:9100, а совсем без
|
||||
# ports) — Prometheus ходит к нему по внутренней сети compose
|
||||
# (`node-exporter:9100`), публикация на хост для этого не нужна.
|
||||
# Секции `ports` нет и с host-сетью быть не может: контейнер слушает
|
||||
# прямо на интерфейсах хоста. От внешнего мира порт закрывает ufw.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9100/metrics >/dev/null || exit 1"]
|
||||
interval: 10s
|
||||
|
||||
@@ -32,6 +32,40 @@ rtc:
|
||||
use_external_ip: ${LIVEKIT_USE_EXTERNAL_IP}
|
||||
node_ip: ${LIVEKIT_NODE_IP}
|
||||
|
||||
# Внешний TURN (сервис coturn, профиль `media`) — АНОНС КЛИЕНТАМ.
|
||||
# Сам SFU через эти серверы не ходит: LiveKit лишь отдаёт их браузеру в
|
||||
# списке ICE-серверов при подключении (см. iceServersForParticipant в
|
||||
# pkg/service/roommanager.go), а клиент уже решает, нужен ли ему relay.
|
||||
#
|
||||
# Зачем. До 28.07.2026 coturn работал, но КЛИЕНТЫ О НЁМ НЕ ЗНАЛИ: секция
|
||||
# `turn` ниже выключена (встроенный TURN не поднимаем), внешний в конфиге
|
||||
# объявлен не был, а фронтенд `iceServers` не задаёт. За всё время работы
|
||||
# в логах coturn — ноль ALLOCATE. Итог: у клиентов из сетей с жёстким NAT
|
||||
# не было relay-фолбэка вообще, только прямой UDP и TCP 7881. Именно так
|
||||
# объясняются `PEER_CONNECTION_DISCONNECTED` на нагрузочном тесте — все
|
||||
# у внешних участников, ни одного у офисных (.forcc/LOAD-FINDINGS.md,
|
||||
# причина C).
|
||||
#
|
||||
# `secret` обязан совпадать с `static-auth-secret` в turnserver.conf —
|
||||
# оба рендерятся из одного TURN_STATIC_AUTH_SECRET (deploy/render-templates.sh).
|
||||
# Логин/пароль LiveKit генерирует сам по механизму TURN REST API.
|
||||
#
|
||||
# UDP и TCP на 3478 — оба порта уже открыты в ufw. TLS (5349) намеренно не
|
||||
# объявляем: в turnserver.conf сертификаты не смонтированы, и анонс
|
||||
# неработающего `turns:` заставил бы клиента впустую ждать таймаута,
|
||||
# прежде чем перейти к рабочему кандидату.
|
||||
turn_servers:
|
||||
- host: ${TURN_EXTERNAL_IP}
|
||||
port: 3478
|
||||
protocol: udp
|
||||
secret: ${TURN_STATIC_AUTH_SECRET}
|
||||
ttl: 14400
|
||||
- host: ${TURN_EXTERNAL_IP}
|
||||
port: 3478
|
||||
protocol: tcp
|
||||
secret: ${TURN_STATIC_AUTH_SECRET}
|
||||
ttl: 14400
|
||||
|
||||
# Redis обязателен для сервиса egress (см. deploy/egress/) — он использует
|
||||
# его как pub/sub и key-value хранилище состояния запущенных записей;
|
||||
# без него egress не может получать room/track-события от LiveKit
|
||||
|
||||
@@ -38,9 +38,16 @@ scrape_configs:
|
||||
# — сервис node-exporter). Единственный источник, который покажет
|
||||
# нехватку памяти/CPU на сервере, если она не проявится как рост
|
||||
# латентности API (см. дашборд host.json).
|
||||
#
|
||||
# Адрес `host.docker.internal`, а не `node-exporter:9100`: с 2026-07-28
|
||||
# экспортер работает в host-сети и по имени сервиса в docker-сети не
|
||||
# резолвится. Причина перевода — сетевые метрики: `/proc/net` это симлинк
|
||||
# на `self/net`, поэтому в bridge-сети экспортер отдавал трафик
|
||||
# собственного `eth0` вместо хостового `enp3s0`. Имя резолвится через
|
||||
# `extra_hosts: host-gateway` у сервиса prometheus (deploy/docker-compose.yml).
|
||||
- job_name: node
|
||||
static_configs:
|
||||
- targets: ["node-exporter:9100"]
|
||||
- targets: ["host.docker.internal:9100"]
|
||||
|
||||
# Метрики по каждому контейнеру (CPU/память/сеть отдельно у backend,
|
||||
# worker, postgres и т.д. — профиль monitoring, сервис
|
||||
@@ -53,6 +60,32 @@ scrape_configs:
|
||||
static_configs:
|
||||
- targets: ["container-exporter:9419"]
|
||||
|
||||
# LiveKit SFU (профиль `media`). Порт объявлен в самом LiveKit —
|
||||
# `deploy/livekit/livekit.yaml.template`, секция `prometheus: port: 6789`;
|
||||
# наружу он не публикуется, скрейп идёт по имени сервиса внутри docker-сети.
|
||||
#
|
||||
# Почему это важно отдельно от `container-exporter`: тот показывает CPU,
|
||||
# память и суммарный трафик контейнера, но ничего не знает о том, ЧТО внутри
|
||||
# этого трафика. Разбор нагрузочного теста 28.07.2026 пришлось делать по
|
||||
# логам именно потому, что job'а здесь не было (см. .forcc/LOAD-FINDINGS.md).
|
||||
#
|
||||
# Ключевое, что отсюда появляется:
|
||||
# livekit_track_subscribed_total / livekit_track_published_total —
|
||||
# подписки против публикаций, то есть прямой эффект adaptiveStream;
|
||||
# livekit_participant_total, livekit_room_total — нагрузка в участниках;
|
||||
# livekit_quality_score, livekit_packet_loss_percent, livekit_rtt_ms,
|
||||
# livekit_jitter_us, livekit_nack_total, livekit_pli_total — качество
|
||||
# связи у клиентов, а не догадки по событиям congestion в логах;
|
||||
# livekit_webhook_queue_length, livekit_webhook_dispatch_total — очередь
|
||||
# доставки вебхуков в backend (на тесте она росла до 56 секунд).
|
||||
#
|
||||
# Профиль `media` входит в дефолтный набор COMPOSE_PROFILES (см. .env.example),
|
||||
# поэтому job включён, а не закомментирован, как `llm`. На инсталляции без
|
||||
# профиля `media` таргет не резолвится — закомментируйте секцию.
|
||||
- job_name: livekit
|
||||
static_configs:
|
||||
- targets: ["livekit:6789"]
|
||||
|
||||
# Локальный LLM-сервер (llama.cpp, LLAMA_ARG_ENDPOINT_METRICS=1). Адрес
|
||||
# `llm:8080` разрешается ОДНИМ из двух compose-сервисов в зависимости от
|
||||
# выбранного при установке пресета — `llm` (CPU, профиль `llm`, уровни
|
||||
|
||||
@@ -49,7 +49,10 @@ envsubst '${TURN_STATIC_AUTH_SECRET} ${TURN_REALM} ${TURN_EXTERNAL_IP}' \
|
||||
< "$SCRIPT_DIR/coturn/turnserver.conf.template" > "$SCRIPT_DIR/coturn/turnserver.conf"
|
||||
echo "[render] deploy/coturn/turnserver.conf готов"
|
||||
|
||||
envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD}' \
|
||||
# TURN_EXTERNAL_IP и TURN_STATIC_AUTH_SECRET нужны и здесь: с 0.0.14 LiveKit
|
||||
# анонсирует клиентам внешний coturn (секция `rtc.turn_servers`), и секрет
|
||||
# обязан совпадать с `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}' \
|
||||
< "$SCRIPT_DIR/livekit/livekit.yaml.template" > "$SCRIPT_DIR/livekit/livekit.yaml"
|
||||
echo "[render] deploy/livekit/livekit.yaml готов"
|
||||
|
||||
|
||||
@@ -82,9 +82,25 @@ ufw allow 80/tcp # HTTP (редирект на HTTPS + ACME-challenge)
|
||||
ufw allow 443/tcp # HTTPS
|
||||
ufw allow 7881/tcp # LiveKit RTC TCP fallback (профиль media)
|
||||
ufw allow 54000:54100/udp # LiveKit WebRTC media (ICE), см. docker-compose.yml
|
||||
# TURN (coturn) — только если включаете раздел 8:
|
||||
# TURN (coturn) — только если включаете раздел 8. Нужны ОБА пункта:
|
||||
# сигнальные порты И диапазон relay-аллокаций (min-port/max-port из
|
||||
# deploy/coturn/turnserver.conf). Без второго TURN отвечает на запросы, но
|
||||
# сам релей не работает — клиент получает кандидата и не может им
|
||||
# воспользоваться, а в логах coturn при этом тишина.
|
||||
# ufw allow 3478/tcp
|
||||
# ufw allow 3478/udp
|
||||
# ufw allow 49160:49200/udp
|
||||
|
||||
# Мониторинг (профиль `monitoring`): node-exporter работает в host-сети —
|
||||
# иначе он отдаёт сетевые метрики собственного контейнера вместо метрик
|
||||
# сервера (`/proc/net` — симлинк на `self/net`, bind-mount `/proc` этого не
|
||||
# обходит; см. комментарий у сервиса в deploy/docker-compose.yml). Порт
|
||||
# слушается на хосте, поэтому Prometheus в docker-сети упирается в
|
||||
# политику ufw по умолчанию. Правило разрешает скрейп ТОЛЬКО из внутренних
|
||||
# docker-подсетей — снаружи 9100 остаётся закрыт (172.16.0.0/12 не
|
||||
# маршрутизируется в интернете):
|
||||
ufw allow from 172.16.0.0/12 to any port 9100 proto tcp comment 'node-exporter: скрейп Prometheus из docker-сети'
|
||||
|
||||
ufw enable
|
||||
```
|
||||
|
||||
@@ -309,29 +325,48 @@ firewall) хватает для подавляющего большинства
|
||||
(характерный симптом — конференция подключается по signaling, `connection
|
||||
state: connected`, но собеседник не видит видео/не слышит звук).
|
||||
|
||||
По умолчанию `deploy/livekit/livekit.yaml.template` содержит
|
||||
`turn.enabled: false`, и `rtc.turn_servers` не задан — standalone coturn
|
||||
поднимается (профиль `media`), но LiveKit не раздаёт его клиентам как
|
||||
ICE-фолбэк.
|
||||
**С версии 0.0.14 LiveKit анонсирует coturn клиентам** — секция
|
||||
`rtc.turn_servers` в `deploy/livekit/livekit.yaml.template` (UDP и TCP на
|
||||
3478, credentials по механизму TURN REST API из общего
|
||||
`TURN_STATIC_AUTH_SECRET`). Встроенный TURN LiveKit при этом остаётся
|
||||
выключенным (`turn.enabled: false`), чтобы не поднимать два TURN-сервера.
|
||||
|
||||
Включение (правки шаблона `deploy/livekit/livekit.yaml.template` +
|
||||
редеплой; **код-фикс не входит в это руководство без запроса** — обсудите
|
||||
с командой перед изменением):
|
||||
⚠️ **Чем это было до 0.0.14, если вы обновляетесь со старой версии.** coturn
|
||||
поднимался и был healthy, но клиенты о нём не знали: в конфиге LiveKit
|
||||
внешний TURN объявлен не был, а фронтенд `iceServers` не задаёт. За всё
|
||||
время работы в логах coturn не было ни одного ALLOCATE — то есть relay не
|
||||
использовался никогда, и участники из сетей с жёстким NAT просто теряли
|
||||
соединение (`PEER_CONNECTION_DISCONNECTED`).
|
||||
|
||||
1. Открыть 443 для TURN/TLS (наиболее надёжный фолбэк — TURN через тот же
|
||||
порт, что и остальной HTTPS-трафик, редко блокируется firewall'ами):
|
||||
потребует отдельного TLS-сертификата для coturn (`cert-file`/`pkey-file`
|
||||
в `deploy/coturn/turnserver.conf.template`) — можно переиспользовать тот
|
||||
же Let's Encrypt сертификат, что и nginx (тот же `/etc/letsencrypt`, уже
|
||||
смонтированный в nginx — coturn сейчас его не монтирует, потребуется
|
||||
доп. volume).
|
||||
2. В `livekit.yaml.template` включить `turn.enabled: true` и/или явно
|
||||
прописать `rtc.turn_servers` со статическими credentials
|
||||
(`TURN_STATIC_AUTH_SECRET` уже есть в `.env`).
|
||||
3. `ufw allow 3478/tcp` + `ufw allow 3478/udp` (шаг 1, закомментированные
|
||||
строки) — сейчас coturn поднят, но порт не проверялся как обязательный.
|
||||
4. Передеплой (`docker compose ... up -d --force-recreate livekit coturn`)
|
||||
и повторный кросс-сетевой тест именно с проблемной сетью.
|
||||
Что нужно проверить на своей инсталляции:
|
||||
|
||||
1. **Порты в ufw — оба пункта** (см. шаг 1): `3478/tcp` + `3478/udp` для
|
||||
сигнализации и `49160:49200/udp` для relay-аллокаций. Диапазон должен
|
||||
совпадать с `min-port`/`max-port` в
|
||||
`deploy/coturn/turnserver.conf.template`. Без него TURN отвечает на
|
||||
запросы, но релей не работает — самый неприятный вариант, потому что в
|
||||
логах coturn при этом тишина.
|
||||
2. **`TURN_EXTERNAL_IP` в `.env`** — реальный внешний IP или домен сервера.
|
||||
Именно это значение уезжает клиентам как адрес TURN-сервера, поэтому
|
||||
`127.0.0.1` из dev-дефолта сделает анонс бесполезным.
|
||||
3. После правок — `./deploy/render-templates.sh` (перерендерит конфиги из
|
||||
шаблонов), затем `docker compose ... up -d --force-recreate livekit`.
|
||||
⚠️ Перезапуск LiveKit **разрывает все активные конференции** — выбирайте
|
||||
окно.
|
||||
4. Проверка, что релей заработал: провести звонок из проблемной сети и
|
||||
убедиться, что в логах появились аллокации:
|
||||
`docker logs vidconf-coturn-1 --since 10m 2>&1 | grep -ci allocate`.
|
||||
Ноль при живом звонке из-за NAT означает, что до coturn не дошли —
|
||||
смотрите ufw и `TURN_EXTERNAL_IP`.
|
||||
|
||||
**TURN over TLS (порт 5349 или 443) — не настроен.** Это самый надёжный
|
||||
фолбэк (проходит там, где режут UDP и нестандартные порты), но требует
|
||||
смонтировать в coturn TLS-сертификат: раскомментировать `cert`/`pkey` в
|
||||
`deploy/coturn/turnserver.conf.template`, добавить volume с
|
||||
`/etc/letsencrypt` (nginx его уже монтирует, coturn — нет), открыть порт и
|
||||
не забыть про перезапуск coturn при обновлении сертификата. Пока этого нет,
|
||||
`turns:` намеренно не анонсируется: анонс неработающего адреса заставил бы
|
||||
клиента ждать таймаута перед переходом к рабочему кандидату.
|
||||
|
||||
---
|
||||
|
||||
@@ -389,7 +424,7 @@ docker exec vidconf-postgres-1 pg_dump -U vidconf vidconf | gzip > db-$(date +%F
|
||||
```bash
|
||||
gunzip -c db-2026-07-25.sql.gz | docker exec -i vidconf-postgres-1 psql -U vidconf vidconf
|
||||
# Догнать миграции, если бэкап снят на более старой версии кода:
|
||||
docker compose -f deploy/docker-compose.yml --env-file .env run --rm backend uv run alembic upgrade head
|
||||
docker compose -f deploy/docker-compose.yml --env-file .env run --rm backend uv run --no-sync alembic upgrade head
|
||||
docker compose -f deploy/docker-compose.yml --env-file .env restart backend worker
|
||||
```
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ CPU-only — отдельного GPU-варианта профилей для
|
||||
|
||||
Поведение:
|
||||
- **Дефолт (Enter):** применяет матрицу выбранного пресета к настройкам БД
|
||||
(defs: `docker compose exec -T backend uv run python -m scripts.apply_preset_settings --force`)
|
||||
(defs: `docker compose exec -T backend uv run --no-sync python -m scripts.apply_preset_settings --force`)
|
||||
- **Отказ (`n`):** сохраняет ручные правки админа; переменные `BOOTSTRAP_*` в `.env`
|
||||
обновляются, но скрипт применения настроек НЕ запускается
|
||||
- **Флаг `--yes`:** автоматически применяет пресет без вопроса (для CI/CD)
|
||||
@@ -111,7 +111,7 @@ Grafana/Prometheus не поднимаются автоматически — с
|
||||
собирает фронтенд-SPA и вкомпилирует статику, `frontend/Dockerfile`).
|
||||
4. Поднимает `postgres`/`redis` (`up -d --wait`) и применяет **до старта
|
||||
backend** миграции и seed одноразовыми контейнерами:
|
||||
`docker compose run --rm backend uv run alembic upgrade head` +
|
||||
`docker compose run --rm backend uv run --no-sync alembic upgrade head` +
|
||||
`... python -m scripts.seed`. Порядок критичен: `backend.lifespan`
|
||||
бутстрапит `instance_settings` при каждом старте приложения, поэтому на
|
||||
чистой БД таблицы обязаны существовать до первого запуска backend — иначе
|
||||
@@ -122,9 +122,8 @@ Grafana/Prometheus не поднимаются автоматически — с
|
||||
настройки инстанса не перетираются).
|
||||
5. Поднимает остальной стек: `docker compose <--profile ...> up -d --wait`
|
||||
(backend, worker, nginx с фронтом + сервисы активных профилей). Команда
|
||||
идемпотентна и обёрнута в ретрай (до 3 попыток): первый старт backend/worker
|
||||
включает `uv run` (синхронизация окружения + компиляция байткода), и на
|
||||
слабой/загруженной машине healthcheck может не успеть за отведённые
|
||||
идемпотентна и обёрнута в ретрай (до 3 попыток): на слабой/загруженной
|
||||
машине healthcheck может не успеть за отведённые
|
||||
retries — повтор лишь дожидается уже стартующих контейнеров.
|
||||
6. Печатает сводку: URL фронтенда/бэкенда, учётные данные администратора,
|
||||
команда для `--profile monitoring`.
|
||||
|
||||
@@ -63,7 +63,7 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
# Без -B: beat уже запущен на базовом worker (см. правило выше).
|
||||
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker",
|
||||
command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker",
|
||||
"-Q", "summarize", "--hostname=worker-summarize-%h@%h", "--loglevel=info"]
|
||||
env_file:
|
||||
- ../.env
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
|
||||
import { AlertCircle, Send, X } from 'lucide-react'
|
||||
import { AlertCircle, Send, SmilePlus, X } from 'lucide-react'
|
||||
import type { ChatConnectionStatus, ChatMessageOut } from '@/hooks/useChat'
|
||||
import { formatLocalTime } from '@/lib/localTime'
|
||||
import { useModalDismiss } from '@/hooks/useModalDismiss'
|
||||
|
||||
/**
|
||||
* Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на
|
||||
* сотни килобайт ради десятка кнопок в поповере.
|
||||
*/
|
||||
const EMOJI_OPTIONS = [
|
||||
'😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢',
|
||||
'😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️',
|
||||
'🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝',
|
||||
]
|
||||
|
||||
interface ChatPanelProps {
|
||||
messages: ChatMessageOut[]
|
||||
@@ -38,12 +49,21 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
// Авторасширение textarea до 4 строк, дальше — внутренний скролл. Эффектом,
|
||||
// а не только в обработчике ввода, — так пересчёт срабатывает и после
|
||||
// программной вставки эмодзи, и после очистки поля отправкой.
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${Math.min(el.scrollHeight, 96)}px`
|
||||
}, [draft])
|
||||
|
||||
function handleSend() {
|
||||
const text = draft.trim()
|
||||
if (!text || status !== 'open') return
|
||||
onSend(text)
|
||||
setDraft('')
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
@@ -55,10 +75,27 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
|
||||
|
||||
function handleDraftChange(event: ChangeEvent<HTMLTextAreaElement>) {
|
||||
setDraft(event.target.value)
|
||||
// Авторасширение textarea до 4 строк, дальше — внутренний скролл.
|
||||
const el = event.target
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${Math.min(el.scrollHeight, 96)}px`
|
||||
}
|
||||
|
||||
// Вставка эмодзи в позицию курсора, а не в конец поля, — ожидаемое поведение
|
||||
// при редактировании текста в середине.
|
||||
function insertEmoji(emoji: string) {
|
||||
const el = textareaRef.current
|
||||
if (!el) {
|
||||
setDraft((current) => current + emoji)
|
||||
return
|
||||
}
|
||||
const start = el.selectionStart ?? draft.length
|
||||
const end = el.selectionEnd ?? draft.length
|
||||
setDraft(draft.slice(0, start) + emoji + draft.slice(end))
|
||||
// value обновится реальным React-рендером только на следующий тик —
|
||||
// курсор возвращаем после него, иначе setSelectionRange встанет на
|
||||
// ещё старую (более короткую) строку.
|
||||
requestAnimationFrame(() => {
|
||||
const pos = start + emoji.length
|
||||
el.focus()
|
||||
el.setSelectionRange(pos, pos)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -101,6 +138,7 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
|
||||
handleSend()
|
||||
}}
|
||||
>
|
||||
<EmojiPickerButton onSelect={insertEmoji} disabled={status !== 'open'} />
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
@@ -118,3 +156,77 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
interface EmojiPickerButtonProps {
|
||||
onSelect: (emoji: string) => void
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Кнопка с поповером выбора эмодзи слева от поля ввода. Поповер открывается
|
||||
* ВВЕРХ от кнопки (как `.tb-menu` тулбара) — на мобильном чат-панель занята
|
||||
* полем ввода снизу, а место над тулбаром свободно, так поповер никогда не
|
||||
* перекрывает textarea.
|
||||
*/
|
||||
function EmojiPickerButton({ onSelect, disabled }: EmojiPickerButtonProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Закрытие по клику вне — тем же паттерном, что меню «Вид» (StageViewMenu):
|
||||
// слушатель на document, пока поповер открыт.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="chat-emoji-wrap" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`chat-emoji-trigger${open ? ' is-open' : ''}`}
|
||||
aria-label="Вставить эмодзи"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<SmilePlus className="lucide" aria-hidden="true" />
|
||||
</button>
|
||||
{open && (
|
||||
<EmojiPopover
|
||||
onSelect={(emoji) => {
|
||||
onSelect(emoji)
|
||||
setOpen(false)
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface EmojiPopoverProps {
|
||||
onSelect: (emoji: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** Отдельный компонент — чтобы `useModalDismiss` (Escape + возврат фокуса) жил ровно то время, пока поповер открыт. */
|
||||
function EmojiPopover({ onSelect, onClose }: EmojiPopoverProps) {
|
||||
useModalDismiss(onClose)
|
||||
|
||||
return (
|
||||
<div className="chat-emoji-popover" role="dialog" aria-label="Выбор эмодзи">
|
||||
{EMOJI_OPTIONS.map((emoji) => (
|
||||
<button key={emoji} type="button" className="chat-emoji-option" onClick={() => onSelect(emoji)}>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
|
||||
import { useModalDismiss } from '@/hooks/useModalDismiss'
|
||||
import { StageViewOptions, type StageViewProps } from '@/components/room/StageViewOptions'
|
||||
import { isAudioOutputSelectable, saveAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
||||
|
||||
interface DeviceSettingsDialogProps {
|
||||
interface DeviceSettingsDialogProps extends StageViewProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** Совпадает с мобильным брейкпоинтом комнаты (`room.css`, `max-width: 600px`) — ниже него панель рендерится шторкой снизу вместо модалки. */
|
||||
const COMPACT_VIEWPORT_QUERY = '(max-width: 600px)'
|
||||
/** Свайп ручки шторки вниз дальше этого порога (px) закрывает панель, меньше — она возвращается на место. */
|
||||
const SHEET_DISMISS_THRESHOLD_PX = 80
|
||||
|
||||
@@ -19,20 +19,6 @@ function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string):
|
||||
return device.label || `${fallback} ${index + 1}`
|
||||
}
|
||||
|
||||
/** Живое отслеживание мобильной ширины — та же схема, что системная тема в `useTheme.ts` (matchMedia + change-листенер). */
|
||||
function useIsCompactViewport(): boolean {
|
||||
const [isCompact, setIsCompact] = useState(() => window.matchMedia(COMPACT_VIEWPORT_QUERY).matches)
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(COMPACT_VIEWPORT_QUERY)
|
||||
const handleChange = (event: MediaQueryListEvent) => setIsCompact(event.matches)
|
||||
media.addEventListener('change', handleChange)
|
||||
return () => media.removeEventListener('change', handleChange)
|
||||
}, [])
|
||||
|
||||
return isCompact
|
||||
}
|
||||
|
||||
/**
|
||||
* Панель «Настройки устройств» — три селекта на хуках `@livekit/components-react`:
|
||||
* список устройств и переключение целиком в `useMediaDeviceSelect` (сама
|
||||
@@ -47,12 +33,23 @@ function useIsCompactViewport(): boolean {
|
||||
* модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз за ручку.
|
||||
* Десктоп не меняется.
|
||||
*
|
||||
* Там же, и только там, первой секцией идёт «Вид» (режим показа участников и
|
||||
* скрытие остальных): на мобильном тулбар ужат до пяти кнопок, отдельной
|
||||
* кнопки «Вид» там нет — см. `RoomToolbar`. Поэтому и заголовок панели на
|
||||
* мобильном шире по смыслу («Настройки», а не «Настройки устройств»).
|
||||
*
|
||||
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без явно
|
||||
* переданного `room` берёт активную комнату из `RoomContext` — вне контекста
|
||||
* он создал бы отдельный, ни с чем не связанный `Room()` и переключал бы
|
||||
* устройство «в никуда».
|
||||
*/
|
||||
export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
|
||||
export function DeviceSettingsDialog({
|
||||
onClose,
|
||||
layoutMode,
|
||||
onLayoutModeChange,
|
||||
hideOthers,
|
||||
onHideOthersChange,
|
||||
}: DeviceSettingsDialogProps) {
|
||||
const toast = useToast()
|
||||
const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices()
|
||||
const mic = useMediaDeviceSelect({ kind: 'audioinput' })
|
||||
@@ -143,12 +140,26 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
|
||||
)}
|
||||
|
||||
<div className="room-modal-head">
|
||||
<h2 id="device-settings-title">Настройки устройств</h2>
|
||||
<h2 id="device-settings-title">{isCompact ? 'Настройки' : 'Настройки устройств'}</h2>
|
||||
<button type="button" className="room-modal-close" aria-label="Закрыть" onClick={onClose}>
|
||||
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Обёртка НЕ `.room-field`: там `label { display: block }` со
|
||||
специфичностью выше, чем у `.stage-view-mode` — пункты режимов
|
||||
рассыпались бы в столбик (кружок отдельно, текст под ним). */}
|
||||
{isCompact && (
|
||||
<div className="stage-view-section">
|
||||
<StageViewOptions
|
||||
layoutMode={layoutMode}
|
||||
onLayoutModeChange={onLayoutModeChange}
|
||||
hideOthers={hideOthers}
|
||||
onHideOthersChange={onHideOthersChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="room-field">
|
||||
<label htmlFor="device-settings-mic">Микрофон</label>
|
||||
<select
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { EyeOff, Mic, MicOff, Users } from 'lucide-react'
|
||||
import { Track, type Participant } from 'livekit-client'
|
||||
import {
|
||||
CarouselLayout,
|
||||
FocusLayoutContainer,
|
||||
GridLayout,
|
||||
RoomAudioRenderer,
|
||||
isTrackReference,
|
||||
useRoomContext,
|
||||
useSpeakingParticipants,
|
||||
useTrackToggle,
|
||||
useTracks,
|
||||
type TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-react'
|
||||
import { RoomParticipantTile } from '@/components/room/RoomParticipantTile'
|
||||
import { StageGrid } from '@/components/room/StageGrid'
|
||||
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
|
||||
import { pickStageFocus, stageTrackKey } from '@/components/room/stageFocus'
|
||||
import type { StageLayoutMode } from '@/lib/stageLayoutMode'
|
||||
|
||||
/**
|
||||
* Стабильная (модульная, не пересоздаётся на каждый рендер) ссылка на
|
||||
@@ -80,10 +84,80 @@ function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant
|
||||
return holdMs <= 0 ? speakers : steady
|
||||
}
|
||||
|
||||
/**
|
||||
* Есть ли у трека ЖИВОЕ видео: это настоящий трек (не плейсхолдер выключенной
|
||||
* камеры) и он не в мьюте. По этому признаку режим «Живые плитки» делит
|
||||
* участников на сетку и карусель, а `pickStageFocus` отдаёт предпочтение
|
||||
* говорящему с картинкой.
|
||||
*/
|
||||
function hasLiveVideo(track: TrackReferenceOrPlaceholder): boolean {
|
||||
return isTrackReference(track) && !track.publication.isMuted
|
||||
}
|
||||
|
||||
/**
|
||||
* Кнопка микрофона поверх единственной плитки мини-плеера (задача A5): в
|
||||
* Document PiP своего тулбара нет вовсе, а звонок продолжается — без неё
|
||||
* замьютить себя можно, только развернув основное окно. `useTrackToggle` —
|
||||
* тот же хук, что и в `RoomToolbar`, читает/меняет состояние ЧЕРЕЗ
|
||||
* `RoomContext`, а не через DOM, поэтому работает одинаково что в основном
|
||||
* окне, что в портале, рендерящем эту сцену внутрь `pipWindow.document.body`
|
||||
* (React-контекст порталом не разрывается, в отличие от DOM-дерева).
|
||||
*/
|
||||
function PipMicToggle() {
|
||||
const mic = useTrackToggle({ source: Track.Source.Microphone })
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...mic.buttonProps}
|
||||
className={`room-pip-mic-toggle${mic.enabled ? '' : ' is-off'}`}
|
||||
aria-label={mic.enabled ? 'Выключить микрофон' : 'Включить микрофон'}
|
||||
>
|
||||
{mic.enabled ? <Mic className="lucide" aria-hidden="true" /> : <MicOff className="lucide" aria-hidden="true" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Основная сцена конференции: превью остальных участников + крупная плитка
|
||||
* активного спикера (FocusLayoutContainer + CarouselLayout при нескольких
|
||||
* участниках, GridLayout при одном/двух).
|
||||
* участниках, равномерная сетка при одном/двух).
|
||||
*
|
||||
* РЕЖИМЫ ПОКАЗА (`layoutMode`, выбор пользователя, см. `lib/stageLayoutMode.ts`):
|
||||
* - `standard` — как было до 0.0.11: крупная плитка (кого показать, решает
|
||||
* `pickStageFocus`) + карусель остальных сбоку;
|
||||
* - `tiles` — все участники равными плитками (`StageGrid`), без фокуса;
|
||||
* - `live-tiles` — сетка только из участников с включённой камерой,
|
||||
* остальные (плейсхолдер/мьют, см. `hasLiveVideo`) — в карусели сбоку.
|
||||
* Если камеру не включил никто, сетка была бы пустой, поэтому в этом
|
||||
* случае показываем всех — режим вырождается в `tiles`.
|
||||
*
|
||||
* ДЕМОНСТРАЦИЯ ЭКРАНА перебивает выбранный режим: пока в комнате есть хоть
|
||||
* одна активная демонстрация, сцена ведёт себя как `standard` (демонстрация
|
||||
* крупно, все камеры в карусели). Это сознательно: смысл плиточных режимов —
|
||||
* равноправие участников, а демонстрация по определению неравноправна, ради
|
||||
* неё её и включают. Режим при этом не меняется — он живёт в состоянии
|
||||
* `RoomPage`, поэтому по завершении демонстрации сцена сама возвращается к
|
||||
* выбранному пользователем виду.
|
||||
*
|
||||
* СКРЫТИЕ ОСТАЛЬНЫХ (`hideOthers`): карусель не рендерится вовсе, основная
|
||||
* область занимает всю сцену. Скрыть можно тремя способами — меню «Вид»,
|
||||
* шторка настроек на мобильном и кнопка «Скрыть» прямо над колонкой миниатюр
|
||||
* (`onHideOthers`, только широкий экран). Вернуть — кнопкой «Показать
|
||||
* остальных» на сцене (`onShowOthers`): она видна всегда, пока кто-то скрыт,
|
||||
* чтобы участники не «потерялись» без понятного способа их вернуть. В режиме
|
||||
* `tiles` скрывать нечего (карусели нет), переключатель там заблокирован —
|
||||
* см. `StageViewOptions`.
|
||||
*
|
||||
* ФОКУС ПЕРЕЖИВАЕТ ПЕРЕЕЗД В МИНИ-ПЛЕЕР. Сцена в мини-плеере — ОТДЕЛЬНЫЙ
|
||||
* экземпляр этого компонента (портал в PiP-окно), и своё состояние фокуса он
|
||||
* начинал с нуля: демонстрации нет, никто прямо сейчас не говорит — и
|
||||
* `pickStageFocus` доходил до последнего фолбэка `localKey`, то есть мини-окно
|
||||
* открывалось на самом пользователе вместо того, что он видел крупно. В Safari
|
||||
* бага не было видно: там Document PiP не используется, а video-фолбэк
|
||||
* (`useRoomPiP`) берёт `<video>` прямо из фокус-плитки основного окна. Лечится
|
||||
* передачей ключа наружу и обратно: `onFocusKeyChange` → состояние в
|
||||
* `RoomPage` → `initialFocusKey` следующего экземпляра. Работает в обе стороны
|
||||
* — возврат из мини-плеера тоже не сбрасывает фокус.
|
||||
*
|
||||
* Раскладка — вертикальная колонка миниатюр слева от основной сцены (не
|
||||
* горизонтальная лента, см. design/mockups/room.html после правки: узкая
|
||||
@@ -111,7 +185,8 @@ function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant
|
||||
*
|
||||
* Проп `variant="pip"` — для рендера
|
||||
* ВНУТРИ мини-плеера (Document PiP, портал в `RoomPage.tsx`). В этом режиме
|
||||
* показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида.
|
||||
* показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида;
|
||||
* режимы показа и скрытие остальных на мини-плеер не влияют вовсе.
|
||||
*
|
||||
* Фокус следует за активным спикером в ОБОИХ вариантах (`followSpeaker` у
|
||||
* `pickStageFocus`; для основного окна — с 0.0.6, задача 3.2), но по-разному:
|
||||
@@ -120,8 +195,40 @@ function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant
|
||||
* фокуса живую демонстрацию экрана (`holdScreenShare`) и умеет закрепление
|
||||
* участника (`pinnedKey`, задача 3.1) — кнопка-булавка на плитке.
|
||||
*/
|
||||
export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
|
||||
export function RoomStage({
|
||||
variant = 'full',
|
||||
layoutMode = 'standard',
|
||||
hideOthers = false,
|
||||
onShowOthers,
|
||||
onHideOthers,
|
||||
initialFocusKey = null,
|
||||
onFocusKeyChange,
|
||||
onPinFocus,
|
||||
}: {
|
||||
variant?: 'full' | 'pip'
|
||||
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
|
||||
layoutMode?: StageLayoutMode
|
||||
/** Скрыть карусель остальных участников; игнорируется при `variant="pip"`. */
|
||||
hideOthers?: boolean
|
||||
/** Вернуть скрытых участников — кнопка на сцене (см. докстринг выше). */
|
||||
onShowOthers?: () => void
|
||||
/** Скрыть остальных — кнопка над каруселью (только широкий экран). */
|
||||
onHideOthers?: () => void
|
||||
/** Чем инициализировать фокус при монтировании — см. докстринг про мини-плеер. */
|
||||
initialFocusKey?: string | null
|
||||
/** Сообщать наружу текущий фокус, чтобы его пережил переезд сцены в мини-плеер и обратно. */
|
||||
onFocusKeyChange?: (key: string | null) => void
|
||||
/**
|
||||
* Участника только что закрепили (не открепили) в режиме без крупной
|
||||
* плитки — сцена сама переключиться не может (режим живёт в `RoomPage`),
|
||||
* поэтому просит переключить на `standard`, где закреплённого есть куда
|
||||
* показать крупно. Открепление НЕ вызывает колбэк — вид назад не
|
||||
* переключаем (см. докстринг `RoomPage`, обоснование решения в коммите).
|
||||
*/
|
||||
onPinFocus?: () => void
|
||||
}) {
|
||||
const room = useRoomContext()
|
||||
const isCompact = useIsCompactViewport()
|
||||
const tracks = useTracks(STAGE_TRACK_SOURCES, {
|
||||
onlySubscribed: false,
|
||||
})
|
||||
@@ -167,7 +274,10 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
|
||||
// `pickStageFocus` меняют результат без изменения самих треков.
|
||||
const [prevTracks, setPrevTracks] = useState(tracks)
|
||||
const [prevSpeakingParticipants, setPrevSpeakingParticipants] = useState(speakingParticipants)
|
||||
const [focusKey, setFocusKey] = useState<string | null>(null)
|
||||
// Стартовое значение — фокус, доставшийся от предыдущего экземпляра сцены
|
||||
// (см. `initialFocusKey` и докстринг про мини-плеер). Дальше живёт своей
|
||||
// жизнью: `pickStageFocus` пересчитывает его на каждое значимое изменение.
|
||||
const [focusKey, setFocusKey] = useState<string | null>(initialFocusKey)
|
||||
// Закрепление живёт в состоянии сцены (задача 3.1): ключ `identity:source`
|
||||
// плитки, которую пользователь закрепил булавкой; `null` — закрепления нет.
|
||||
// Только для основного окна — в PiP плитка одна и закреплять нечего.
|
||||
@@ -211,10 +321,7 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
|
||||
speakingCameraKeys,
|
||||
// Приоритет «говорящий с камерой выше говорящего без камеры» — только
|
||||
// основному окну: PiP по договорённости ведёт себя ровно как раньше.
|
||||
cameraKeysWithVideo:
|
||||
variant === 'pip'
|
||||
? []
|
||||
: cameraTracks.filter((t) => isTrackReference(t) && !t.publication.isMuted).map(stageTrackKey),
|
||||
cameraKeysWithVideo: variant === 'pip' ? [] : cameraTracks.filter(hasLiveVideo).map(stageTrackKey),
|
||||
prevKeys,
|
||||
prevFocusKey: focusKey,
|
||||
pinnedKey: pinnedAlive ? pinnedKey : null,
|
||||
@@ -228,6 +335,14 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Отдаём фокус наружу (в `RoomPage`), чтобы он пережил размонтирование этой
|
||||
// сцены и достался следующей — см. `initialFocusKey`. Именно эффект, а не
|
||||
// вызов в теле рендера: setState ЧУЖОГО компонента во время рендера React
|
||||
// запрещает.
|
||||
useEffect(() => {
|
||||
onFocusKeyChange?.(focusKey)
|
||||
}, [focusKey, onFocusKeyChange])
|
||||
|
||||
const focusTrack = tracks.find((t) => stageTrackKey(t) === focusKey) ?? screenShareTracks[0] ?? cameraTracks[0]
|
||||
const focusTrackKey = focusTrack ? stageTrackKey(focusTrack) : null
|
||||
// При активной демонстрации карусель — ВСЕ камеры (включая демонстратора) И
|
||||
@@ -255,9 +370,16 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
|
||||
* Закрепить/открепить плитку: повторное нажатие на уже закреплённой снимает
|
||||
* закрепление. Ключ приходит из самой плитки (она знает свой трек из
|
||||
* контекста — в карусели/гриде плитки рендерятся шаблоном, без пропсов).
|
||||
*
|
||||
* Закрепление доступно из ЛЮБОГО режима показа (задача 3.1 → 3.3): в
|
||||
* плиточных режимах крупной плитки нет, поэтому само закрепление (не
|
||||
* открепление) просит `RoomPage` переключить вид на `standard` —
|
||||
* закреплённого иначе негде показать крупно.
|
||||
*/
|
||||
function handleTogglePin(key: string) {
|
||||
setPinnedKey((prev) => (prev === key ? null : key))
|
||||
const willPin = pinnedKey !== key
|
||||
setPinnedKey(willPin ? key : null)
|
||||
if (willPin) onPinFocus?.()
|
||||
}
|
||||
|
||||
// Мини-плеер показывает ТОЛЬКО активное окно — без карусели/
|
||||
@@ -268,34 +390,100 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
|
||||
return (
|
||||
<section className="stage room-single-tile">
|
||||
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
|
||||
<PipMicToggle />
|
||||
<RoomAudioRenderer />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Демонстрация экрана перебивает выбранный режим (обоснование — в докстринге).
|
||||
const effectiveMode: StageLayoutMode = hasScreenShare ? 'standard' : layoutMode
|
||||
const liveCameraTracks = cameraTracks.filter(hasLiveVideo)
|
||||
|
||||
// Кого показываем сбоку в карусели. В «плитках» — никого (все равноправны),
|
||||
// в «живых плитках» — тех, у кого камера выключена (а если камеры нет ни у
|
||||
// кого, карусель пуста: все ушли в сетку), в «стандарте» — всех, кроме
|
||||
// плитки в фокусе.
|
||||
const sideTracks: TrackReferenceOrPlaceholder[] =
|
||||
effectiveMode === 'tiles'
|
||||
? []
|
||||
: effectiveMode === 'live-tiles'
|
||||
? liveCameraTracks.length > 0
|
||||
? cameraTracks.filter((t) => !hasLiveVideo(t))
|
||||
: []
|
||||
: carouselTracks
|
||||
const showCarousel = !hideOthers && sideTracks.length > 0
|
||||
// Булавка закрепления есть на КАЖДОЙ плитке во всех режимах (задача 3.3):
|
||||
// сама кнопка вызывает переключение на `standard`, где закреплённый и
|
||||
// попадёт в фокус (см. `handleTogglePin`/`onPinFocus`).
|
||||
const pinProps = { pinnedKey, onTogglePin: handleTogglePin }
|
||||
|
||||
function renderMain(): ReactNode {
|
||||
if (effectiveMode === 'tiles') {
|
||||
return (
|
||||
<StageGrid tracks={tracks}>
|
||||
<RoomParticipantTile {...pinProps} />
|
||||
</StageGrid>
|
||||
)
|
||||
}
|
||||
if (effectiveMode === 'live-tiles') {
|
||||
return (
|
||||
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks}>
|
||||
<RoomParticipantTile {...pinProps} />
|
||||
</StageGrid>
|
||||
)
|
||||
}
|
||||
// «Стандарт» с единственным участником (карусель пуста и скрывать нечего) —
|
||||
// прежнее поведение: равномерная сетка на всю сцену, а не фокус-плитка.
|
||||
if (sideTracks.length === 0 && !hideOthers) {
|
||||
return (
|
||||
<StageGrid tracks={tracks}>
|
||||
<RoomParticipantTile {...pinProps} />
|
||||
</StageGrid>
|
||||
)
|
||||
}
|
||||
// FocusLayout оригинала — лёгкая обёртка ровно над ParticipantTile
|
||||
// (см. её исходник), поэтому вместо неё используем свою обёртку
|
||||
// напрямую с тем же trackRef (аватар в фокус-плитке).
|
||||
return (
|
||||
focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} {...pinProps} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="stage">
|
||||
{!hasScreenShare && (!focusTrack || carouselTracks.length === 0) ? (
|
||||
<GridLayout tracks={tracks} className="stage-tiles">
|
||||
<RoomParticipantTile pinnedKey={pinnedKey} onTogglePin={handleTogglePin} />
|
||||
</GridLayout>
|
||||
) : (
|
||||
{showCarousel ? (
|
||||
<FocusLayoutContainer className="stage-tiles">
|
||||
<CarouselLayout tracks={carouselTracks}>
|
||||
<RoomParticipantTile pinnedKey={pinnedKey} onTogglePin={handleTogglePin} />
|
||||
</CarouselLayout>
|
||||
{/* FocusLayout оригинала — лёгкая обёртка ровно над ParticipantTile
|
||||
(см. её исходник), поэтому вместо неё используем свою обёртку
|
||||
напрямую с тем же trackRef (аватар в фокус-плитке). */}
|
||||
{focusTrack && (
|
||||
<RoomParticipantTile
|
||||
trackRef={focusTrack}
|
||||
onStopSharing={handleStopSharing}
|
||||
pinnedKey={pinnedKey}
|
||||
onTogglePin={handleTogglePin}
|
||||
/>
|
||||
{/* Обёртка колонки миниатюр: сама карусель не может быть прямым
|
||||
ребёнком грида, потому что над ней стоит кнопка «Скрыть». Из-за
|
||||
этого мобильный `.lk-carousel{order:1}` библиотеки перестаёт
|
||||
действовать — порядок переносим на `.stage-side` (см. room.css). */}
|
||||
<div className="stage-side">
|
||||
{/* Быстрый способ убрать карусель, не открывая меню «Вид». Только
|
||||
на широком экране: на мобильном колонка превращается в узкую
|
||||
полосу снизу, где кнопке не место, — там переключатель живёт в
|
||||
шторке настроек. Вернуть участников можно кнопкой на сцене. */}
|
||||
{!isCompact && onHideOthers && (
|
||||
<button type="button" className="stage-side-hide" onClick={onHideOthers}>
|
||||
<EyeOff className="lucide" aria-hidden="true" />
|
||||
<span>Скрыть</span>
|
||||
</button>
|
||||
)}
|
||||
<CarouselLayout tracks={sideTracks}>
|
||||
<RoomParticipantTile {...pinProps} />
|
||||
</CarouselLayout>
|
||||
</div>
|
||||
{renderMain()}
|
||||
</FocusLayoutContainer>
|
||||
) : (
|
||||
// Карусели нет — основная область занимает сцену целиком.
|
||||
<div className="stage-tiles stage-main-only">{renderMain()}</div>
|
||||
)}
|
||||
{hideOthers && sideTracks.length > 0 && (
|
||||
<button type="button" className="stage-show-others" onClick={onShowOthers}>
|
||||
<Users className="lucide" aria-hidden="true" />
|
||||
<span>Показать остальных ({sideTracks.length})</span>
|
||||
</button>
|
||||
)}
|
||||
<RoomAudioRenderer />
|
||||
</section>
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
|
||||
import { DisconnectButton, useTrackToggle } from '@livekit/components-react'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
|
||||
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
|
||||
|
||||
/**
|
||||
* Опции захвата демонстрации экрана: `audio: true` — звук
|
||||
@@ -36,7 +38,7 @@ const SCREEN_SHARE_CAPTURE_OPTIONS: ScreenShareCaptureOptions = {
|
||||
systemAudio: 'include',
|
||||
}
|
||||
|
||||
interface RoomToolbarProps {
|
||||
interface RoomToolbarProps extends StageViewProps {
|
||||
/** Показывать ли кнопку чата — `JoinOut.chat_enabled` И чат не помечен недоступным (close-код 4404). */
|
||||
chatVisible: boolean
|
||||
chatOpen: boolean
|
||||
@@ -56,10 +58,15 @@ interface RoomToolbarProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Нижний тулбар комнаты: микрофон/камера/демонстрация экрана/
|
||||
* Нижний тулбар комнаты: микрофон/камера/демонстрация экрана/вид сцены/
|
||||
* настройки устройств/полноэкранный режим/мини-плеер/чат/выход — собственные
|
||||
* кнопки на хуках LiveKit (useTrackToggle/DisconnectButton) и панели чата,
|
||||
* стилизованные по design/mockups/room.html.
|
||||
*
|
||||
* Кнопка «Вид» (режимы показа и скрытие остальных) рендерится ТОЛЬКО на
|
||||
* широком экране — условным рендерингом, а не скрытием через CSS: тулбар на
|
||||
* мобильном и так ужат до пяти кнопок, а те же настройки там доступны секцией
|
||||
* «Вид» в шторке настроек (`DeviceSettingsDialog`).
|
||||
*/
|
||||
export function RoomToolbar({
|
||||
chatVisible,
|
||||
@@ -73,8 +80,13 @@ export function RoomToolbar({
|
||||
pipSupported,
|
||||
pipActive,
|
||||
onTogglePiP,
|
||||
layoutMode,
|
||||
onLayoutModeChange,
|
||||
hideOthers,
|
||||
onHideOthersChange,
|
||||
}: RoomToolbarProps) {
|
||||
const toast = useToast()
|
||||
const isCompact = useIsCompactViewport()
|
||||
const mic = useTrackToggle({ source: Track.Source.Microphone })
|
||||
const camera = useTrackToggle({ source: Track.Source.Camera })
|
||||
const screenShare = useTrackToggle({
|
||||
@@ -140,6 +152,15 @@ export function RoomToolbar({
|
||||
<span className="label">Демонстрация</span>
|
||||
</button>
|
||||
|
||||
{!isCompact && (
|
||||
<StageViewMenu
|
||||
layoutMode={layoutMode}
|
||||
onLayoutModeChange={onLayoutModeChange}
|
||||
hideOthers={hideOthers}
|
||||
onHideOthersChange={onHideOthersChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="tb-btn"
|
||||
|
||||
@@ -4,7 +4,14 @@ import { useState } from 'react'
|
||||
import { pluralizeParticipants } from '@/lib/pluralize'
|
||||
|
||||
interface RoomTopbarProps {
|
||||
roomName: string
|
||||
/**
|
||||
* Название конференции (`ConferenceOut.title`/`ResolveOut.title`) —
|
||||
* `null`/не задано, если организатор его не указал. НЕ путать с
|
||||
* `JoinOut.room_name` (техническое имя комнаты LiveKit вида `hC-Dmos9KEM`,
|
||||
* которое сюда раньше и попадало по ошибке, см. задачу A6) — тот нужен
|
||||
* только серверу и в UI показывать нечего.
|
||||
*/
|
||||
title: string | null
|
||||
/** Slug/номер конференции из адреса — для инвайт-чипа (копирование ссылки). */
|
||||
slug?: string
|
||||
number?: string
|
||||
@@ -19,7 +26,7 @@ interface RoomTopbarProps {
|
||||
* тёмных токенов темы `room` (см. `--color-room-tile*`), без новых
|
||||
* цветов и форм.
|
||||
*/
|
||||
export function RoomTopbar({ roomName, slug, number }: RoomTopbarProps) {
|
||||
export function RoomTopbar({ title, slug, number }: RoomTopbarProps) {
|
||||
const participants = useParticipants()
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
@@ -39,7 +46,7 @@ export function RoomTopbar({ roomName, slug, number }: RoomTopbarProps) {
|
||||
return (
|
||||
<header className="room-topbar">
|
||||
<div className="room-title-block">
|
||||
<h1>{roomName}</h1>
|
||||
<h1>{title ?? 'Конференция без названия'}</h1>
|
||||
<p>
|
||||
<span className="rec-dot" aria-hidden="true" />
|
||||
Конференция активна · <Users className="lucide" style={{ width: 14, height: 14 }} aria-hidden="true" />{' '}
|
||||
|
||||
113
frontend/src/components/room/StageGrid.tsx
Normal file
113
frontend/src/components/room/StageGrid.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useRef, type ReactNode, type RefObject } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
TrackLoop,
|
||||
useGridLayout,
|
||||
usePagination,
|
||||
useSwipe,
|
||||
type GridLayoutDefinition,
|
||||
type TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-react'
|
||||
|
||||
/**
|
||||
* Раскладки равномерной сетки — расширенный набор поверх библиотечного
|
||||
* `GRID_LAYOUTS` (@livekit/components-core, `helper/grid-layouts.ts`).
|
||||
*
|
||||
* Зачем свой набор. Библиотечный требует `minWidth: 560` уже для сетки 2×2,
|
||||
* поэтому на телефоне (~390px) максимальной раскладкой оказывается `1×2` — две
|
||||
* плитки на страницу, и десяток участников превращается в пять страниц
|
||||
* листания. Здесь добавлены два портретных варианта под узкий экран (2×2 и
|
||||
* 2×3): плитка шириной ~190px с именем и аватаром читается нормально, а
|
||||
* страниц становится втрое меньше.
|
||||
*
|
||||
* Как это читает `selectGridLayout` (см. её исходник): раскладки сортируются
|
||||
* по `maxTiles` (при равенстве — по `minWidth`), берётся первая, вмещающая всех
|
||||
* участников, при этом раскладка пропускается, если ДАЛЬШЕ есть другая с тем же
|
||||
* `maxTiles` и подходящей ориентацией контейнера. Отсюда пары «портрет/
|
||||
* ландшафт» на одно и то же число плиток: 1×2 / 2×1 и 2×3 / 3×2 — на широком
|
||||
* экране выигрывает горизонтальный вариант, на узком вертикальный. Если
|
||||
* контейнер не дотягивает до `minWidth`/`minHeight`, функция рекурсивно
|
||||
* спускается к меньшей раскладке, а лишние участники уходят на следующую
|
||||
* страницу (`usePagination`).
|
||||
*/
|
||||
const STAGE_GRID_LAYOUTS: GridLayoutDefinition[] = [
|
||||
{ columns: 1, rows: 1 },
|
||||
{ columns: 1, rows: 2, orientation: 'portrait' },
|
||||
{ columns: 2, rows: 1, orientation: 'landscape' },
|
||||
// 4 плитки помещаются и на телефоне: 390px / 2 ≈ 190px на плитку.
|
||||
{ columns: 2, rows: 2, minWidth: 360 },
|
||||
{ columns: 2, rows: 3, minWidth: 360, minHeight: 480, orientation: 'portrait' },
|
||||
{ columns: 3, rows: 2, minWidth: 700, orientation: 'landscape' },
|
||||
{ columns: 3, rows: 3, minWidth: 700 },
|
||||
{ columns: 4, rows: 4, minWidth: 960 },
|
||||
{ columns: 5, rows: 5, minWidth: 1100 },
|
||||
]
|
||||
|
||||
interface StageGridProps {
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
/** Шаблон плитки — рендерится для каждого трека страницы (как у `GridLayout`, через `TrackLoop`). */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Равномерная сетка плиток с пагинацией — собственная сборка вместо
|
||||
* библиотечного `GridLayout`.
|
||||
*
|
||||
* Причина ровно одна: `GridLayout` не пробрасывает `gridLayouts` в
|
||||
* `useGridLayout`, то есть набор раскладок у неё жёстко зашит (см.
|
||||
* `STAGE_GRID_LAYOUTS` выше). Всё остальное — те же публичные хуки, что
|
||||
* использует оригинал (`useGridLayout` + `usePagination` + `useSwipe` +
|
||||
* `TrackLoop`) и тот же класс `.lk-grid-layout`, поэтому и стили библиотеки, и
|
||||
* `useVisualStableUpdate` внутри пагинации работают как раньше. Отличается
|
||||
* только элемент управления страницами: библиотечные `PaginationIndicator`/
|
||||
* `PaginationControl` из пакета не экспортируются, вместо них — свой
|
||||
* `.stage-grid-pages` (кнопки со стрелками + счётчик, доступен и мышью, и с
|
||||
* клавиатуры; на тач-экране страницы листаются ещё и свайпом).
|
||||
*/
|
||||
export function StageGrid({ tracks, children }: StageGridProps) {
|
||||
const gridEl = useRef<HTMLDivElement | null>(null)
|
||||
// Хуки библиотеки объявлены с `RefObject<HTMLDivElement>` (типы React 18, где
|
||||
// `current` был readonly и тип вёл себя ковариантно). В типах React 19
|
||||
// `current` мутабельный, поэтому `RefObject<HTMLDivElement | null>` в такой
|
||||
// параметр уже не присваивается — приведение безопасно: оба хука только
|
||||
// читают `.current` (ResizeObserver и слушатели touch-событий).
|
||||
const gridRef = gridEl as RefObject<HTMLDivElement>
|
||||
const { layout } = useGridLayout(gridRef, tracks.length, { gridLayouts: STAGE_GRID_LAYOUTS })
|
||||
const pagination = usePagination(layout.maxTiles, tracks)
|
||||
|
||||
useSwipe(gridRef, {
|
||||
onLeftSwipe: pagination.nextPage,
|
||||
onRightSwipe: pagination.prevPage,
|
||||
})
|
||||
|
||||
const hasPages = pagination.totalPageCount > 1
|
||||
|
||||
return (
|
||||
<div ref={gridEl} className="lk-grid-layout stage-grid" data-lk-pagination={hasPages}>
|
||||
<TrackLoop tracks={pagination.tracks}>{children}</TrackLoop>
|
||||
{hasPages && (
|
||||
<div className="stage-grid-pages">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Предыдущая страница участников"
|
||||
disabled={pagination.currentPage <= 1}
|
||||
onClick={pagination.prevPage}
|
||||
>
|
||||
<ChevronLeft className="lucide" aria-hidden="true" />
|
||||
</button>
|
||||
<span aria-live="polite">
|
||||
{pagination.currentPage} / {pagination.totalPageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Следующая страница участников"
|
||||
disabled={pagination.currentPage >= pagination.totalPageCount}
|
||||
onClick={pagination.nextPage}
|
||||
>
|
||||
<ChevronRight className="lucide" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
frontend/src/components/room/StageViewOptions.tsx
Normal file
130
frontend/src/components/room/StageViewOptions.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { LayoutGrid } from 'lucide-react'
|
||||
import { STAGE_LAYOUT_MODE_OPTIONS, type StageLayoutMode } from '@/lib/stageLayoutMode'
|
||||
|
||||
export interface StageViewProps {
|
||||
layoutMode: StageLayoutMode
|
||||
onLayoutModeChange: (mode: StageLayoutMode) => void
|
||||
/** Карусель остальных участников скрыта — на сцене только основная область. */
|
||||
hideOthers: boolean
|
||||
onHideOthersChange: (hide: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Блок выбора вида сцены: режим показа (три варианта) + скрытие остальных
|
||||
* участников. Один и тот же блок рендерится в двух местах — в поповере кнопки
|
||||
* «Вид» на десктопе (`StageViewMenu` ниже) и секцией внутри мобильной шторки
|
||||
* настроек (`DeviceSettingsDialog`), поэтому вынесен отдельно.
|
||||
*
|
||||
* «Скрыть остальных» в режиме «Плитки» заблокировано, а не спрятано: все
|
||||
* плитки там равноправны, скрывать нечего, но исчезающий на ровном месте
|
||||
* переключатель читался бы как баг — вместо этого он выключен с пояснением.
|
||||
*/
|
||||
export function StageViewOptions({
|
||||
layoutMode,
|
||||
onLayoutModeChange,
|
||||
hideOthers,
|
||||
onHideOthersChange,
|
||||
}: StageViewProps) {
|
||||
// Радиогруппе нужно имя, уникальное на документ: блок может оказаться на
|
||||
// странице дважды (десктопный поповер и шторка живут в разных ветках
|
||||
// рендера, но полагаться на это не стоит).
|
||||
const groupName = useId()
|
||||
const hideOthersDisabled = layoutMode === 'tiles'
|
||||
|
||||
return (
|
||||
<div className="stage-view-options">
|
||||
<fieldset className="stage-view-modes">
|
||||
<legend>Режим показа</legend>
|
||||
{STAGE_LAYOUT_MODE_OPTIONS.map((option) => (
|
||||
<label
|
||||
key={option.mode}
|
||||
className={`stage-view-mode${layoutMode === option.mode ? ' is-active' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={groupName}
|
||||
value={option.mode}
|
||||
checked={layoutMode === option.mode}
|
||||
onChange={() => onLayoutModeChange(option.mode)}
|
||||
/>
|
||||
<span className="stage-view-mode-text">
|
||||
<span className="stage-view-mode-title">{option.title}</span>
|
||||
<span className="stage-view-mode-hint">{option.hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<label className={`stage-view-toggle${hideOthersDisabled ? ' is-disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideOthers}
|
||||
disabled={hideOthersDisabled}
|
||||
onChange={(e) => onHideOthersChange(e.target.checked)}
|
||||
/>
|
||||
<span>Скрыть остальных участников</span>
|
||||
</label>
|
||||
{hideOthersDisabled && (
|
||||
<p className="stage-view-note">В режиме «Плитки» все участники равноправны — скрывать нечего.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Кнопка «Вид» в тулбаре с поповером над ней — десктопный вход в
|
||||
* `StageViewOptions`. На мобильном не рендерится вовсе (см. `RoomToolbar`):
|
||||
* там тулбар и так ужат до пяти кнопок, а те же настройки доступны секцией
|
||||
* «Вид» в шторке настроек.
|
||||
*
|
||||
* Закрытие по клику вне и Escape — тем же паттерном, что меню пользователя в
|
||||
* `ShellTopbar` (слушатели на document, пока меню открыто).
|
||||
*/
|
||||
export function StageViewMenu(props: StageViewProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="tb-menu-wrap" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`tb-btn${open ? ' is-panel-open' : ''}`}
|
||||
aria-label="Вид сцены"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span className="icon-shell">
|
||||
<LayoutGrid className="lucide" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="label">Вид</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="tb-menu" role="dialog" aria-label="Вид сцены">
|
||||
<StageViewOptions {...props} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
frontend/src/hooks/useIsCompactViewport.ts
Normal file
27
frontend/src/hooks/useIsCompactViewport.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/** Совпадает с мобильным брейкпоинтом комнаты (`room.css`, `max-width: 600px`). */
|
||||
const COMPACT_VIEWPORT_QUERY = '(max-width: 600px)'
|
||||
|
||||
/**
|
||||
* Живое отслеживание мобильной ширины — та же схема, что системная тема в
|
||||
* `useTheme.ts` (matchMedia + change-листенер).
|
||||
*
|
||||
* Нужен там, где мобильный вариант — это ДРУГАЯ разметка, а не другие стили:
|
||||
* шторка вместо модалки (`DeviceSettingsDialog`) и условный рендеринг кнопок
|
||||
* тулбара (`RoomToolbar`). Прятать элементы новым CSS-правилом поверх
|
||||
* медиазапроса в `room.css` намеренно не стали — там уже была битва
|
||||
* специфичности (см. комментарий в конце `room.css`).
|
||||
*/
|
||||
export function useIsCompactViewport(): boolean {
|
||||
const [isCompact, setIsCompact] = useState(() => window.matchMedia(COMPACT_VIEWPORT_QUERY).matches)
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(COMPACT_VIEWPORT_QUERY)
|
||||
const handleChange = (event: MediaQueryListEvent) => setIsCompact(event.matches)
|
||||
media.addEventListener('change', handleChange)
|
||||
return () => media.removeEventListener('change', handleChange)
|
||||
}, [])
|
||||
|
||||
return isCompact
|
||||
}
|
||||
55
frontend/src/lib/stageLayoutMode.ts
Normal file
55
frontend/src/lib/stageLayoutMode.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
const STORAGE_KEY = 'vidconf-stage-layout-mode'
|
||||
|
||||
/**
|
||||
* Режим показа участников на сцене конференции — выбирается пользователем
|
||||
* явно (до 0.0.11 раскладка выбиралась автоматически по наличию демонстрации
|
||||
* экрана и числу участников).
|
||||
*
|
||||
* - `standard` — один крупный участник (фокус) + остальные в карусели сбоку;
|
||||
* кто именно в фокусе, по-прежнему решает `pickStageFocus`.
|
||||
* - `tiles` — все участники равными плитками, без выделенного крупного.
|
||||
* - `live-tiles` — равномерная сетка ТОЛЬКО из участников с включённой
|
||||
* камерой, остальные (камера выключена) — в карусели сбоку.
|
||||
*/
|
||||
export type StageLayoutMode = 'standard' | 'tiles' | 'live-tiles'
|
||||
|
||||
/** Порядок и подписи режимов в переключателе (единый источник для тулбара и мобильной шторки). */
|
||||
export const STAGE_LAYOUT_MODE_OPTIONS: readonly {
|
||||
mode: StageLayoutMode
|
||||
title: string
|
||||
hint: string
|
||||
}[] = [
|
||||
{ mode: 'standard', title: 'Стандарт', hint: 'Крупно — активный участник, остальные сбоку' },
|
||||
{ mode: 'tiles', title: 'Плитки', hint: 'Все участники равными плитками' },
|
||||
{ mode: 'live-tiles', title: 'Живые плитки', hint: 'Плитками — только с камерой, остальные сбоку' },
|
||||
]
|
||||
|
||||
function isStageLayoutMode(value: string | null): value is StageLayoutMode {
|
||||
return value === 'standard' || value === 'tiles' || value === 'live-tiles'
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранённый режим показа — читается при входе в комнату.
|
||||
*
|
||||
* Персист сделан своим модулем (как `lib/audioOutputDevice.ts`), а не через
|
||||
* `usePersistentUserChoices` LiveKit: их `LocalUserChoices` — фиксированная
|
||||
* структура про устройства и имя, поля для нашей раскладки там нет и добавить
|
||||
* его нельзя. Схема хранения та же: одна строка в localStorage, любые сбои
|
||||
* доступа к нему гасятся (приватный режим/политики браузера).
|
||||
*/
|
||||
export function loadStageLayoutMode(): StageLayoutMode {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
return isStageLayoutMode(stored) ? stored : 'standard'
|
||||
} catch {
|
||||
return 'standard'
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStageLayoutMode(mode: StageLayoutMode): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, mode)
|
||||
} catch {
|
||||
// Сохранение недоступно — выбор продержится до конца сессии в комнате.
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,7 @@ export function JoinPage() {
|
||||
state: {
|
||||
livekitUrl: data.livekit_url,
|
||||
token: data.token,
|
||||
roomName: data.room_name,
|
||||
title: resolved.title,
|
||||
conferenceId: data.conference_id,
|
||||
chatEnabled: data.chat_enabled,
|
||||
},
|
||||
|
||||
@@ -40,7 +40,7 @@ export function LobbyPage() {
|
||||
state: {
|
||||
livekitUrl: conference.join.livekit_url,
|
||||
token: conference.join.token,
|
||||
roomName: conference.join.room_name,
|
||||
title: conference.title,
|
||||
conferenceId: conference.join.conference_id,
|
||||
chatEnabled: conference.join.chat_enabled,
|
||||
number: conference.number,
|
||||
|
||||
@@ -18,11 +18,13 @@ import { RoomToolbar } from '@/components/room/RoomToolbar'
|
||||
import { ChatPanel } from '@/components/room/ChatPanel'
|
||||
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
|
||||
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
||||
import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode'
|
||||
|
||||
interface RoomJoinState {
|
||||
livekitUrl: string
|
||||
token: string
|
||||
roomName: string
|
||||
/** Название конференции (`ConferenceOut.title`/`ResolveOut.title`) — для топбара, см. `RoomTopbar` и задачу A6. */
|
||||
title?: string | null
|
||||
conferenceId?: string
|
||||
/** Номер конференции — для инвайт-чипа в топбаре (см. RoomTopbar). Есть только сразу после создания. */
|
||||
number?: string
|
||||
@@ -91,7 +93,7 @@ export function RoomPage() {
|
||||
setJoinState({
|
||||
livekitUrl: result.livekit_url,
|
||||
token: result.token,
|
||||
roomName: result.room_name,
|
||||
title: info.title,
|
||||
conferenceId: result.conference_id,
|
||||
chatEnabled: result.chat_enabled,
|
||||
})
|
||||
@@ -150,6 +152,39 @@ export function RoomPage() {
|
||||
const pip = useRoomPiP(roomRootRef)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
// Вид сцены живёт здесь, а не в `RoomStage`: переключатели — в тулбаре и в
|
||||
// шторке настроек, а сцена их только читает (общий предок).
|
||||
//
|
||||
// Режим показа персистится между заходами в комнату (`lib/stageLayoutMode.ts`,
|
||||
// localStorage) — это устойчивое предпочтение: кто любит «плитки», хочет их
|
||||
// и завтра. А вот «скрыть остальных» намеренно НЕ сохраняется: это разовое
|
||||
// действие по ходу разговора, и войти в новую конференцию сразу без половины
|
||||
// участников — сюрприз, а не удобство. Сбрасывается вместе с уходом со
|
||||
// страницы комнаты.
|
||||
const [layoutMode, setLayoutMode] = useState<StageLayoutMode>(loadStageLayoutMode)
|
||||
const [hideOthers, setHideOthers] = useState(false)
|
||||
|
||||
const handleLayoutModeChange = useCallback((mode: StageLayoutMode) => {
|
||||
setLayoutMode(mode)
|
||||
saveStageLayoutMode(mode)
|
||||
}, [])
|
||||
|
||||
// Закрепление участника из плиточного режима переключает вид на «Стандарт»
|
||||
// (см. докстринг `RoomStage`, `onPinFocus`) — но это НЕ то же самое, что
|
||||
// явный выбор пользователя: переключение не сохраняется в localStorage
|
||||
// (в отличие от `handleLayoutModeChange`), иначе разовое закрепление в этой
|
||||
// встрече поменяло бы устойчивое предпочтение режима для всех следующих.
|
||||
const handlePinFocus = useCallback(() => {
|
||||
setLayoutMode('standard')
|
||||
}, [])
|
||||
|
||||
// Ключ трека, который сцена показывает крупно. Живёт ЗДЕСЬ, а не только
|
||||
// внутри `RoomStage`, потому что при открытии мини-плеера сцена
|
||||
// размонтируется в основном окне и монтируется заново в PiP-окне (портал
|
||||
// ниже) — без этого мостика новый экземпляр начинал бы выбор фокуса с нуля и
|
||||
// открывал мини-окно на самом пользователе. Подробнее — докстринг `RoomStage`.
|
||||
const [stageFocusKey, setStageFocusKey] = useState<string | null>(null)
|
||||
|
||||
// Сохранённый выбор устройств — читаем через собственный вызов
|
||||
// usePersistentUserChoices (независимый от того, что использует
|
||||
// DeviceSettingsDialog: там свой вызов хука со своим состоянием). ВАЖНО:
|
||||
@@ -162,6 +197,22 @@ export function RoomPage() {
|
||||
const { userChoices } = usePersistentUserChoices()
|
||||
const roomOptions = useMemo<RoomOptions>(
|
||||
() => ({
|
||||
// Оба флага в LiveKit по умолчанию выключены, и без них каждый клиент
|
||||
// подписан на полное качество всех чужих треков независимо от размера
|
||||
// плитки, а каждый паблишер шлёт все слои симулкаста, даже если их никто
|
||||
// не смотрит. На тесте 28.07.2026 (19 участников, ~8 камер) это дало
|
||||
// устойчивые 140–169 Мбит/с исходящего трафика при пике 240, 662 события
|
||||
// `remote bwe: channel congestion detected` и 146 переходов аллокатора
|
||||
// STABLE → DEFICIENT — то есть видимый участникам лаг.
|
||||
//
|
||||
// adaptiveStream: подписка на слой по фактическому размеру плитки на
|
||||
// экране + пауза треков, которые сейчас не отрисованы. Именно на нём
|
||||
// начинает экономить уже написанный код: «скрыть остальных»
|
||||
// (RoomStage) не рендерит карусель, а пагинация StageGrid рендерит
|
||||
// только текущую страницу — неприаттаченные треки считаются невидимыми.
|
||||
// dynacast: паблишер прекращает отдавать слои, на которые нет подписчиков.
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined },
|
||||
videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined },
|
||||
// Аудиовыход (колонки/наушники/bluetooth) — отдельный персист, не через
|
||||
@@ -199,13 +250,13 @@ export function RoomPage() {
|
||||
serverUrl={joinState.livekitUrl}
|
||||
token={joinState.token}
|
||||
connect
|
||||
audio
|
||||
video
|
||||
audio={false}
|
||||
video={false}
|
||||
options={roomOptions}
|
||||
onDisconnected={handleDisconnected}
|
||||
>
|
||||
<div data-lk-theme="default" className="room-shell">
|
||||
<RoomTopbar roomName={joinState.roomName} slug={slug} number={joinState.number} />
|
||||
<RoomTopbar title={joinState.title ?? null} slug={slug} number={joinState.number} />
|
||||
<div className="room-main">
|
||||
{pip.mode === 'document' ? (
|
||||
// Сцена сейчас рисуется в PiP-окне (через createPortal ниже) —
|
||||
@@ -218,7 +269,15 @@ export function RoomPage() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<RoomStage />
|
||||
<RoomStage
|
||||
layoutMode={layoutMode}
|
||||
hideOthers={hideOthers}
|
||||
onShowOthers={() => setHideOthers(false)}
|
||||
onHideOthers={() => setHideOthers(true)}
|
||||
initialFocusKey={stageFocusKey}
|
||||
onFocusKeyChange={setStageFocusKey}
|
||||
onPinFocus={handlePinFocus}
|
||||
/>
|
||||
)}
|
||||
{chatVisible && chatOpen && (
|
||||
<ChatPanel
|
||||
@@ -242,15 +301,32 @@ export function RoomPage() {
|
||||
pipSupported={pip.supported}
|
||||
pipActive={pip.active}
|
||||
onTogglePiP={pip.toggle}
|
||||
layoutMode={layoutMode}
|
||||
onLayoutModeChange={handleLayoutModeChange}
|
||||
hideOthers={hideOthers}
|
||||
onHideOthersChange={setHideOthers}
|
||||
/>
|
||||
</div>
|
||||
{settingsOpen && <DeviceSettingsDialog onClose={() => setSettingsOpen(false)} />}
|
||||
{settingsOpen && (
|
||||
<DeviceSettingsDialog
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
layoutMode={layoutMode}
|
||||
onLayoutModeChange={handleLayoutModeChange}
|
||||
hideOthers={hideOthers}
|
||||
onHideOthersChange={setHideOthers}
|
||||
/>
|
||||
)}
|
||||
{/* Портал внутрь LiveKitRoom (не как сосед снаружи!) — RoomStage,
|
||||
рендерящийся в PiP-окне, продолжает читать RoomContext/треки.
|
||||
`variant="pip"` — мини-плеер
|
||||
показывает только активное окно (одну плитку), без карусели/грида
|
||||
основного окна. */}
|
||||
{pip.pipWindow && createPortal(<RoomStage variant="pip" />, pip.pipWindow.document.body)}
|
||||
основного окна. `initialFocusKey` — то, что было крупно в основном
|
||||
окне: без него мини-окно открывалось на самом пользователе. */}
|
||||
{pip.pipWindow &&
|
||||
createPortal(
|
||||
<RoomStage variant="pip" initialFocusKey={stageFocusKey} onFocusKeyChange={setStageFocusKey} />,
|
||||
pip.pipWindow.document.body,
|
||||
)}
|
||||
</LiveKitRoom>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -47,7 +47,9 @@
|
||||
.room-invite-chip span { font: var(--text-mono-sm); color: var(--color-room-text-secondary); }
|
||||
|
||||
.room-main { display: flex; flex: 1; min-height: 0; }
|
||||
.stage { flex: 1; display: flex; flex-direction: column; padding: var(--space-5) var(--space-6); gap: var(--space-4); min-width: 0; }
|
||||
/* `position: relative` — якорь для кнопки «Показать остальных» (`.stage-show-others`),
|
||||
которая висит оверлеем над сценой, см. её правило ниже. */
|
||||
.stage { flex: 1; display: flex; flex-direction: column; padding: var(--space-5) var(--space-6); gap: var(--space-4); min-width: 0; position: relative; }
|
||||
|
||||
/*
|
||||
* Демонстрация экрана: `object-fit: cover` (дефолт VideoTrack для
|
||||
@@ -115,6 +117,10 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
||||
@media (max-width: 600px) {
|
||||
.stage-tiles.lk-focus-layout { grid-template-columns: 1fr; }
|
||||
.stage { padding: var(--space-3) var(--space-4); gap: var(--space-3); }
|
||||
/* Их `.lk-carousel{order:1}` (ставит миниатюры ПОД спикера) больше не
|
||||
действует: прямой ребёнок грида теперь обёртка `.stage-side`, а не сама
|
||||
карусель — переносим порядок на неё, иначе миниатюры уезжают наверх. */
|
||||
.stage-side { order: 1; }
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -127,9 +133,149 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
||||
* `.stage-tiles` в основном окне) плюс подстраховочный `min-height`, чтобы
|
||||
* плитка не схлопывалась, если видео ещё не выдало реальные размеры.
|
||||
*/
|
||||
.room-single-tile { flex: 1; min-height: 0; display: flex; }
|
||||
.room-single-tile { position: relative; flex: 1; min-height: 0; display: flex; }
|
||||
.room-single-tile .lk-participant-tile { flex: 1; min-height: 220px; width: 100%; }
|
||||
|
||||
/* Кнопка микрофона мини-плеера (задача A5) — своего тулбара в PiP нет,
|
||||
кнопка лежит поверх единственной плитки левым нижним углом (правый занят
|
||||
штатным `.lk-focus-toggle-button`/нашей булавкой закрепления — тут её нет,
|
||||
в PiP закреплять нечего, но геометрия зеркалит `.room-pin-toggle`). */
|
||||
.room-pip-mic-toggle {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
z-index: 5;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-room-mic-on);
|
||||
color: #10331f;
|
||||
cursor: pointer;
|
||||
}
|
||||
.room-pip-mic-toggle svg { width: 18px; height: 18px; }
|
||||
.room-pip-mic-toggle.is-off { background: var(--color-room-danger-bg); color: #fff; }
|
||||
|
||||
/*
|
||||
* ---------- Колонка миниатюр (кнопка «Скрыть» + карусель) ----------
|
||||
* Прямой ребёнок `.lk-focus-layout` — вместо самой карусели, потому что над
|
||||
* ней стоит кнопка «Скрыть» (см. `RoomStage`). Карусель занимает весь остаток
|
||||
* колонки: ей важны собственные размеры — `CarouselLayout` по ним и решает,
|
||||
* вертикальная она или горизонтальная (см. комментарий в `RoomStage`).
|
||||
* `min-height: 0` обязателен — иначе flex-элемент не даёт себя сжать и
|
||||
* колонка вылезает за высоту сцены.
|
||||
*/
|
||||
.stage-side { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; min-height: 0; }
|
||||
.stage-side .lk-carousel { flex: 1; min-height: 0; }
|
||||
.stage-side-hide {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-room-tile-border);
|
||||
background: var(--color-room-tile);
|
||||
color: var(--color-room-text-secondary);
|
||||
font: var(--text-caption);
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stage-side-hide:hover { background: var(--color-room-tile-hover); color: var(--color-room-text-primary); }
|
||||
.stage-side-hide svg { width: 14px; height: 14px; flex-shrink: 0; }
|
||||
|
||||
/*
|
||||
* ---------- Основная область без карусели ----------
|
||||
* `.stage-main-only` — сцена, где карусель не рендерится вовсе: плиточные
|
||||
* режимы («Плитки» всегда, «Живые плитки» когда камеры выключены не у кого),
|
||||
* «Стандарт» с единственным участником и любой режим при включённом «скрыть
|
||||
* остальных». Единственный ребёнок (плитка фокуса или сетка `.stage-grid`)
|
||||
* занимает всё место — тем же приёмом, что `.room-single-tile` выше, но без
|
||||
* `min-height` мини-плеера: в основном окне высота приходит от `.room-main`.
|
||||
*/
|
||||
.stage-main-only { display: flex; }
|
||||
.stage-main-only > * { flex: 1; min-width: 0; min-height: 0; }
|
||||
|
||||
/*
|
||||
* ---------- Равномерная сетка плиток (`StageGrid`) ----------
|
||||
* Класс `.lk-grid-layout` библиотеки на месте (оттуда вся геометрия сетки:
|
||||
* `--lk-col-count`/`--lk-row-count`, gap, padding), здесь — только то, чего у
|
||||
* неё нет: сброс минимальных размеров (сетка может жить внутри grid-ячейки
|
||||
* `.lk-focus-layout`, где без `min-*: 0` она распирала бы колонку) и якорь для
|
||||
* собственного переключателя страниц.
|
||||
*/
|
||||
.stage-grid { position: relative; min-width: 0; min-height: 0; }
|
||||
.stage-grid-pages {
|
||||
position: absolute;
|
||||
bottom: calc(var(--lk-grid-gap) / 2);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(30, 30, 30, 0.85);
|
||||
border: 1px solid var(--color-room-tile-border);
|
||||
color: var(--color-room-text-primary);
|
||||
font: var(--text-caption);
|
||||
}
|
||||
.stage-grid-pages button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stage-grid-pages button:hover:not(:disabled) { background: var(--color-room-tile-hover); }
|
||||
.stage-grid-pages button:disabled { opacity: 0.35; cursor: default; }
|
||||
.stage-grid-pages svg { width: 16px; height: 16px; }
|
||||
.stage-grid-pages span { padding: 0 4px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/*
|
||||
* ---------- Кнопка «Показать остальных» ----------
|
||||
* Единственный видимый способ вернуть скрытую карусель, поэтому висит прямо на
|
||||
* сцене, а не только в меню «Вид»: пользователь не должен «терять» участников
|
||||
* без понятного способа их вернуть. Внизу по центру — там у плиток пусто
|
||||
* (`.lk-participant-metadata` прижата к левому и правому углам).
|
||||
*/
|
||||
.stage-show-others {
|
||||
position: absolute;
|
||||
bottom: var(--space-5);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--color-room-tile-border);
|
||||
background: rgba(30, 30, 30, 0.9);
|
||||
color: var(--color-room-text-primary);
|
||||
font: var(--text-body);
|
||||
font-weight: 600;
|
||||
/* Иначе на узком экране подпись ломается на две строки и кнопка вырастает
|
||||
в высоту вдвое (поймали на 390px). */
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--shadow-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
.stage-show-others:hover { background: var(--color-room-tile-hover); }
|
||||
.stage-show-others svg { width: 18px; height: 18px; flex-shrink: 0; }
|
||||
|
||||
/* Нижний тулбар: свои кнопки на хуках LiveKit (TrackToggle/DisconnectButton) */
|
||||
.room-toolbar {
|
||||
display: flex;
|
||||
@@ -200,6 +346,75 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/*
|
||||
* ---------- Кнопка «Вид» и её поповер (десктоп) ----------
|
||||
* Обёртка вокруг кнопки тулбара — якорь для всплывающей панели над ней
|
||||
* (`StageViewMenu`). На мобильном ни кнопки, ни поповера нет вовсе: там блок
|
||||
* настроек вида рендерится секцией внутри шторки (условный рендеринг в React,
|
||||
* а не скрытие через CSS, — см. `RoomToolbar`).
|
||||
*/
|
||||
.tb-menu-wrap { position: relative; display: flex; }
|
||||
.tb-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + var(--space-2));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
width: 280px;
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-room-tile-border);
|
||||
background: var(--color-room-surface-raised);
|
||||
box-shadow: var(--shadow-room-panel);
|
||||
}
|
||||
|
||||
/*
|
||||
* ---------- Блок «Вид сцены» (`StageViewOptions`) ----------
|
||||
* Общий для десктопного поповера и мобильной шторки — отсюда нейтральная
|
||||
* геометрия без привязки к контейнеру (ширину задаёт родитель).
|
||||
*/
|
||||
.stage-view-options { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
/* Секция «Вид» внутри шторки настроек — отбивка как у `.room-field` (свой
|
||||
класс, а не `.room-field`, см. комментарий в `DeviceSettingsDialog`). */
|
||||
.stage-view-section { margin-bottom: var(--space-5); }
|
||||
.stage-view-modes { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; border: none; }
|
||||
.stage-view-modes legend {
|
||||
padding: 0 0 var(--space-2);
|
||||
font: var(--text-caption);
|
||||
color: var(--color-room-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.stage-view-mode {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stage-view-mode:hover { background: var(--color-room-tile); }
|
||||
.stage-view-mode.is-active { background: var(--color-room-tile); border-color: var(--color-room-speaker-ring); }
|
||||
.stage-view-mode input { margin-top: 3px; accent-color: var(--color-room-mic-on); flex-shrink: 0; }
|
||||
.stage-view-mode-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.stage-view-mode-title { font: var(--text-body); font-weight: 600; color: var(--color-room-text-primary); }
|
||||
.stage-view-mode-hint { font: var(--text-caption); text-transform: none; letter-spacing: normal; color: var(--color-room-text-secondary); }
|
||||
|
||||
.stage-view-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-room-tile-border);
|
||||
font: var(--text-body);
|
||||
color: var(--color-room-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.stage-view-toggle input { accent-color: var(--color-room-mic-on); flex-shrink: 0; }
|
||||
.stage-view-toggle.is-disabled { color: var(--color-room-text-tertiary); cursor: default; }
|
||||
.stage-view-note { margin: 0; font: var(--text-caption); text-transform: none; letter-spacing: normal; color: var(--color-room-text-tertiary); }
|
||||
|
||||
/* ---------- Панель чата (см. design/mockups/room.html, .chat-panel) ---------- */
|
||||
.chat-panel {
|
||||
width: 320px;
|
||||
@@ -305,6 +520,49 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
||||
}
|
||||
.chat-input-row button:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.chat-emoji-wrap { position: relative; display: flex; flex-shrink: 0; }
|
||||
.chat-emoji-trigger {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-room-tile);
|
||||
border: 1px solid var(--color-room-tile-border);
|
||||
color: var(--color-room-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chat-emoji-trigger:hover { color: var(--color-room-text-primary); }
|
||||
.chat-emoji-trigger.is-open { color: var(--color-room-mic-on); border-color: var(--color-room-speaker-ring); }
|
||||
.chat-emoji-trigger:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.chat-emoji-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + var(--space-2));
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
width: 224px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 2px;
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-room-tile-border);
|
||||
background: var(--color-room-surface-raised);
|
||||
box-shadow: var(--shadow-room-panel);
|
||||
}
|
||||
.chat-emoji-option {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
.chat-emoji-option:hover { background: var(--color-room-tile); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.chat-panel {
|
||||
position: fixed;
|
||||
@@ -518,17 +776,50 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
||||
}
|
||||
.room-pip-placeholder svg { width: 48px; height: 48px; color: var(--color-room-text-tertiary); }
|
||||
|
||||
/* ---------- Аватар вместо иконки-заглушки при выключенной камере ---------- */
|
||||
/* ---------- Аватар вместо иконки-заглушки при выключенной камере ----------
|
||||
* Размер считается от УЗКОЙ стороны плитки, а не от ширины и высоты по
|
||||
* отдельности. Раньше здесь было `width: 40%; height: 40%` — проценты от
|
||||
* РАЗНЫХ сторон, а круглым `.avatar` делает только `border-radius: 50%`
|
||||
* (shell.css), поэтому на неквадратной плитке (а они почти всегда такие)
|
||||
* получался эллипс. Плюс `max-*: 96px` не давал иконке вырасти на большой
|
||||
* плитке фокуса.
|
||||
*
|
||||
* Механика: контейнером container-запросов делаем `.lk-participant-placeholder`
|
||||
* — блок библиотеки, который `position: absolute; inset: 0`, то есть в
|
||||
* точности повторяет плитку и уже имеет обе размерности (обязательное условие
|
||||
* для `container-type: size`). Контейнер именно на нём, а не на самой
|
||||
* `.lk-participant-tile`: `container-type: size` включает size containment, и
|
||||
* на плитке он отрезал бы её содержимое (видео, метаданные) от влияния на
|
||||
* авторазмер — на placeholder-обёртке внутри ничего, кроме аватара, нет,
|
||||
* ломать нечему.
|
||||
*
|
||||
* `cqmin` — минимум из ширины и высоты контейнера, буквально «узкая сторона»:
|
||||
* - основная (крупная) плитка — 50cqmin, примерно половина плитки;
|
||||
* - плитки карусели — во всю узкую сторону минус отступ 5px с каждого края.
|
||||
* `aspect-ratio: 1` + `height: auto` гарантируют круг при любой форме плитки:
|
||||
* диаметр никогда не больше меньшей стороны, дальше иконка уменьшается
|
||||
* пропорционально вместе с плиткой. `min-width: 32px` оставлен — на совсем
|
||||
* крошечной плитке инициалы должны оставаться читаемыми (сжатие плитки ниже
|
||||
* этого порога и так означает, что показывать там нечего).
|
||||
*/
|
||||
.lk-participant-tile .lk-participant-placeholder {
|
||||
container-type: size;
|
||||
}
|
||||
.room-tile-avatar {
|
||||
width: 40%;
|
||||
height: 40%;
|
||||
width: 50cqmin;
|
||||
height: auto;
|
||||
aspect-ratio: 1;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
max-width: 96px;
|
||||
max-height: 96px;
|
||||
font-size: clamp(12px, 3vw, 28px);
|
||||
min-height: 0;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
font-size: clamp(12px, 20cqmin, 44px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.lk-carousel .room-tile-avatar {
|
||||
width: calc(100cqmin - 10px);
|
||||
font-size: clamp(12px, 34cqmin, 28px);
|
||||
}
|
||||
|
||||
.room-loader, .room-error {
|
||||
display: flex;
|
||||
|
||||
13
install.sh
13
install.sh
@@ -526,13 +526,14 @@ docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --wait postgres r
|
||||
# exist" и healthcheck (--wait) никогда не проходит. `compose run` запускает
|
||||
# одноразовый контейнер с нужной командой, не поднимая uvicorn/lifespan.
|
||||
echo "[install] Применяю миграции Alembic и seed (админ/справочники) — до старта backend"
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run alembic upgrade head
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run python -m scripts.seed
|
||||
# `--no-sync`: окружение собрано в образе, повторная синхронизация в рантайме
|
||||
# только тянула бы dev-группу (см. комментарий в backend/Dockerfile).
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run --no-sync alembic upgrade head
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run --no-sync python -m scripts.seed
|
||||
|
||||
echo "[install] docker compose up -d --wait (backend/worker/nginx и остальные сервисы профиля)"
|
||||
# Первый старт backend/worker включает `uv run` (синхронизация окружения +
|
||||
# компиляция байткода) — на слабой/загруженной машине healthcheck может не
|
||||
# успеть пройти за отведённые retries, и `--wait` вернёт "container is
|
||||
# На слабой/загруженной машине healthcheck может не успеть пройти за отведённые
|
||||
# retries, и `--wait` вернёт "container is
|
||||
# unhealthy", хотя сервис через несколько секунд становится healthy. Команда
|
||||
# идемпотентна, поэтому повторяем её несколько раз: повтор лишь дожидается
|
||||
# уже стартующих контейнеров, ничего не пересоздавая.
|
||||
@@ -573,7 +574,7 @@ if [ "$FRESH_ENV" != "1" ]; then
|
||||
fi
|
||||
if [ "$APPLY_PRESET_SETTINGS" = "1" ]; then
|
||||
echo "[install] Применяю настройки модулей инстанса под пресет ${PRESET}"
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec -T backend uv run python -m scripts.apply_preset_settings --force
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec -T backend uv run --no-sync python -m scripts.apply_preset_settings --force
|
||||
else
|
||||
echo "[install] Настройки модулей инстанса НЕ изменены — сохранены ручные правки администратора"
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user