14 Commits

Author SHA1 Message Date
e5c596f2bf release: версия 0.0.16
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-01 22:08:34 +03:00
4c60e092e5 feat(room): принудительный мьют участника организатором
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Новый эндпоинт POST /conferences/{id}/mute-participant: права проверяются
ЗАНОВО по владельцу конференции в БД (ConferenceService.mute_participant),
не по метаданным LiveKit-токена вызывающего — те лишь подсказка для UI и
потенциально подделываемы клиентом. Обычный участник получает 403, чужая/
несуществующая конференция — 404, участник не в комнате LiveKit — отдельный
404 (participant_not_in_room).

Само выключение — серверный вызов api.LiveKitAPI (services/room_control.py,
тот же паттерн, что services/egress.py): backend аутентифицируется
СОБСТВЕННЫМИ api_key/api_secret, а не токеном организатора, поэтому
дополнительный LiveKit-грант в токене организатора не нужен — мьютит сервер
от своего имени. Если трек данного source не опубликован (с 0.0.15 участники
заходят с выключенными микрофоном/камерой) — не ошибка, а no-op: искомое
состояние уже достигнуто, ответ muted:false.

Уведомление участника — тот же общий канал комнаты, что и очередь рук
(hand_queue_channel): рассылается всем, получатель сам сверяет identity
(ForcedMuteWatcher, рендерится внутри LiveKitRoom). Само выключение трека
участник видит сразу через штатный useTrackToggle (LiveKit сам присылает
TrackMuted), тост только поясняет причину — иначе не отличить от глюка.
Включить себя обратно можно сразу тем же тулбаром, сервер это не блокирует.

Кнопки — на чужой плитке камеры, видны только организатору по наведению
(на тач-устройствах — всегда, как и булавка закрепления).

Тесты: владелец мьютит успешно и публикует broadcast, уже-выключенный трек —
muted:false без broadcast, администратор мьютит чужую конференцию, обычный
участник получает 403 без обращения к LiveKit, конференция не найдена и
участник не в комнате — соответствующие 404.
2026-08-01 22:07:06 +03:00
8e5eda88a2 feat(room): поднятие руки и очередь для организатора
Транспорт — существующий аутентифицированный WS чата (api/chat.py), а не
отдельный эндпоинт: сервер уже держит это соединение на каждого участника
(обоснование — докстринг chat_websocket и useChat.ts). Состояние очереди —
Redis (services/hand_queue.py), не Postgres: это эфемерное состояние звонка,
а не история, и два процесса uvicorn делают наивную память одного процесса
недостаточной. HSETNX даёт идемпотентное «поднять» (повторный клик не
переставляет в конец очереди), снапшот шлётся всем участникам при любом
изменении — организатор, зашедший позже, сразу видит актуальную картину.

Опустить чужую руку может организатор (решение оператора) — проверка через
conference.owner_id, не через identity клиента. Участник, вышедший из
комнаты LiveKit (webhook participant_left), теряет место в очереди
автоматически; переподключение WS чата место не сбрасывает (Redis не привязан
к жизни соединения). room_finished чистит очередь целиком — она не должна
пережить завершение звонка.

Побочный эффект транспортного решения: поднять руку нельзя, если чат выключен
настройкой инстанса (WS вообще не открывается) — принятый компромисс ради
переиспользования уже готового канала.

UI: кнопка «Рука» в тулбаре (у всех, бейдж — общий счётчик), бейдж на плитке
говорящего (видно всем), панель «Очередь» организатору (HandQueuePanel).
Кнопка «Рука» и панель «Очередь» намеренно НЕ прячутся в мобильную шторку
настроек, в отличие от «Вида», — поднятие руки посреди разговора требует
кнопки под рукой, а не в два клика вглубь настроек.

Этим же коммитом (файлы разделяемые с задачей B2, RoomParticipantTile.tsx/
useChat.ts/RoomStage.tsx/RoomPage.tsx/room.css) — проброс conferenceId и
каркас forced_mute-обработки, без которых кнопки принудительного мьюта не
скомпилировались бы; сама реализация мьюта — следующим коммитом.
2026-08-01 22:06:43 +03:00
42bfb88a22 feat(room): роль организатора в метаданных LiveKit-токена
Предварительная работа для очереди рук и принудительного мьюта (B1/B2):
build_join кладёт is_organizer:true в метаданные токена организатора
(создатель мгновенной конференции и владелец при обычном входе). Метаданные
токена — только подсказка для UI (см. предупреждение в докстринге
build_join), любое серверное действие организатора обязано перепроверяться
по conference.owner_id в БД — так и сделано в mute_participant (B2).

На фронте — общий парсер метаданных участника (lib/participantMetadata.ts,
переиспользован в RoomParticipantTile вместо локальной копии) и хук
useIsOrganizer (читает подсказку для локального участника через
useLocalParticipant — вызывается только внутри LiveKitRoom).
2026-08-01 22:06:10 +03:00
07dc1aaeef feat(chat): выбор эмодзи скачущего коня
Добавлен 🐎 в набор EMOJI_OPTIONS по просьбе оператора.
2026-08-01 22:04:44 +03:00
0162cc8a6d release: версия 0.0.15
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-30 00:27:51 +03:00
5d39e0f076 feat(room): кнопка микрофона в мини-окне (PiP)
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Document PiP не показывает тулбар комнаты — во время звонка в мини-окне
нельзя было замьютить себя, не разворачивая основное окно. Кнопка лежит
поверх единственной плитки мини-плеера и использует тот же useTrackToggle,
что и тулбар: React-контекст LiveKitRoom не разрывается порталом в
pipWindow.document.body, поэтому состояние читается и меняется одинаково
что в основном окне, что в мини-плеере.
2026-07-30 00:25:19 +03:00
43cc1ae3e8 feat(room): закрепление участника доступно из любого режима показа
Булавка закрепления раньше рендерилась только в «Стандарте» — в «Плитках»
и «Живых плитках» её не было вовсе, хотя крупной плитки для показа
закреплённого там и так нет. Теперь кнопка есть на любой плитке в любом
режиме, а сам клик закрепления (не открепления) переключает вид на
«Стандарт» — иначе закреплённого негде показать крупно.

Открепление обратно на предыдущий вид не переключает — это разовое
действие, а не устойчивый выбор пользователя, дёргать раскладку туда-обратно
не нужно. Переключение при закреплении не сохраняется в localStorage
(в отличие от явного выбора режима в тулбаре/настройках) по той же причине.

Заодно закрывает задачу «закрепление на мобильном»: раньше кнопки не было
в плиточных режимах даже там, а на тач-устройствах она и так всегда видна
(`@media (hover: none)`), отдельной мобильной доработки не потребовалось.
2026-07-30 00:25:02 +03:00
82204553e2 feat(chat): выбор эмодзи в панели чата
Кнопка со смайликом слева от поля ввода открывает поповер с небольшим
собственным набором популярных эмодзи (без библиотеки-пикера) — выбор
вставляется в позицию курсора. Поповер открывается вверх от кнопки, поэтому
на мобильном никогда не перекрывает textarea; закрытие — по клику вне и
по Escape (переиспользован useModalDismiss).
2026-07-30 00:23:53 +03:00
3290eb5d07 feat(room): показывать название конференции вместо технического room_name
RoomTopbar рендерил `JoinOut.room_name` — техническое имя комнаты LiveKit
вида `hC-Dmos9KEM`, которое туда попало по ошибке (перепутано с
человекочитаемым title конференции). Теперь топбар получает `title`
(из резолва номера/ссылки или из карточки конференции в лобби) и падает
на «Конференция без названия», если организатор его не задал. Номер и
slug остаются на своих местах — в инвайт-чипе и в адресе, где они нужны
функционально.
2026-07-30 00:23:18 +03:00
f627d827af feat(room): выключенные микрофон и камера при входе в конференцию
Раньше LiveKitRoom публиковал оба трека сразу при подключении (audio/video
без значения — булев true). Теперь участник заходит с выключенными
микрофоном и камерой и включает их сам уже в комнате; кнопки тулбара
(useTrackToggle) отражают фактическое состояние и работают штатно.
2026-07-30 00:22:44 +03:00
5e6c4f8bfd chore(monitoring): глубина логов и срок хранения метрик под наблюдение неделями
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Готовим стенд к сбору статистики по реальным конференциям вместо разового
нагрузочного теста. В прежней конфигурации данные не дожили бы до разбора.

Логи: 10 МБ × 3 → 50 МБ × 5 на контейнер. При полусотне участников логи
LiveKit перезаписывались за часы, а по ним восстанавливается то, чего нет
в метриках: сколько камер работало одновременно, кого и почему отключило,
как шли события congestion. Именно так был уточнён профиль теста 28.07
(оказалось 17 камер, а не 8).

Prometheus: retention 15 суток (дефолт) → 30. Первые два флага в `command`
дублируют дефолт образа намеренно — `command` перекрывает CMD целиком, без
них Prometheus не найдёт конфиг.

Расход: 250 МБ логов на контейнер и рост TSDB со 100 МБ; на сервере
свободно 15 ГБ.
2026-07-29 13:32:18 +03:00
270926cc96 release: версия 0.0.14
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-29 00:17:19 +03:00
e018837a1d fix(livekit): анонсировать клиентам внешний TURN — relay не работал совсем
coturn поднимался, был healthy и слушал 3478 — но клиенты о нём никогда не
узнавали: встроенный TURN выключен (`turn.enabled: false`), внешний в
конфигурации не объявлен, фронтенд `iceServers` не задаёт. За всё время
работы сервера в логах coturn нет ни одной аллокации.

Следствие: у участников из сетей, где прямое UDP-соединение не проходит,
не было relay-фолбэка вообще — только прямой UDP и TCP 7881. На
нагрузочном тесте 28.07 все разрывы `PEER_CONNECTION_DISCONNECTED`
пришлись на внешних участников и ни одного — на офисных.

Добавлена секция `rtc.turn_servers` (UDP и TCP на 3478). Эти серверы
только анонсируются клиенту в списке ICE — сам SFU через них не ходит
(см. iceServersForParticipant в LiveKit). Credentials генерируются по
механизму TURN REST API из общего `TURN_STATIC_AUTH_SECRET`, поэтому
`render-templates.sh` теперь подставляет его и `TURN_EXTERNAL_IP` также в
конфигурацию LiveKit.

TLS (5349/443) намеренно не анонсируется: сертификаты в coturn не
смонтированы, а неработающий `turns:` заставил бы клиента ждать таймаута
перед переходом к рабочему кандидату. Что нужно для его включения —
описано в разделе 8 руководства.

Там же исправлено умолчание в правилах ufw: помимо 3478 нужен диапазон
relay-аллокаций `49160:49200/udp`. Без него TURN отвечает на запросы, но
релей не работает, причём в логах coturn при этом тишина.
2026-07-29 00:17:19 +03:00
36 changed files with 2157 additions and 116 deletions

View File

@@ -112,7 +112,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.13
VIDCONF_VERSION=0.0.16
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,

View File

@@ -3,6 +3,85 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [0.0.16] — 2026-08-01
Роль организатора в комнате: поднятие руки с очередью и принудительный мьют.
### Добавлено
- Роль организатора прокинута в комнату — метаданные LiveKit-токена несут
подсказку `is_organizer` для UI (владелец конференции/администратор);
любое серверное действие организатора перепроверяется по владельцу
конференции в БД, метаданным токена для авторизации не доверяем.
- Поднятие руки: кнопка «Рука» в тулбаре (у всех участников, с общим
счётчиком), бейдж на плитке говорящего, видимый всем, и панель «Очередь»
для организатора — участники в порядке поднятия руки, с возможностью
опустить любую руку. Состояние — в Redis (не в БД): очередь существует
ровно во время звонка. Участник, вышедший из конференции, автоматически
исчезает из очереди; переподключение место не теряет.
- Принудительный мьют: организатор может выключить микрофон или камеру
любого участника — кнопки на его плитке. Участник получает уведомление,
что его выключил организатор, и может включить себя обратно сразу тем же
тулбаром.
- Эмодзи скачущего коня 🐎 в наборе смайликов чата.
### Изменено
- Транспорт чата (`WS /conferences/{id}/chat`) расширен: очередь поднятых
рук и уведомления о мьюте едут по тому же аутентифицированному
соединению, что и сообщения чата — без нового эндпоинта.
Миграций БД нет — новое состояние (очередь поднятых рук) хранится в Redis,
не в Postgres.
## [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
Снижение нагрузки на сеть: клиент перестаёт получать полное качество всех

View File

@@ -1 +1 @@
0.0.13
0.0.16

View File

@@ -1,10 +1,17 @@
"""WS-роутер текстового чата конференции: `WS /api/v1/conferences/{id}/chat`.
"""WS-роутер комнаты конференции: `WS /api/v1/conferences/{id}/chat`.
Протокол: `connect` -> `accept()` -> клиент шлёт `{"type":"auth","token":...}`
первым сообщением (таймаут 10 с; токен не query-параметр — не палим его в
логах nginx) -> сервер проверяет тоггл `chat.enabled` и LiveKit-токен ->
история последних 50 сообщений открытой сессии -> двунаправленный обмен
`{"type":"message","text":...}` через Redis pub/sub (echo отправителю тоже).
история последних 50 сообщений чата + текущая очередь поднятых рук ->
двунаправленный обмен: `{"type":"message","text":...}` (чат, Redis pub/sub,
echo отправителю тоже), `{"type":"raise_hand"}`/`{"type":"lower_hand"}`
(очередь рук, задача B1 — состояние в Redis, см. `services/hand_queue.py`,
НЕ в БД: это эфемерное состояние звонка, а не история). Название файла и
эндпоинта («чат») оставлено как есть — эндпоинт исторически первый и
единственный аутентифицированный WS комнаты, поэтому очередь рук едет по
нему же, а не заводит отдельное соединение (дешевле: сервер уже держит
это соединение на каждого участника).
"""
import asyncio
@@ -13,7 +20,7 @@ import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from pydantic import ValidationError
from pydantic import Field, TypeAdapter, ValidationError
from redis.asyncio.client import PubSub
from sqlalchemy.ext.asyncio import AsyncSession
@@ -28,6 +35,8 @@ from schemas.chat import (
ChatMessageIn,
ChatMessageOut,
)
from schemas.room_events import LowerHandIn, RaiseHandIn
from services import hand_queue
from services.chat import ChatAuthError, ChatIdentity, ChatService, InvalidTokenError, chat_channel
logger = logging.getLogger(__name__)
@@ -37,6 +46,14 @@ router = APIRouter(prefix="/api/v1/conferences", tags=["chat"])
# Таймаут ожидания первого (auth) сообщения клиента.
AUTH_TIMEOUT_SECONDS = 10.0
# Дискриминированное объединение сообщений клиента ПОСЛЕ auth — по полю `type`.
_ClientEnvelope = Annotated[
ChatMessageIn | RaiseHandIn | LowerHandIn, Field(discriminator="type")
]
_client_envelope_adapter: TypeAdapter[ChatMessageIn | RaiseHandIn | LowerHandIn] = TypeAdapter(
_ClientEnvelope
)
@router.websocket("/{conference_id}/chat")
async def chat_websocket(
@@ -44,7 +61,7 @@ async def chat_websocket(
conference_id: uuid.UUID,
session: Annotated[AsyncSession, Depends(get_session)],
) -> None:
"""WS-эндпоинт текстового чата конференции — единая аутентификация LiveKit-токеном."""
"""WS-эндпоинт комнаты конференции — единая аутентификация LiveKit-токеном."""
await websocket.accept()
service = ChatService(session)
@@ -57,21 +74,26 @@ async def chat_websocket(
pubsub = redis_client.pubsub()
channel = chat_channel(conference.id)
# Подписка ДО чтения истории: сообщение,
# опубликованное другим клиентом в окне между SELECT истории и
# subscribe, иначе теряется для подключающегося клиента — Redis начинает
room_channel = hand_queue.hand_queue_channel(conference.id)
# Подписка ДО чтения истории/снапшота очереди: событие,
# опубликованное другим клиентом в окне между SELECT/HGETALL и subscribe,
# иначе теряется для подключающегося клиента — Redis начинает
# буферизовать входящие publish для этого соединения сразу после
# subscribe, до первого вызова `get_message`. На стыке возможен дубликат
# (то же сообщение и в history, и в первом pub/sub-сообщении) — безопаснее
# дедуплицировать по `id`, чем потерять сообщение.
await pubsub.subscribe(channel)
# (то же сообщение чата и в history, и в первом pub/sub-сообщении) —
# безопаснее дедуплицировать по `id`, чем потерять сообщение; снапшот
# очереди дублировать безвредно (полная замена состояния на клиенте).
await pubsub.subscribe(channel, room_channel)
try:
history = await service.history(conference)
await websocket.send_json(ChatHistoryOut(messages=history).model_dump(mode="json"))
seen_ids = {item.id for item in history}
queue_out = await hand_queue.get_snapshot_out(conference.id)
await websocket.send_json(queue_out.model_dump(mode="json"))
async with asyncio.TaskGroup() as tg:
tg.create_task(_pump_pubsub_to_websocket(websocket, pubsub, seen_ids))
tg.create_task(_pump_pubsub_to_websocket(websocket, pubsub, channel, seen_ids))
tg.create_task(_pump_websocket_to_service(websocket, service, conference, identity))
except* WebSocketDisconnect:
# Штатное закрытие соединения клиентом — не ошибка.
@@ -91,7 +113,7 @@ async def chat_websocket(
finally:
# Всегда отписываемся и закрываем pubsub-соединение, иначе при частых
# обрывах соединений копятся забытые подписки на стороне Redis.
await pubsub.unsubscribe(channel)
await pubsub.unsubscribe(channel, room_channel)
# `PubSub.aclose` в redis-py не аннотирован (untyped def) несмотря на
# `py.typed` пакета — узкий игнор именно этого вызова.
await pubsub.aclose() # type: ignore[no-untyped-call]
@@ -111,37 +133,71 @@ async def _authenticate(websocket: WebSocket, service: ChatService) -> ChatIdent
async def _pump_pubsub_to_websocket(
websocket: WebSocket, pubsub: PubSub, seen_ids: set[int]
websocket: WebSocket, pubsub: PubSub, chat_channel_name: str, seen_ids: set[int]
) -> None:
"""Читать сообщения Redis pub/sub канала чата и пересылать их подключённому клиенту.
"""Читать оба Redis pub/sub канала комнаты (чат + очередь рук) и пересылать клиенту.
`seen_ids` — id сообщений, уже отправленных клиенту в `history` (на
`seen_ids` — id сообщений чата, уже отправленных клиенту в `history` (на
стыке подписки и SELECT истории возможен дубликат, см. докстринг
`chat_websocket`) — такие сообщения не пересылаются повторно.
`chat_websocket`) — такие сообщения не пересылаются повторно. Снапшоты
очереди рук такой дедупликации не требуют (полная замена состояния).
"""
while True:
raw = await pubsub.get_message(ignore_subscribe_messages=True, timeout=None)
if raw is None:
continue
if raw["channel"] == chat_channel_name:
message = ChatMessageOut.model_validate_json(raw["data"])
if message.id in seen_ids:
continue
seen_ids.add(message.id)
await websocket.send_json(ChatMessageEventOut(message=message).model_dump(mode="json"))
await websocket.send_json(
ChatMessageEventOut(message=message).model_dump(mode="json")
)
else:
# Канал комнаты (`hand_queue.hand_queue_channel`) — уже готовый
# JSON исходящего конверта (`HandQueueOut`/`ForcedMuteOut`,
# см. `services/hand_queue.py::publish_snapshot` и эндпоинт мьюта
# в `api/conferences.py`), пересылаем как есть без пересборки.
await websocket.send_text(raw["data"])
async def _pump_websocket_to_service(
websocket: WebSocket, service: ChatService, conference: Conference, identity: ChatIdentity
) -> None:
"""Читать текстовые сообщения клиента, валидировать и сохранять+публиковать их."""
"""Читать сообщения клиента (текст чата / поднять-опустить руку), валидировать и обработать."""
is_organizer = conference.owner_id is not None and conference.owner_id == identity.user_id
while True:
raw = await websocket.receive_text()
try:
envelope = ChatMessageIn.model_validate_json(raw)
envelope = _client_envelope_adapter.validate_json(raw)
except ValidationError:
await websocket.send_json(ChatErrorOut(code="invalid_message").model_dump(mode="json"))
continue
if isinstance(envelope, ChatMessageIn):
await service.persist_and_publish(conference, identity=identity, text=envelope.text)
elif isinstance(envelope, RaiseHandIn):
await hand_queue.raise_hand(
conference.id, identity=_identity_key(identity), name=identity.author_name
)
await hand_queue.publish_snapshot(conference.id)
else:
target = envelope.identity or _identity_key(identity)
if target != _identity_key(identity) and not is_organizer:
await websocket.send_json(
ChatErrorOut(code="forbidden").model_dump(mode="json")
)
continue
await hand_queue.lower_hand(conference.id, identity=target)
await hand_queue.publish_snapshot(conference.id)
def _identity_key(identity: ChatIdentity) -> str:
"""Identity участника в формате LiveKit/очереди рук — `str(user_id)` либо `guest:{id}`."""
if identity.user_id is not None:
return str(identity.user_id)
return f"guest:{identity.guest_access_id}"
async def _close_quietly(websocket: WebSocket, code: int) -> None:

View File

@@ -18,6 +18,8 @@ from schemas.conferences import (
GuestJoinIn,
JoinIn,
JoinOut,
MuteParticipantIn,
MuteParticipantOut,
OccurrenceOut,
ResolveOut,
)
@@ -34,6 +36,7 @@ from services.conferences import (
InviteeUserNotFoundError,
NotConferenceOwnerError,
)
from services.room_control import ParticipantNotInRoomError
router = APIRouter(prefix="/api/v1/conferences", tags=["conferences"])
@@ -171,6 +174,37 @@ async def guest_join_conference(
) from exc
@router.post("/{conference_id}/mute-participant", response_model=MuteParticipantOut)
async def mute_participant(
conference_id: uuid.UUID,
data: MuteParticipantIn,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> MuteParticipantOut:
"""Принудительно выключить микрофон/камеру участника (задача B2) — владелец/администратор.
Права проверяются ЗАНОВО по владельцу конференции в БД
(`ConferenceService.mute_participant`), а не по метаданным LiveKit-токена
вызывающего — те лишь подсказка для UI и потенциально подделываемы клиентом.
"""
service = ConferenceService(session)
try:
muted = await service.mute_participant(
conference_id, actor=user, target_identity=data.identity, source=data.source
)
except ConferenceNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc
except NotConferenceOwnerError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc
except ParticipantNotInRoomError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="participant_not_in_room"
) from exc
return MuteParticipantOut(muted=muted)
@router.get("/{conference_id}", response_model=ConferenceOut)
async def get_conference(
conference_id: uuid.UUID,

View File

@@ -6,6 +6,7 @@ from datetime import UTC, datetime, timedelta
from pydantic import BaseModel, EmailStr, Field, field_serializer, field_validator, model_validator
from core.plugins.config import SummaryRecipientsMode
from schemas.room_events import ForcedMuteSource
from services.recurrence import RecurrenceRule
# Допуск в прошлое при плановом создании/правке — небольшой запас на задержку
@@ -210,3 +211,21 @@ class GuestJoinIn(BaseModel):
display_name: str = Field(min_length=1, max_length=255)
email: EmailStr | None = None
password: str | None = None
class MuteParticipantIn(BaseModel):
"""Тело запроса принудительного мьюта участника организатором (задача B2).
`identity` — тот же формат, что и `Participant.identity` в LiveKit
(`str(user_id)` либо `guest:{id}`); клиент берёт его из `useParticipants()`
LiveKit, не подбирает вручную.
"""
identity: str = Field(min_length=1)
source: ForcedMuteSource
class MuteParticipantOut(BaseModel):
"""Ответ на принудительный мьют — `muted=False`, если трек и так не был опубликован."""
muted: bool

View File

@@ -0,0 +1,61 @@
"""Pydantic-схемы событий комнаты, мультиплексируемых поверх WS-чата (`api/chat.py`).
Отдельный протокол от собственно чата (`schemas/chat.py`): очередь поднятых
рук и уведомления о принудительном мьюте — эфемерное состояние звонка
(Redis, не БД, см. `services/hand_queue.py`) и не должны попадать в
персистентную историю сообщений чата, хотя и едут по тому же соединению.
"""
from datetime import UTC, datetime
from typing import Literal
from pydantic import BaseModel, Field, field_serializer
class RaiseHandIn(BaseModel):
"""Клиент поднимает свою руку."""
type: Literal["raise_hand"]
class LowerHandIn(BaseModel):
"""Клиент опускает руку — свою (без `identity`) либо, только для организатора, чужую."""
type: Literal["lower_hand"]
identity: str | None = None
class HandQueueEntryOut(BaseModel):
"""Один участник в очереди поднятых рук."""
identity: str
name: str
raised_at: datetime
@field_serializer("raised_at")
def _serialize_raised_at(self, value: datetime) -> str:
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
class HandQueueOut(BaseModel):
"""Снапшот очереди поднятых рук — рассылается всем участникам при любом изменении."""
type: Literal["hand_queue"] = "hand_queue"
queue: list[HandQueueEntryOut] = Field(default_factory=list)
ForcedMuteSource = Literal["microphone", "camera"]
class ForcedMuteOut(BaseModel):
"""Организатор принудительно выключил трек участника (задача B2) — уведомление всем.
Рассылается всем (не только затронутому), как и `HandQueueOut`: канал —
общий broadcast, а не адресная доставка одному соединению; получатели,
для которых `identity` не совпадает с их собственной, событие
игнорируют.
"""
type: Literal["forced_mute"] = "forced_mute"
identity: str
source: ForcedMuteSource

View File

@@ -53,19 +53,31 @@ def build_join(
name: str,
chat_enabled: bool,
avatar_url: str | None = None,
is_organizer: bool = False,
) -> JoinOut:
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции.
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
`chat_enabled` — снятый вызывающей стороной тоггл `instance_settings`:
читается здесь параметром, а не заново из БД, чтобы не плодить
отдельный запрос настроек на каждый join. `avatar_url` прокидывается
в метаданные токена как JSON
`{"avatar_url": ...}`; `None` (гость либо пользователь без аватара) —
метаданные не выставляются вовсе.
отдельный запрос настроек на каждый join. `avatar_url`/`is_organizer`
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
— поля добавляются, только если заданы (гость без аватара и не-организатор
получают токен вовсе без метаданных, как и раньше).
⚠️ `is_organizer` в метаданных — только подсказка для UI клиента (показать/
скрыть кнопки организатора). Метаданным токена доверять для АВТОРИЗАЦИИ
нельзя — участник технически может их подделать на своей стороне. Любое
серверное действие организатора (например, принудительный мьют) обязано
заново проверяться по `conference.owner_id` в БД, а не по этому полю.
"""
settings = get_settings()
metadata = json.dumps({"avatar_url": avatar_url}) if avatar_url else None
metadata_payload: dict[str, object] = {}
if avatar_url:
metadata_payload["avatar_url"] = avatar_url
if is_organizer:
metadata_payload["is_organizer"] = True
metadata = json.dumps(metadata_payload) if metadata_payload else None
token = create_room_access_token(
room_name=conference.slug, identity=identity, name=name, metadata=metadata
)

View File

@@ -33,12 +33,14 @@ from schemas.conferences import (
JoinOut,
OccurrenceOut,
)
from services import hand_queue
from services.avatars import avatar_url as resolve_avatar_url
from services.conference_access import build_join, ensure_joinable
from services.conference_ids import generate_number, generate_slug
from services.instance_settings import InstanceSettingsService
from services.invitations_producer import enqueue_invitations
from services.recurrence import RecurrenceRule, expand_occurrences
from services.room_control import MuteSource, mute_participant_track
logger = logging.getLogger(__name__)
@@ -154,6 +156,7 @@ class ConferenceService:
name=owner_name,
chat_enabled=chat_enabled,
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
is_organizer=True,
)
else:
# Плановая (разовая) либо закреплённая с повторением/датой — есть
@@ -247,6 +250,7 @@ class ConferenceService:
name=user.name_user,
chat_enabled=chat_enabled,
avatar_url=resolve_avatar_url(self._media_root, user.avatar_path),
is_organizer=conference.owner_id is not None and conference.owner_id == user.id,
)
async def join_as_guest(self, conference_id: uuid.UUID, *, data: GuestJoinIn) -> JoinOut:
@@ -269,6 +273,36 @@ class ConferenceService:
chat_enabled=chat_enabled,
)
async def mute_participant(
self,
conference_id: uuid.UUID,
*,
actor: User,
target_identity: str,
source: MuteSource,
) -> bool:
"""Принудительно замьютить трек участника (задача B2); владелец/администратор.
Права — ТОЛЬКО отсюда (`_ensure_owner_or_admin` по `conference.owner_id`
в БД), не по метаданным LiveKit-токена вызывающего: те лишь подсказка
для UI (см. `services/conference_access.py::build_join`) и потенциально
подделываемы клиентом. `target_identity` НИКАК не валидируется против
состава участников заранее — если его сейчас нет в комнате LiveKit,
`mute_participant_track` бросит `ParticipantNotInRoomError` (ловит
API-роутер).
"""
conference = await self._get_or_raise(conference_id)
self._ensure_owner_or_admin(conference, actor)
muted = await mute_participant_track(
conference.slug, identity=target_identity, source=source
)
if muted:
await hand_queue.publish_forced_mute(
conference.id, identity=target_identity, source=source
)
return muted
async def update(
self, conference_id: uuid.UUID, *, actor: User, data: ConferenceUpdateIn
) -> Conference:

View File

@@ -0,0 +1,144 @@
"""Очередь поднятых рук конференции — состояние в Redis, не в Postgres (задача B1).
Транспорт для клиентов — тот же аутентифицированный WS чата (`api/chat.py`):
переиспользуем уже открытые и держащиеся сервером соединения вместо отдельного
эндпоинта. Хранение — Redis, а не БД: очередь существует ровно во время звонка
и не должна переживать его завершение (в отличие от истории чата), а два
процесса uvicorn (`UVICORN_WORKERS`) делают наивное состояние в памяти одного
процесса недостаточным — организатор и участник могут оказаться на разных
воркерах.
Один Redis-ключ (HASH) на конференцию: поле — identity участника (тот же
формат, что в LiveKit-токене и вебхуках — `str(user_id)` или
`guest:{guest_access.id}`), значение — JSON `{"name": ..., "raised_at": <unix
epoch>}`. `HSETNX` даёт атомарное «добавить, только если ещё нет» — повторное
поднятие уже поднятой руки НЕ сбрасывает её место в очереди (идемпотентно).
Порядок — сортировкой по `raised_at` при чтении снапшота (участников в одной
конференции — единицы-десятки, сортировка в Python здесь дешевле, чем держать
вторую структуру (ZSET) синхронно с первой).
"""
import json
import time
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from core.redis import redis_client
from schemas.room_events import ForcedMuteOut, ForcedMuteSource, HandQueueEntryOut, HandQueueOut
# TTL ключа очереди — подстраховка на случай пропущенного webhook
# `room_finished` (см. `services/webhook_handlers.py::_on_room_finished`,
# который чистит очередь явно при штатном завершении). Сама конференция
# столько не длится ни при каких сценариях.
HAND_QUEUE_TTL_SECONDS = 24 * 60 * 60
def hand_queue_key(conference_id: uuid.UUID) -> str:
"""Redis-ключ HASH очереди поднятых рук конкретной конференции."""
return f"hand_queue:{conference_id}"
def hand_queue_channel(conference_id: uuid.UUID) -> str:
"""Redis pub/sub канал событий комнаты (очередь рук + принудительный мьют, задача B2)."""
return f"room_events:{conference_id}"
@dataclass(frozen=True, slots=True)
class HandQueueEntry:
"""Один участник в очереди поднятых рук."""
identity: str
name: str
raised_at: float
async def raise_hand(conference_id: uuid.UUID, *, identity: str, name: str) -> bool:
"""Поднять руку участника; `True` — рука реально поднялась (не была поднята раньше).
`HSETNX` — атомарная проверка-и-запись: если участник уже в очереди,
ничего не меняет (в т.ч. НЕ обновляет `raised_at`) — переподключение и
повторный клик не переставляют его в конец очереди.
"""
key = hand_queue_key(conference_id)
payload = json.dumps({"name": name, "raised_at": time.time()})
added = await redis_client.hsetnx(key, identity, payload)
await redis_client.expire(key, HAND_QUEUE_TTL_SECONDS)
return bool(added)
async def lower_hand(conference_id: uuid.UUID, *, identity: str) -> bool:
"""Опустить руку участника; `True` — рука была поднята и теперь снята."""
removed = await redis_client.hdel(hand_queue_key(conference_id), identity)
return bool(removed)
async def snapshot(conference_id: uuid.UUID) -> list[HandQueueEntry]:
"""Текущая очередь, упорядоченная по времени поднятия (раньше — раньше в списке)."""
raw = await redis_client.hgetall(hand_queue_key(conference_id))
entries = []
for identity, payload in raw.items():
try:
data = json.loads(payload)
entries.append(
HandQueueEntry(
identity=str(identity), name=data["name"], raised_at=data["raised_at"]
)
)
except (ValueError, KeyError, TypeError):
# Побитый/устаревшего формата элемент — пропускаем, а не роняем всю очередь.
continue
entries.sort(key=lambda entry: entry.raised_at)
return entries
async def clear(conference_id: uuid.UUID) -> None:
"""Полностью снести очередь конференции (штатное завершение — `room_finished`)."""
await redis_client.delete(hand_queue_key(conference_id))
def _to_out(entries: list[HandQueueEntry]) -> HandQueueOut:
"""Собрать исходящий снапшот из внутренних записей очереди."""
return HandQueueOut(
queue=[
HandQueueEntryOut(
identity=entry.identity,
name=entry.name,
raised_at=datetime.fromtimestamp(entry.raised_at, tz=UTC),
)
for entry in entries
]
)
async def get_snapshot_out(conference_id: uuid.UUID) -> HandQueueOut:
"""Текущая очередь в исходящем формате — для отправки сразу после подключения к WS."""
return _to_out(await snapshot(conference_id))
async def publish_snapshot(conference_id: uuid.UUID) -> None:
"""Опубликовать текущий снапшот очереди всем подписчикам канала комнаты.
Вызывается после любого изменения очереди (`raise_hand`/`lower_hand` —
из `api/chat.py`, а также `participant_left`/`room_finished` — из
`services/webhook_handlers.py`), чтобы у всех участников (и особенно у
организатора, зашедшего позже) была всегда актуальная картина.
"""
payload = _to_out(await snapshot(conference_id))
await redis_client.publish(hand_queue_channel(conference_id), payload.model_dump_json())
async def publish_forced_mute(
conference_id: uuid.UUID, *, identity: str, source: ForcedMuteSource
) -> None:
"""Оповестить всех участников комнаты о принудительном мьюте (задача B2).
Тот же канал, что и у очереди рук (`hand_queue_channel`) — `api/chat.py`
пересылает с него ЛЮБОЙ JSON как есть, различая события по полю `type`
(см. `_pump_pubsub_to_websocket`). Рассылается ВСЕМ, а не адресно
затронутому участнику: канал общий на конференцию, адресной доставки
одному соединению тут нет, поэтому клиент сам сверяет `identity` со
своей (см. `ForcedMuteOut` в `schemas/room_events.py`).
"""
payload = ForcedMuteOut(identity=identity, source=source)
await redis_client.publish(hand_queue_channel(conference_id), payload.model_dump_json())

View File

@@ -0,0 +1,81 @@
"""Управление комнатой LiveKit от имени организатора (задача B2): принудительный мьют.
Тонкая обёртка над `RoomServiceClient` — тот же паттерн, что и
`services/egress.py` (единственная точка мокирования в тестах, свой
`api.LiveKitAPI` на вызов, аутентификация СЕРВЕРНЫМИ `api_key`/`api_secret`,
а не токеном организатора). Именно поэтому организатору не нужен отдельный
LiveKit-грант в собственном access-токене под это действие — мьютит backend
от своего имени, клиент лишь инициирует вызов, а право на это проверяется
по владельцу конференции в БД (`services/conferences.py::mute_participant`),
ДО обращения сюда.
"""
import logging
from livekit import api
from livekit.protocol.models import TrackSource
from core.config import get_settings
from schemas.room_events import ForcedMuteSource
logger = logging.getLogger(__name__)
# Переэкспорт под более общим именем — этот модуль не завязан на протокол WS
# (`schemas/room_events.py`), которому концептуально принадлежит `ForcedMuteSource`.
MuteSource = ForcedMuteSource
_TRACK_SOURCE_BY_NAME: dict[MuteSource, int] = {
"microphone": TrackSource.MICROPHONE,
"camera": TrackSource.CAMERA,
}
class ParticipantNotInRoomError(Exception):
"""Участника с таким identity сейчас нет в комнате LiveKit (уже вышел/не заходил)."""
async def mute_participant_track(room_name: str, *, identity: str, source: MuteSource) -> bool:
"""Принудительно замьютить опубликованный трек участника; `True` — трек реально замьючен.
Если трек данного `source` сейчас не опубликован — не ошибка, а no-op:
искомое состояние («трек не идёт») уже достигнуто. Обычный случай с
0.0.15 — участники заходят с выключенными микрофоном/камерой (задача
A1), трек попросту не существует, пока человек не включит его сам;
мьютить в этот момент нечего, и это НЕ повод отвечать клиенту ошибкой.
"""
settings = get_settings()
lkapi = api.LiveKitAPI(
settings.livekit_url,
api_key=settings.livekit_api_key,
api_secret=settings.livekit_api_secret,
)
try:
try:
participant = await lkapi.room.get_participant(
api.RoomParticipantIdentity(room=room_name, identity=identity)
)
except api.TwirpError as exc:
if exc.status == 404:
raise ParticipantNotInRoomError from exc
raise
target_source = _TRACK_SOURCE_BY_NAME[source]
track = next((t for t in participant.tracks if t.source == target_source), None)
if track is None or track.muted:
return False
await lkapi.room.mute_published_track(
api.MuteRoomTrackRequest(
room=room_name, identity=identity, track_sid=track.sid, muted=True
)
)
logger.info(
"room_control: принудительный мьют — комната=%s identity=%s source=%s трек=%s",
room_name,
identity,
source,
track.sid,
)
return True
finally:
await lkapi.aclose()

View File

@@ -27,6 +27,7 @@ from repositories.conferences import (
ConferenceRepository,
ConferenceSessionRepository,
)
from services import hand_queue
from services.egress import run_track_egress
from services.instance_settings import InstanceSettingsService
from services.pipeline_producer import enqueue_pipeline
@@ -154,6 +155,17 @@ class WebhookDispatcher:
return
user_id, guest_id = identity
# Очередь поднятых рук живёт в Redis по `conference.id`, независимо
# от `ConferenceSession` (задача B1) — снимаем руку СРАЗУ, до guard'а
# на отсутствующий открытый сеанс ниже: пропущенный/задержанный
# `room_started` не должен оставлять фантомную запись в очереди у
# реально вышедшего участника. Не путать с обрывом WS-соединения
# самой очереди рук — то живёт своей жизнью и переживается без
# потери места (см. `services/hand_queue.py`).
removed = await hand_queue.lower_hand(conference.id, identity=event.participant.identity)
if removed:
await hand_queue.publish_snapshot(conference.id)
session_record = await self._sessions.get_open_by_conference(conference.id)
if session_record is None:
logger.warning(
@@ -308,6 +320,10 @@ class WebhookDispatcher:
now = datetime.now(UTC)
await self._sessions.close(session_record, t_end=now)
await self._sessions.close_all_open_participants(session_id=session_record.id, left_at=now)
# Очередь поднятых рук — состояние звонка, не история; следующий
# заход (в т.ч. у закреплённой конференции) должен начинать с чистой
# очереди, а не наследовать поднятые руки из прошлого раза.
await hand_queue.clear(conference.id)
# Незакреплённая умирает по завершении (история/саммари остаются);
# закреплённая возвращается в ожидание следующего вхождения (ADR-001, п.2).

View File

@@ -85,11 +85,19 @@ def _guest_token(conference: Conference, guest: GuestAccess) -> str:
async def _connect_and_auth(session: ASGIWebSocketSession, token: str) -> dict[str, Any]:
"""Подключиться, аутентифицироваться и вернуть первое сообщение (`history`)."""
"""Подключиться, аутентифицироваться и вернуть первое сообщение (`history`).
После `history` сервер сразу шлёт снапшот очереди поднятых рук
(`{"type":"hand_queue",...}`, задача B1) — здесь он молча вычитывается
и отбрасывается, чтобы не путать существующие тесты чата, которым он
не интересен (см. `tests/test_hand_queue_ws.py` для тестов самой очереди).
"""
accept = await session.connect()
assert accept["type"] == "websocket.accept"
await session.send_json({"type": "auth", "token": token})
return await session.receive_json()
history = await session.receive_json()
await session.receive_json()
return history
# --- Основной сценарий: обмен сообщениями + история -------------------------

View File

@@ -708,6 +708,38 @@ async def test_join_metadata_absent_for_user_without_avatar(
assert "metadata" not in payload
async def test_join_metadata_contains_is_organizer_for_owner(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(owner)
)
assert response.status_code == 200, response.text
payload = _decode_livekit_token(response.json()["token"])
metadata = json.loads(str(payload["metadata"]))
assert metadata["is_organizer"] is True
async def test_join_metadata_absent_is_organizer_for_non_owner(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
other = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(other)
)
assert response.status_code == 200, response.text
payload = _decode_livekit_token(response.json()["token"])
assert "metadata" not in payload
async def test_guest_join_metadata_is_absent(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:

View File

@@ -0,0 +1,272 @@
"""Тесты очереди поднятых рук поверх WS комнаты (`WS /api/v1/conferences/{id}/chat`, задача B1).
Протокол и аутентификация — общие с чатом (`api/chat.py`), поэтому структура
тестов и хелперы намеренно зеркалят `tests/test_chat_ws.py`.
"""
import uuid
from collections.abc import Callable
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import hash_password
from models.conference import Conference
from models.guest import GuestAccess
from models.user import User
from services.conference_ids import generate_number, generate_slug
from services.livekit_tokens import create_room_access_token
from tests.conftest import ASGIWebSocketSession
WSFactory = Callable[[str], ASGIWebSocketSession]
# --- Хелперы (см. tests/test_chat_ws.py) ------------------------------------
async def _make_user(session: AsyncSession, *, name: str = "Hand Tester") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user=name,
password_hash=hash_password("password123"),
email_verified=True,
)
session.add(user)
await session.flush()
return user
async def _make_conference(
session: AsyncSession, *, owner_id: uuid.UUID | None = None, status: str = "active"
) -> Conference:
conference = Conference(
number=generate_number(),
slug=generate_slug(),
title="Hand Queue Test",
status=status,
owner_id=owner_id,
)
session.add(conference)
await session.flush()
return conference
async def _make_guest(session: AsyncSession, conference: Conference, *, name: str) -> GuestAccess:
guest = GuestAccess(conference_id=conference.id, display_name=name)
session.add(guest)
await session.flush()
return guest
def _chat_path(conference_id: uuid.UUID) -> str:
return f"/api/v1/conferences/{conference_id}/chat"
def _user_token(conference: Conference, user: User) -> str:
return create_room_access_token(
room_name=conference.slug, identity=str(user.id), name=user.name_user
)
def _guest_token(conference: Conference, guest: GuestAccess) -> str:
return create_room_access_token(
room_name=conference.slug, identity=f"guest:{guest.id}", name=guest.display_name
)
async def _connect_auth_and_queue(
session: ASGIWebSocketSession, token: str
) -> dict[str, Any]:
"""Подключиться, аутентифицироваться, вычитать `history` и вернуть снапшот очереди."""
accept = await session.connect()
assert accept["type"] == "websocket.accept"
await session.send_json({"type": "auth", "token": token})
await session.receive_json() # history — не интересен этим тестам
return await session.receive_json()
def _identities(queue_frame: dict[str, Any]) -> list[str]:
return [entry["identity"] for entry in queue_frame["queue"]]
# --- Поднять/опустить свою руку -----------------------------------------------
async def test_raise_and_lower_own_hand_broadcasts_to_everyone(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
conference = await _make_conference(db_session)
alice = await _make_user(db_session, name="Alice")
bob = await _make_user(db_session, name="Bob")
await db_session.commit()
path = _chat_path(conference.id)
ws1 = ws_client(path)
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
ws2 = ws_client(path)
initial2 = await _connect_auth_and_queue(ws2, _user_token(conference, bob))
assert initial2 == {"type": "hand_queue", "queue": []}
await ws1.send_json({"type": "raise_hand"})
queue1 = await ws1.receive_json()
assert _identities(queue1) == [str(alice.id)]
assert queue1["queue"][0]["name"] == "Alice"
assert queue1["queue"][0]["raised_at"].endswith("Z")
queue2 = await ws2.receive_json()
assert queue2 == queue1
await ws1.send_json({"type": "lower_hand"})
queue1_after = await ws1.receive_json()
assert queue1_after == {"type": "hand_queue", "queue": []}
queue2_after = await ws2.receive_json()
assert queue2_after == queue1_after
async def test_raise_hand_order_is_preserved(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""Порядок в очереди — по времени поднятия, не по алфавиту/подключению."""
conference = await _make_conference(db_session)
alice = await _make_user(db_session, name="Alice")
bob = await _make_user(db_session, name="Bob")
await db_session.commit()
path = _chat_path(conference.id)
ws1 = ws_client(path)
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
ws2 = ws_client(path)
await _connect_auth_and_queue(ws2, _user_token(conference, bob))
# Боб поднимает руку ПЕРВЫМ, хотя подключился вторым — он и должен
# оказаться первым в очереди.
await ws2.send_json({"type": "raise_hand"})
await ws2.receive_json()
await ws1.receive_json()
await ws1.send_json({"type": "raise_hand"})
queue = await ws1.receive_json()
assert _identities(queue) == [str(bob.id), str(alice.id)]
async def test_re_raising_hand_does_not_move_position(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""Повторное поднятие уже поднятой руки — идемпотентно, место в очереди не меняется."""
conference = await _make_conference(db_session)
alice = await _make_user(db_session, name="Alice")
bob = await _make_user(db_session, name="Bob")
await db_session.commit()
path = _chat_path(conference.id)
ws1 = ws_client(path)
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
ws2 = ws_client(path)
await _connect_auth_and_queue(ws2, _user_token(conference, bob))
await ws1.send_json({"type": "raise_hand"})
first = await ws1.receive_json()
await ws2.receive_json()
await ws2.send_json({"type": "raise_hand"})
await ws2.receive_json()
await ws1.receive_json()
# Алиса (уже в очереди первой) поднимает руку ещё раз.
await ws1.send_json({"type": "raise_hand"})
repeated = await ws1.receive_json()
await ws2.receive_json()
assert _identities(repeated) == [str(alice.id), str(bob.id)]
assert repeated["queue"][0]["raised_at"] == first["queue"][0]["raised_at"]
async def test_guest_can_raise_hand(db_session: AsyncSession, ws_client: WSFactory) -> None:
conference = await _make_conference(db_session)
guest = await _make_guest(db_session, conference, name="Guest Carl")
await db_session.commit()
ws = ws_client(_chat_path(conference.id))
await _connect_auth_and_queue(ws, _guest_token(conference, guest))
await ws.send_json({"type": "raise_hand"})
queue = await ws.receive_json()
assert _identities(queue) == [f"guest:{guest.id}"]
assert queue["queue"][0]["name"] == "Guest Carl"
# --- Права организатора -------------------------------------------------------
async def test_non_organizer_cannot_lower_someone_elses_hand(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
owner = await _make_user(db_session, name="Owner")
conference = await _make_conference(db_session, owner_id=owner.id)
alice = await _make_user(db_session, name="Alice")
bob = await _make_user(db_session, name="Bob")
await db_session.commit()
path = _chat_path(conference.id)
ws1 = ws_client(path)
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
ws2 = ws_client(path)
await _connect_auth_and_queue(ws2, _user_token(conference, bob))
await ws1.send_json({"type": "raise_hand"})
await ws1.receive_json()
await ws2.receive_json()
# Боб (обычный участник, не организатор) пытается опустить руку Алисы.
await ws2.send_json({"type": "lower_hand", "identity": str(alice.id)})
error = await ws2.receive_json()
assert error == {"type": "error", "code": "forbidden"}
async def test_organizer_can_lower_someone_elses_hand(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
owner = await _make_user(db_session, name="Owner")
conference = await _make_conference(db_session, owner_id=owner.id)
alice = await _make_user(db_session, name="Alice")
await db_session.commit()
path = _chat_path(conference.id)
ws_alice = ws_client(path)
await _connect_auth_and_queue(ws_alice, _user_token(conference, alice))
ws_owner = ws_client(path)
await _connect_auth_and_queue(ws_owner, _user_token(conference, owner))
await ws_alice.send_json({"type": "raise_hand"})
await ws_alice.receive_json()
await ws_owner.receive_json()
await ws_owner.send_json({"type": "lower_hand", "identity": str(alice.id)})
queue_owner = await ws_owner.receive_json()
queue_alice = await ws_alice.receive_json()
assert queue_owner == {"type": "hand_queue", "queue": []}
assert queue_alice == queue_owner
async def test_organizer_joining_late_sees_already_raised_hands(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""Организатор зашёл позже, когда руки уже подняты, — видит актуальную очередь сразу."""
owner = await _make_user(db_session, name="Owner")
conference = await _make_conference(db_session, owner_id=owner.id)
alice = await _make_user(db_session, name="Alice")
await db_session.commit()
path = _chat_path(conference.id)
ws_alice = ws_client(path)
await _connect_auth_and_queue(ws_alice, _user_token(conference, alice))
await ws_alice.send_json({"type": "raise_hand"})
await ws_alice.receive_json()
ws_owner = ws_client(path)
initial_queue = await _connect_auth_and_queue(ws_owner, _user_token(conference, owner))
assert _identities(initial_queue) == [str(alice.id)]

View File

@@ -34,6 +34,7 @@ from models.instance_setting import InstanceSetting
from models.participant import ConferenceParticipant
from models.session import ConferenceSession
from models.user import User
from services import hand_queue
from services.conference_ids import generate_number, generate_slug
from services.egress import EgressStartResult
@@ -217,6 +218,53 @@ async def test_full_cycle_joined_left_finished(
assert conference.ended_at is not None
async def test_participant_left_removes_raised_hand_from_queue(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Задача B1: участник с поднятой рукой вышел из конференции — рука исчезает из очереди."""
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-hand-1@example.com")
await db_session.commit()
identity = str(user.id)
await hand_queue.raise_hand(conference.id, identity=identity, name=user.name_user)
assert [e.identity for e in await hand_queue.snapshot(conference.id)] == [identity]
left = _load_fixture(
"participant_left.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=identity,
)
resp = await _post_webhook(client, left)
assert resp.status_code == 200
assert await hand_queue.snapshot(conference.id) == []
async def test_room_finished_clears_hand_queue(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Задача B1: очередь поднятых рук — состояние звонка, не переживает его завершение."""
conference = await _make_conference(db_session, generate_slug())
await db_session.commit()
started = _load_fixture(
"room_started.json", event_id=f"evt-{uuid.uuid4()}", room_name=conference.slug
)
assert (await _post_webhook(client, started)).status_code == 200
await hand_queue.raise_hand(conference.id, identity="guest:leftover", name="Leftover Guest")
assert len(await hand_queue.snapshot(conference.id)) == 1
finished = _load_fixture(
"room_finished.json", event_id=f"evt-{uuid.uuid4()}", room_name=conference.slug
)
assert (await _post_webhook(client, finished)).status_code == 200
assert await hand_queue.snapshot(conference.id) == []
async def test_pinned_conference_returns_to_scheduled_on_finish(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:

View File

@@ -0,0 +1,245 @@
"""Тесты `POST /api/v1/conferences/{id}/mute-participant` (задача B2).
`mute_participant_track` (реальный вызов LiveKit `RoomServiceClient`) мокается
на уровне `services.conferences` — тот же паттерн, что и `start_track_egress`
в `tests/test_livekit_webhook.py`: сетевой вызов к LiveKit в тестах не нужен,
важна только бизнес-логика (права, маршрутизация ошибок, broadcast).
"""
import asyncio
import uuid
from typing import Any
from unittest.mock import AsyncMock
import httpx
import pytest
from redis.asyncio.client import PubSub
from sqlalchemy.ext.asyncio import AsyncSession
import services.conferences as conferences_module
from core.redis import redis_client
from core.security import create_access_token, hash_password
from models.conference import Conference
from models.user import User
from services.conference_ids import generate_number, generate_slug
from services.hand_queue import hand_queue_channel
from services.room_control import ParticipantNotInRoomError
MUTE_URL = "{base}/mute-participant"
async def _receive_within(pubsub: PubSub, *, max_wait: float) -> dict[str, Any] | None:
"""Дождаться СОДЕРЖАТЕЛЬНОГО сообщения канала в пределах `max_wait` секунд.
`ignore_subscribe_messages=True` у `get_message` фильтрует служебное
подтверждение подписки, но при этом всё равно может вернуть `None` для
ЭТОГО конкретного вызова (см. `api/chat.py::_pump_pubsub_to_websocket`,
ровно поэтому там `while True: ... if raw is None: continue`) — здесь тот
же цикл, но с общим дедлайном вместо бесконечного ожидания.
"""
deadline = asyncio.get_event_loop().time() + max_wait
while True:
remaining = deadline - asyncio.get_event_loop().time()
if remaining <= 0:
return None
raw: dict[str, Any] | None = await pubsub.get_message(
ignore_subscribe_messages=True, timeout=remaining
)
if raw is not None:
return raw
async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Mute Tester",
password_hash=hash_password("password123"),
email_verified=True,
role=role,
)
session.add(user)
await session.flush()
return user
async def _make_conference(
session: AsyncSession, *, owner_id: uuid.UUID | None = None
) -> Conference:
conference = Conference(
number=generate_number(),
slug=generate_slug(),
title="Mute Test",
status="active",
owner_id=owner_id,
)
session.add(conference)
await session.flush()
return conference
def _auth_headers(user: User) -> dict[str, str]:
return {"Authorization": f"Bearer {create_access_token(user.id, user.role)}"}
def _url(conference_id: uuid.UUID) -> str:
return MUTE_URL.format(base=f"/api/v1/conferences/{conference_id}")
async def test_owner_can_mute_participant_and_broadcast_is_published(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
mock_mute = AsyncMock(return_value=True)
monkeypatch.setattr(conferences_module, "mute_participant_track", mock_mute)
pubsub = redis_client.pubsub()
channel = hand_queue_channel(conference.id)
await pubsub.subscribe(channel)
try:
response = await client.post(
_url(conference.id),
json={"identity": "some-identity", "source": "microphone"},
headers=_auth_headers(owner),
)
assert response.status_code == 200, response.text
assert response.json() == {"muted": True}
mock_mute.assert_awaited_once_with(
conference.slug, identity="some-identity", source="microphone"
)
raw = await _receive_within(pubsub, max_wait=2)
assert raw is not None
assert raw["data"] == (
'{"type":"forced_mute","identity":"some-identity","source":"microphone"}'
)
finally:
await pubsub.unsubscribe(channel)
await pubsub.aclose() # type: ignore[no-untyped-call]
async def test_mute_already_off_does_not_broadcast(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Трек не был опубликован (камера/мьют и так выключены, задача A1) — не ошибка.
Ответ `muted=false` (искомое состояние уже достигнуто), без broadcast'а.
"""
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
monkeypatch.setattr(
conferences_module, "mute_participant_track", AsyncMock(return_value=False)
)
pubsub = redis_client.pubsub()
channel = hand_queue_channel(conference.id)
await pubsub.subscribe(channel)
try:
response = await client.post(
_url(conference.id),
json={"identity": "some-identity", "source": "camera"},
headers=_auth_headers(owner),
)
assert response.status_code == 200, response.text
assert response.json() == {"muted": False}
raw = await _receive_within(pubsub, max_wait=0.5)
assert raw is None
finally:
await pubsub.unsubscribe(channel)
await pubsub.aclose() # type: ignore[no-untyped-call]
async def test_admin_can_mute_participant_of_someone_elses_conference(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
admin = await _make_user(db_session, role="admin")
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
monkeypatch.setattr(conferences_module, "mute_participant_track", AsyncMock(return_value=True))
response = await client.post(
_url(conference.id),
json={"identity": "some-identity", "source": "microphone"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
async def test_regular_participant_cannot_mute_someone_else(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
other = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
mock_mute = AsyncMock()
monkeypatch.setattr(conferences_module, "mute_participant_track", mock_mute)
response = await client.post(
_url(conference.id),
json={"identity": str(owner.id), "source": "microphone"},
headers=_auth_headers(other),
)
assert response.status_code == 403
assert response.json()["detail"] == "not_owner"
mock_mute.assert_not_awaited()
async def test_mute_conference_not_found(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
_url(uuid.uuid4()),
json={"identity": "some-identity", "source": "microphone"},
headers=_auth_headers(user),
)
assert response.status_code == 404
assert response.json()["detail"] == "conference_not_found"
async def test_mute_participant_not_in_room_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
monkeypatch.setattr(
conferences_module,
"mute_participant_track",
AsyncMock(side_effect=ParticipantNotInRoomError()),
)
response = await client.post(
_url(conference.id),
json={"identity": "ghost", "source": "microphone"},
headers=_auth_headers(owner),
)
assert response.status_code == 404
assert response.json()["detail"] == "participant_not_in_room"
async def test_mute_rejects_invalid_source(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
response = await client.post(
_url(conference.id),
json={"identity": "some-identity", "source": "screen_share"},
headers=_auth_headers(owner),
)
assert response.status_code == 422

View File

@@ -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,7 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.13}
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.16}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
@@ -664,6 +671,16 @@ 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

View File

@@ -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

View File

@@ -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 готов"

View File

@@ -82,9 +82,14 @@ 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-сети —
# иначе он отдаёт сетевые метрики собственного контейнера вместо метрик
@@ -320,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:` намеренно не анонсируется: анонс неработающего адреса заставил бы
клиента ждать таймаута перед переходом к рабочему кандидату.
---

View File

@@ -145,6 +145,14 @@ export interface ConferenceGuestJoinPayload {
password?: string
}
/** Источник трека, который организатор может принудительно выключить (задача B2). */
export type MuteSource = 'microphone' | 'camera'
/** Ответ на принудительный мьют — `false`, если трек и так не был опубликован (нечего было мьютить). */
export interface MuteParticipantResult {
muted: boolean
}
/** Тело частичного обновления конференции — те же поля, что и при создании, все опциональны. */
export type ConferenceUpdatePayload = Partial<ConferenceCreatePayload>
@@ -202,6 +210,23 @@ export async function guestJoinConference(
})
}
/**
* Принудительно выключить микрофон/камеру участника (задача B2) — только
* владелец конференции/администратор, иначе 403 (`not_owner`). 404
* (`participant_not_in_room`) — участника с таким `identity` сейчас нет в
* комнате LiveKit.
*/
export async function muteParticipant(
conferenceId: string,
identity: string,
source: MuteSource,
): Promise<MuteParticipantResult> {
return apiRequest<MuteParticipantResult>(`/conferences/${conferenceId}/mute-participant`, {
method: 'POST',
body: { identity, source },
})
}
/** Список «моих» конференций — закреплённые (повторяющиеся) и предстоящие разовые владельца. */
export async function getMyConferences(): Promise<ConferenceOut[]> {
return apiRequest<ConferenceOut[]>('/conferences/my')

View File

@@ -1,7 +1,19 @@
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 +50,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 +76,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 +139,7 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
handleSend()
}}
>
<EmojiPickerButton onSelect={insertEmoji} disabled={status !== 'open'} />
<textarea
ref={textareaRef}
rows={1}
@@ -118,3 +157,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>
)
}

View File

@@ -0,0 +1,40 @@
import { useEffect } from 'react'
import { useLocalParticipant } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider'
import type { ForcedMuteEvent } from '@/hooks/useChat'
/**
* Уведомляет ЛОКАЛЬНОГО участника тостом, когда организатор принудительно
* выключил его микрофон/камеру (задача B2). Рендерится безусловно внутри
* `<LiveKitRoom>` — `useLocalParticipant` недоступен снаружи (`RoomPage`
* сам вне контекста LiveKit, см. докстринг `useIsOrganizer`).
*
* Само выключение трека организатор делает СЕРВЕРНЫМ вызовом LiveKit API
* (`services/room_control.py`) — тулбарные кнопки (useTrackToggle) сами
* отразят новое состояние по родному событию LiveKit `TrackMuted`, этот
* компонент только поясняет ПОЧЕМУ: без тоста человек не отличил бы
* действие организатора от случайного глюка. Участник может включить себя
* обратно сразу тем же тулбаром — сервер это не блокирует (см. докстринг B2
* в CHANGELOG/коммите).
*/
export function ForcedMuteWatcher({ event }: { event: ForcedMuteEvent | null }) {
const { localParticipant } = useLocalParticipant()
const toast = useToast()
useEffect(() => {
if (!event || event.identity !== localParticipant.identity) return
toast.show(
event.source === 'microphone'
? 'Организатор выключил ваш микрофон'
: 'Организатор выключил вашу камеру',
'info',
)
// `event` (включая `nonce`) — единственная зависимость, которая должна
// повторно показывать тост; `localParticipant`/`toast` стабильны в
// рамках подключения и намеренно не входят в список, чтобы их
// пересоздание (если когда-нибудь случится) не дублировало уведомление.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [event])
return null
}

View File

@@ -0,0 +1,60 @@
import { Hand, X } from 'lucide-react'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat'
interface HandQueuePanelProps {
queue: HandQueueEntry[]
onLower: (identity: string) => void
onClose: () => void
}
/**
* Панель очереди поднятых рук — видна только организатору (задача B1).
* Визуально — тот же боковой контейнер, что и `ChatPanel` (`.chat-panel`,
* включая мобильное поведение «во весь экран» на ≤900px), содержимое своё:
* упорядоченный список с позицией и кнопкой «Опустить» на каждой строке —
* организатору разрешено опускать чужую руку (решение оператора, задача B1).
*
* `RoomPage` гейтит рендер по `handQueueOpen` (как и `ChatPanel` по
* `chatOpen`) — свой `useIsOrganizer()` здесь ДОПОЛНИТЕЛЬНАЯ, а не
* единственная защита: `RoomToolbar` уже не показывает кнопку открытия
* не-организатору, это подстраховка на случай прямого рендера.
*/
export function HandQueuePanel({ queue, onLower, onClose }: HandQueuePanelProps) {
const isOrganizer = useIsOrganizer()
if (!isOrganizer) return null
return (
<aside className="chat-panel hand-queue-panel">
<div className="chat-head">
<h2>Поднятые руки</h2>
<button type="button" aria-label="Закрыть очередь" onClick={onClose}>
<X className="lucide" aria-hidden="true" />
</button>
</div>
{queue.length === 0 ? (
<p className="chat-empty">Пока никто не поднял руку</p>
) : (
<ol className="hand-queue-list">
{queue.map((entry, index) => (
<li className="hand-queue-item" key={entry.identity}>
<span className="hand-queue-position">{index + 1}</span>
<span className="hand-queue-name">
<Hand className="lucide" aria-hidden="true" />
{entry.name}
</span>
<button
type="button"
className="hand-queue-lower"
onClick={() => onLower(entry.identity)}
>
Опустить
</button>
</li>
))}
</ol>
)}
</aside>
)
}

View File

@@ -1,4 +1,4 @@
import { Pin, PinOff, ScreenShare } from 'lucide-react'
import { Hand, Mic, Pin, PinOff, ScreenShare, Video } from 'lucide-react'
import { Track } from 'livekit-client'
import {
AudioTrack,
@@ -20,21 +20,79 @@ import {
} from '@livekit/components-react'
import { Avatar } from '@/components/ui/Avatar'
import { stageTrackKey } from '@/components/room/stageFocus'
import { parseParticipantMetadata } from '@/lib/participantMetadata'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import { useToast } from '@/components/ui/ToastProvider'
import { muteParticipant } from '@/api/conferences'
/** Метаданные участника из LiveKit access-токена (см. `AccessToken.with_metadata` на backend) — JSON `{"avatar_url": "..."}`; у гостей отсутствуют. */
interface ParticipantMetadata {
avatar_url?: string | null
}
/** Разбирает `participant.metadata` в URL аватара — `null`, если поля нет, метаданные пусты или невалидны (гость). */
/** Достаёт URL аватара из метаданных участника — `null`, если поля нет (гость/без аватара). */
function parseAvatarUrl(metadata: string | undefined): string | null {
if (!metadata) return null
try {
const parsed = JSON.parse(metadata) as ParticipantMetadata
const parsed = parseParticipantMetadata(metadata)
return typeof parsed.avatar_url === 'string' && parsed.avatar_url ? parsed.avatar_url : null
} catch {
return null
}
/**
* Кнопки принудительного мьюта организатором (задача B2) — микрофон/камера
* ЧУЖОГО участника. Видны только организатору (`useIsOrganizer`, подсказка
* UI — сервер перепроверяет права по владельцу конференции в БД) и только на
* чужой плитке камеры (на своей — обычный тулбарный toggle, мьютить себя
* через «принудительное» действие не нужно).
*
* Не проверяют текущее состояние мьюта заранее (усложнило бы плитку ради
* малополезной оптимизации): клик по уже выключенному треку — не ошибка, а
* no-op на backend (`muted: false` в ответе, см. `services/room_control.py`).
*/
function OrganizerMuteControls({
conferenceId,
identity,
displayName,
}: {
conferenceId: string
identity: string
displayName: string
}) {
const toast = useToast()
async function handleMute(source: 'microphone' | 'camera') {
try {
const result = await muteParticipant(conferenceId, identity, source)
if (!result.muted) {
toast.show(
source === 'microphone' ? 'Микрофон и так выключен' : 'Камера и так выключена',
'info',
)
}
} catch {
toast.show('Не удалось выключить трек участника', 'error')
}
}
return (
<div className="room-organizer-controls">
<button
type="button"
title={`Выключить микрофон: ${displayName}`}
aria-label={`Выключить микрофон: ${displayName}`}
onClick={(e) => {
e.stopPropagation()
void handleMute('microphone')
}}
>
<Mic aria-hidden="true" />
</button>
<button
type="button"
title={`Выключить камеру: ${displayName}`}
aria-label={`Выключить камеру: ${displayName}`}
onClick={(e) => {
e.stopPropagation()
void handleMute('camera')
}}
>
<Video aria-hidden="true" />
</button>
</div>
)
}
/**
@@ -44,8 +102,15 @@ function parseAvatarUrl(metadata: string | undefined): string | null {
* разметке (см. `node_modules/@livekit/components-react/src/components/participant/ParticipantTile.tsx`,
* версия 2.9.23 — источник этой копии).
*/
function TileBody({ onStopSharing, pinnedKey, onTogglePin }: TileControlsProps) {
function TileBody({
onStopSharing,
pinnedKey,
onTogglePin,
raisedHandIdentities,
conferenceId,
}: TileControlsProps) {
const trackReference = useEnsureTrackRef()
const isOrganizer = useIsOrganizer()
const isEncrypted = useIsEncrypted(trackReference.participant)
const autoManageSubscription = useFeatureContext()?.autoSubscription
// useParticipantInfo — реактивные name/metadata участника (переподписка на
@@ -67,6 +132,18 @@ function TileBody({ onStopSharing, pinnedKey, onTogglePin }: TileControlsProps)
// рендерятся шаблоном без пропсов, снаружи «какая это плитка» не передать.
const tileKey = stageTrackKey(trackReference)
const isPinned = pinnedKey === tileKey
// Бейдж поднятой руки (задача B1) — только на плитке КАМЕРЫ участника, не
// на плитке его демонстрации экрана (рука — про человека, не про экран).
const isHandRaised =
trackReference.source === Track.Source.Camera &&
Boolean(raisedHandIdentities?.has(trackReference.participant.identity))
// Кнопки принудительного мьюта (задача B2) — организатору, только на
// чужой плитке камеры (см. докстринг `OrganizerMuteControls`).
const showOrganizerMuteControls =
isOrganizer &&
Boolean(conferenceId) &&
trackReference.source === Track.Source.Camera &&
!trackReference.participant.isLocal
return (
<>
@@ -88,6 +165,11 @@ function TileBody({ onStopSharing, pinnedKey, onTogglePin }: TileControlsProps)
<div className="lk-participant-placeholder">
<Avatar name={displayName} avatarUrl={avatarUrl} className="room-tile-avatar" />
</div>
{isHandRaised && (
<div className="room-hand-badge" title={`${displayName}: поднята рука`}>
<Hand className="lucide" aria-hidden="true" />
</div>
)}
<div className="lk-participant-metadata">
<div className="lk-participant-metadata-item">
{trackReference.source === Track.Source.Camera ? (
@@ -141,6 +223,13 @@ function TileBody({ onStopSharing, pinnedKey, onTogglePin }: TileControlsProps)
</button>
</div>
)}
{showOrganizerMuteControls && conferenceId && (
<OrganizerMuteControls
conferenceId={conferenceId}
identity={trackReference.participant.identity}
displayName={displayName}
/>
)}
</>
)
}
@@ -166,6 +255,17 @@ interface TileControlsProps {
* Не передан — кнопки-булавки на плитке нет (мини-плеер: плитка одна).
*/
onTogglePin?: (key: string) => void
/**
* Identity участников с поднятой рукой прямо сейчас (задача B1, из
* `useChat().handQueue`) — плитка сама решает, её ли это identity. Не
* передан — бейдж нигде не рендерится (мини-плеер).
*/
raisedHandIdentities?: Set<string>
/**
* Id конференции (не slug/номер) — нужен для вызова эндпоинта мьюта
* (задача B2). Не передан — кнопок мьюта на плитке нет (мини-плеер).
*/
conferenceId?: string
}
interface RoomParticipantTileProps extends TileControlsProps {
@@ -197,6 +297,8 @@ export function RoomParticipantTile({
onStopSharing,
pinnedKey,
onTogglePin,
raisedHandIdentities,
conferenceId,
}: RoomParticipantTileProps) {
return (
<ParticipantTile
@@ -204,7 +306,13 @@ export function RoomParticipantTile({
disableSpeakingIndicator={disableSpeakingIndicator}
onParticipantClick={onParticipantClick}
>
<TileBody onStopSharing={onStopSharing} pinnedKey={pinnedKey} onTogglePin={onTogglePin} />
<TileBody
onStopSharing={onStopSharing}
pinnedKey={pinnedKey}
onTogglePin={onTogglePin}
raisedHandIdentities={raisedHandIdentities}
conferenceId={conferenceId}
/>
</ParticipantTile>
)
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, type ReactNode } from 'react'
import { EyeOff, Users } from 'lucide-react'
import { EyeOff, Mic, MicOff, Users } from 'lucide-react'
import { Track, type Participant } from 'livekit-client'
import {
CarouselLayout,
@@ -8,6 +8,7 @@ import {
isTrackReference,
useRoomContext,
useSpeakingParticipants,
useTrackToggle,
useTracks,
type TrackReferenceOrPlaceholder,
} from '@livekit/components-react'
@@ -93,6 +94,29 @@ 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 при нескольких
@@ -179,6 +203,9 @@ export function RoomStage({
onHideOthers,
initialFocusKey = null,
onFocusKeyChange,
onPinFocus,
raisedHandIdentities,
conferenceId,
}: {
variant?: 'full' | 'pip'
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
@@ -193,6 +220,18 @@ export function RoomStage({
initialFocusKey?: string | null
/** Сообщать наружу текущий фокус, чтобы его пережил переезд сцены в мини-плеер и обратно. */
onFocusKeyChange?: (key: string | null) => void
/**
* Участника только что закрепили (не открепили) в режиме без крупной
* плитки — сцена сама переключиться не может (режим живёт в `RoomPage`),
* поэтому просит переключить на `standard`, где закреплённого есть куда
* показать крупно. Открепление НЕ вызывает колбэк — вид назад не
* переключаем (см. докстринг `RoomPage`, обоснование решения в коммите).
*/
onPinFocus?: () => void
/** Identity участников с поднятой рукой (задача B1) — бейдж на плитке; игнорируется при `variant="pip"`. */
raisedHandIdentities?: Set<string>
/** Id конференции (задача B2) — кнопки принудительного мьюта на чужих плитках; игнорируется при `variant="pip"`. */
conferenceId?: string
}) {
const room = useRoomContext()
const isCompact = useIsCompactViewport()
@@ -337,9 +376,16 @@ export function RoomStage({
* Закрепить/открепить плитку: повторное нажатие на уже закреплённой снимает
* закрепление. Ключ приходит из самой плитки (она знает свой трек из
* контекста — в карусели/гриде плитки рендерятся шаблоном, без пропсов).
*
* Закрепление доступно из ЛЮБОГО режима показа (задача 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?.()
}
// Мини-плеер показывает ТОЛЬКО активное окно — без карусели/
@@ -350,6 +396,7 @@ export function RoomStage({
return (
<section className="stage room-single-tile">
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
<PipMicToggle />
<RoomAudioRenderer />
</section>
)
@@ -372,23 +419,25 @@ export function RoomStage({
: []
: carouselTracks
const showCarousel = !hideOthers && sideTracks.length > 0
// Закрепление имеет смысл только там, где есть «крупная плитка» —
// в плиточных режимах фокуса нет, поэтому и булавки на плитках нет.
const pinProps =
effectiveMode === 'standard' ? { pinnedKey, onTogglePin: handleTogglePin } : {}
// Булавка закрепления есть на КАЖДОЙ плитке во всех режимах (задача 3.3):
// сама кнопка вызывает переключение на `standard`, где закреплённый и
// попадёт в фокус (см. `handleTogglePin`/`onPinFocus`). Бейдж поднятой
// руки (задача B1) и кнопки принудительного мьюта (задача B2) едут тем же
// спредом — тоже нужны на КАЖДОЙ плитке.
const tileProps = { pinnedKey, onTogglePin: handleTogglePin, raisedHandIdentities, conferenceId }
function renderMain(): ReactNode {
if (effectiveMode === 'tiles') {
return (
<StageGrid tracks={tracks}>
<RoomParticipantTile />
<RoomParticipantTile {...tileProps} />
</StageGrid>
)
}
if (effectiveMode === 'live-tiles') {
return (
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks}>
<RoomParticipantTile />
<RoomParticipantTile {...tileProps} />
</StageGrid>
)
}
@@ -397,7 +446,7 @@ export function RoomStage({
if (sideTracks.length === 0 && !hideOthers) {
return (
<StageGrid tracks={tracks}>
<RoomParticipantTile {...pinProps} />
<RoomParticipantTile {...tileProps} />
</StageGrid>
)
}
@@ -405,7 +454,7 @@ export function RoomStage({
// (см. её исходник), поэтому вместо неё используем свою обёртку
// напрямую с тем же trackRef (аватар в фокус-плитке).
return (
focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} {...pinProps} />
focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} {...tileProps} />
)
}
@@ -429,7 +478,7 @@ export function RoomStage({
</button>
)}
<CarouselLayout tracks={sideTracks}>
<RoomParticipantTile {...pinProps} />
<RoomParticipantTile {...tileProps} />
</CarouselLayout>
</div>
{renderMain()}

View File

@@ -1,4 +1,6 @@
import {
Hand,
ListOrdered,
LogOut,
Maximize,
MessageSquare,
@@ -13,9 +15,11 @@ import {
VideoOff,
} from 'lucide-react'
import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
import { DisconnectButton, useTrackToggle } from '@livekit/components-react'
import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat'
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
/**
@@ -55,18 +59,35 @@ interface RoomToolbarProps extends StageViewProps {
pipSupported: boolean
pipActive: boolean
onTogglePiP: () => void
/**
* Очередь поднятых рук целиком (задача B1, `useChat().handQueue`) — сама
* решает, поднята ли СВОЯ рука (сравнивая с `localParticipant.identity`
* через `useLocalParticipant`), и показывает бейдж общего счётчика.
*/
handQueue: HandQueueEntry[]
onRaiseHand: () => void
onLowerHand: () => void
handQueueOpen: boolean
onToggleHandQueue: () => void
}
/**
* Нижний тулбар комнаты: микрофон/камера/демонстрация экрана/вид сцены/
* настройки устройств/полноэкранный режим/мини-плеер/чат/выход — собственные
* кнопки на хуках LiveKit (useTrackToggle/DisconnectButton) и панели чата,
* стилизованные по design/mockups/room.html.
* Нижний тулбар комнаты: микрофон/камера/демонстрация экрана/рука/очередь
* рук/вид сцены/настройки устройств/полноэкранный режим/мини-плеер/чат/выход —
* собственные кнопки на хуках LiveKit (useTrackToggle/DisconnectButton) и
* панели чата, стилизованные по design/mockups/room.html.
*
* Кнопка «Вид» (режимы показа и скрытие остальных) рендерится ТОЛЬКО на
* широком экране — условным рендерингом, а не скрытием через CSS: тулбар на
* мобильном и так ужат до пяти кнопок, а те же настройки там доступны секцией
* «Вид» в шторке настроек (`DeviceSettingsDialog`).
* мобильном и так ужат до пяти «безусловных» кнопок (демонстрация/
* полноэкранный режим/мини-плеер скрыты на узком экране через CSS, см.
* `styles/room.css`), а те же настройки там доступны секцией «Вид» в шторке
* настроек (`DeviceSettingsDialog`). «Рука» — сознательное исключение из этой
* экономии: поднять руку посреди разговора — действие со временем жизни в
* секунды, прятать его в шторку настроек означало бы делать его практически
* недоступным с телефона. «Очередь» показывается только организатору —
* встречается редко, но по той же причине оставлена в тулбаре, а не в
* шторке: организатору с телефона тоже нужно видеть очередь сразу.
*/
export function RoomToolbar({
chatVisible,
@@ -80,6 +101,11 @@ export function RoomToolbar({
pipSupported,
pipActive,
onTogglePiP,
handQueue,
onRaiseHand,
onLowerHand,
handQueueOpen,
onToggleHandQueue,
layoutMode,
onLayoutModeChange,
hideOthers,
@@ -87,6 +113,9 @@ export function RoomToolbar({
}: RoomToolbarProps) {
const toast = useToast()
const isCompact = useIsCompactViewport()
const isOrganizer = useIsOrganizer()
const { localParticipant } = useLocalParticipant()
const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity)
const mic = useTrackToggle({ source: Track.Source.Microphone })
const camera = useTrackToggle({ source: Track.Source.Camera })
const screenShare = useTrackToggle({
@@ -152,6 +181,40 @@ export function RoomToolbar({
<span className="label">Демонстрация</span>
</button>
<button
type="button"
className={`tb-btn${handRaised ? ' is-hand-raised' : ''}`}
aria-pressed={handRaised}
aria-label={handRaised ? 'Опустить руку' : 'Поднять руку'}
onClick={() => (handRaised ? onLowerHand() : onRaiseHand())}
>
<span className="icon-shell">
<Hand className="lucide" aria-hidden="true" />
{handQueue.length > 0 && (
<span className="badge-count">{handQueue.length > 9 ? '9+' : handQueue.length}</span>
)}
</span>
<span className="label">Рука</span>
</button>
{isOrganizer && (
<button
type="button"
className={`tb-btn${handQueueOpen ? ' is-panel-open' : ''}`}
aria-pressed={handQueueOpen}
aria-label={handQueueOpen ? 'Свернуть очередь поднятых рук' : 'Открыть очередь поднятых рук'}
onClick={onToggleHandQueue}
>
<span className="icon-shell">
<ListOrdered className="lucide" aria-hidden="true" />
{handQueue.length > 0 && (
<span className="badge-count">{handQueue.length > 9 ? '9+' : handQueue.length}</span>
)}
</span>
<span className="label">Очередь</span>
</button>
)}
{!isCompact && (
<StageViewMenu
layoutMode={layoutMode}

View File

@@ -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" />{' '}

View File

@@ -24,10 +24,44 @@ export interface ChatMessageOut {
*/
export type ChatConnectionStatus = 'connecting' | 'open' | 'closed' | 'error'
/**
* Один участник в очереди поднятых рук (задача B1) — 1:1 с pydantic-схемой
* `HandQueueEntryOut` backend. `identity` — тот же формат, что и
* `Participant.identity` в LiveKit (`str(user_id)` либо `guest:{id}`),
* пригоден для прямого сравнения с `localParticipant.identity`/
* `participant.identity` на сцене.
*/
export interface HandQueueEntry {
identity: string
name: string
raised_at: string
}
/** Источник трека, принудительно выключенного организатором (задача B2). */
export type ForcedMuteSource = 'microphone' | 'camera'
/**
* Одно событие принудительного мьюта (задача B2) — рассылается ВСЕМ
* участникам конференции (канал общий, адресной доставки нет), поэтому
* несёт `identity` затронутого: получатель сам решает, про него ли это
* (см. `ForcedMuteWatcher` — сравнивает с `localParticipant.identity`).
* `nonce` — счётчик хука, растёт на каждое полученное событие: тот же
* `source`/`identity` два раза подряд (например, повторный клик
* организатора на уже выключенный трек) должен переоткрыть тост, а не
* молча схлопнуться в один и тот же объект по `useEffect`-сравнению.
*/
export interface ForcedMuteEvent {
identity: string
source: ForcedMuteSource
nonce: number
}
type IncomingFrame =
| { type: 'history'; messages: ChatMessageOut[] }
| { type: 'message'; message: ChatMessageOut }
| { type: 'error'; code: string }
| { type: 'hand_queue'; queue: HandQueueEntry[] }
| { type: 'forced_mute'; identity: string; source: ForcedMuteSource }
interface UseChatOptions {
/** id конференции — пока не известен (страница ещё не подключилась к LiveKit), WS не открываем. */
@@ -47,6 +81,23 @@ interface UseChatResult {
unavailable: boolean
/** Отправить сообщение (1..2000 символов после strip, пустое/слишком длинное — игнорируется). */
sendMessage: (text: string) => void
/**
* Очередь поднятых рук, упорядоченная по времени поднятия — сервер
* присылает полный снапшот при любом изменении (см. `schemas/room_events.py`
* backend), поэтому клиенту не нужно вести собственное состояние очереди.
* Пуста, пока WS не открыт/не пришёл первый снапшот.
*/
handQueue: HandQueueEntry[]
/** Поднять СВОЮ руку — повторный вызов на уже поднятой руке безвреден (идемпотентно на сервере). */
raiseHand: () => void
/**
* Опустить руку — свою (без аргумента) либо чужую по `identity` (только
* организатору, иначе сервер отклонит `{type:"error",code:"forbidden"}`,
* см. `statusMessage`).
*/
lowerHand: (identity?: string) => void
/** Последнее событие принудительного мьюта (задача B2) — `null` до первого. */
lastForcedMute: ForcedMuteEvent | null
}
/** Close-коды сервера — см. зафиксированный протокол WS. */
@@ -61,16 +112,30 @@ function buildChatWsUrl(conferenceId: string): string {
}
/**
* WS-клиент чата комнаты конференции. Реализует зафиксированный протокол:
* connect → `{type:"auth"}` → `{type:"history"}` → далее входящие
* `{type:"message"}`/`{type:"error"}`.
* WS-клиент комнаты конференции (несмотря на имя — не только чат, задача
* B1). Реализует зафиксированный протокол: connect → `{type:"auth"}` →
* `{type:"history"}` → `{type:"hand_queue"}` → далее входящие
* `{type:"message"}`/`{type:"hand_queue"}`/`{type:"error"}`.
*
* Optimistic-append собственных сообщений НЕ делается: сервер всегда
* Очередь поднятых рук (`raiseHand`/`lowerHand`/`handQueue`) едет по тому же
* соединению, что и чат, — переиспользование уже открытого аутентифицированного
* WS дешевле отдельного эндпоинта (см. `backend/api/chat.py`). Следствие:
* поднять руку нельзя, если чат выключен настройкой инстанса (`enabled=false`,
* соединение вообще не открывается) — принятый компромисс, обоснование в
* коммите задачи B1.
*
* Optimistic-append собственных сообщений чата НЕ делается: сервер всегда
* присылает наше же сообщение обратно echo-фреймом `message` — если
* добавлять его на клиенте сразу при отправке, оно задублируется в списке.
* Очередь рук устроена иначе: сервер шлёт ПОЛНЫЙ снапшот на каждое
* изменение, поэтому `raiseHand`/`lowerHand` ничего не трогают в состоянии
* сами — ждут снапшот.
*/
export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseChatResult {
const [messages, setMessages] = useState<ChatMessageOut[]>([])
const [handQueue, setHandQueue] = useState<HandQueueEntry[]>([])
const [lastForcedMute, setLastForcedMute] = useState<ForcedMuteEvent | null>(null)
const forcedMuteNonceRef = useRef(0)
// `wsStatus` меняется ТОЛЬКО из колбэков реального WS-соединения (см. ниже) —
// никогда синхронно в теле эффекта, иначе react-hooks/set-state-in-effect
// (эффект без активной подписки, только синхронизирующий производное
@@ -106,6 +171,7 @@ export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseCh
ws.onopen = () => {
if (stale) return
setMessages([])
setHandQueue([])
setStatusMessage(null)
setUnavailable(false)
setWsStatus('open')
@@ -124,9 +190,19 @@ export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseCh
setMessages(frame.messages)
} else if (frame.type === 'message') {
setMessages((prev) => [...prev, frame.message])
} else if (frame.type === 'hand_queue') {
setHandQueue(frame.queue)
} else if (frame.type === 'forced_mute') {
forcedMuteNonceRef.current += 1
setLastForcedMute({
identity: frame.identity,
source: frame.source,
nonce: forcedMuteNonceRef.current,
})
} else if (frame.type === 'error') {
// Ошибка отдельной операции (например, отклонённое сообщение) — соединение
// не рвётся, просто короткое пояснение пользователю.
// Ошибка отдельной операции (например, отклонённое сообщение или
// запрет опустить чужую руку не-организатору, code:"forbidden") —
// соединение не рвётся, просто короткое пояснение пользователю.
setStatusMessage(`Ошибка чата: ${frame.code}`)
}
}
@@ -168,10 +244,32 @@ export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseCh
ws.send(JSON.stringify({ type: 'message', text: trimmed }))
}, [])
const raiseHand = useCallback(() => {
const ws = wsRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'raise_hand' }))
}, [])
const lowerHand = useCallback((identity?: string) => {
const ws = wsRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'lower_hand', identity: identity ?? null }))
}, [])
// Наружу — производный статус: пока подключаться нечем (выключено/нет
// conferenceId/token), всегда `closed`, даже если внутренний `wsStatus`
// ещё хранит значение от предыдущего подключения.
const status: ChatConnectionStatus = canConnect ? wsStatus : 'closed'
return { messages, status, statusMessage, unavailable, sendMessage }
return {
messages,
status,
statusMessage,
unavailable,
sendMessage,
handQueue,
raiseHand,
lowerHand,
lastForcedMute,
}
}

View File

@@ -0,0 +1,13 @@
import { useLocalParticipant } from '@livekit/components-react'
import { parseParticipantMetadata } from '@/lib/participantMetadata'
/**
* Организатор ли ТЕКУЩИЙ (локальный) участник комнаты — читает подсказку
* `is_organizer` из метаданных собственного LiveKit-токена (см.
* `lib/participantMetadata.ts`). Только для UI (показать/скрыть кнопки
* организатора) — серверные действия перепроверяют права по БД сами.
*/
export function useIsOrganizer(): boolean {
const { localParticipant } = useLocalParticipant()
return Boolean(parseParticipantMetadata(localParticipant.metadata).is_organizer)
}

View File

@@ -0,0 +1,26 @@
/**
* Метаданные участника из LiveKit access-токена (см. `AccessToken.with_metadata`
* на backend, `services/conference_access.py::build_join`) — JSON
* `{"avatar_url"?: string, "is_organizer"?: true}`. У гостей и участников без
* аватара/прав организатора соответствующие поля отсутствуют.
*/
export interface ParticipantMetadata {
avatar_url?: string | null
/**
* Подсказка для UI — организатор ли участник. НЕ источник авторизации:
* метаданные читает и потенциально может подделать сам клиент. Любое
* серверное действие организатора (например, принудительный мьют)
* перепроверяется backend'ом по владельцу конференции в БД.
*/
is_organizer?: boolean
}
/** Разобрать `participant.metadata` — пустой объект, если поля нет, метаданные пусты или невалидны. */
export function parseParticipantMetadata(metadata: string | undefined): ParticipantMetadata {
if (!metadata) return {}
try {
return JSON.parse(metadata) as ParticipantMetadata
} catch {
return {}
}
}

View File

@@ -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,
},

View File

@@ -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,

View File

@@ -16,6 +16,8 @@ import { RoomTopbar } from '@/components/room/RoomTopbar'
import { RoomStage } from '@/components/room/RoomStage'
import { RoomToolbar } from '@/components/room/RoomToolbar'
import { ChatPanel } from '@/components/room/ChatPanel'
import { HandQueuePanel } from '@/components/room/HandQueuePanel'
import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher'
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode'
@@ -23,7 +25,8 @@ import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '
interface RoomJoinState {
livekitUrl: string
token: string
roomName: string
/** Название конференции (`ConferenceOut.title`/`ResolveOut.title`) — для топбара, см. `RoomTopbar` и задачу A6. */
title?: string | null
conferenceId?: string
/** Номер конференции — для инвайт-чипа в топбаре (см. RoomTopbar). Есть только сразу после создания. */
number?: string
@@ -92,7 +95,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,
})
@@ -144,6 +147,18 @@ export function RoomPage() {
// изначально JoinOut.chat_enabled был true (рассинхрон с админкой в моменте).
const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable
// Панель очереди поднятых рук — открыта/закрыта организатором (задача B1).
// Саму видимость кнопки/панели решает `useIsOrganizer()` внутри
// `RoomToolbar`/`HandQueuePanel` (эти компоненты — дети `LiveKitRoom`, а
// `RoomPage` — нет, `useLocalParticipant` здесь не вызвать).
const [handQueueOpen, setHandQueueOpen] = useState(false)
// Identity участников с поднятой рукой — множеством, для дешёвого `.has()`
// на каждой плитке сцены (см. `RoomParticipantTile`).
const raisedHandIdentities = useMemo(
() => new Set(chat.handQueue.map((entry) => entry.identity)),
[chat.handQueue],
)
// Корневой контейнер комнаты — цель для fullscreen и источник video-элемента
// для video-PiP-фолбэка.
const roomRootRef = useRef<HTMLDivElement>(null)
@@ -168,6 +183,15 @@ export function RoomPage() {
saveStageLayoutMode(mode)
}, [])
// Закрепление участника из плиточного режима переключает вид на «Стандарт»
// (см. докстринг `RoomStage`, `onPinFocus`) — но это НЕ то же самое, что
// явный выбор пользователя: переключение не сохраняется в localStorage
// (в отличие от `handleLayoutModeChange`), иначе разовое закрепление в этой
// встрече поменяло бы устойчивое предпочтение режима для всех следующих.
const handlePinFocus = useCallback(() => {
setLayoutMode('standard')
}, [])
// Ключ трека, который сцена показывает крупно. Живёт ЗДЕСЬ, а не только
// внутри `RoomStage`, потому что при открытии мини-плеера сцена
// размонтируется в основном окне и монтируется заново в PiP-окне (портал
@@ -240,13 +264,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 ниже) —
@@ -266,6 +290,9 @@ export function RoomPage() {
onHideOthers={() => setHideOthers(true)}
initialFocusKey={stageFocusKey}
onFocusKeyChange={setStageFocusKey}
onPinFocus={handlePinFocus}
raisedHandIdentities={raisedHandIdentities}
conferenceId={joinState.conferenceId}
/>
)}
{chatVisible && chatOpen && (
@@ -277,6 +304,13 @@ export function RoomPage() {
onClose={() => setChatOpen(false)}
/>
)}
{handQueueOpen && (
<HandQueuePanel
queue={chat.handQueue}
onLower={(identity) => chat.lowerHand(identity)}
onClose={() => setHandQueueOpen(false)}
/>
)}
</div>
<RoomToolbar
chatVisible={chatVisible}
@@ -290,6 +324,11 @@ export function RoomPage() {
pipSupported={pip.supported}
pipActive={pip.active}
onTogglePiP={pip.toggle}
handQueue={chat.handQueue}
onRaiseHand={chat.raiseHand}
onLowerHand={() => chat.lowerHand()}
handQueueOpen={handQueueOpen}
onToggleHandQueue={() => setHandQueueOpen((open) => !open)}
layoutMode={layoutMode}
onLayoutModeChange={handleLayoutModeChange}
hideOthers={hideOthers}
@@ -316,6 +355,7 @@ export function RoomPage() {
<RoomStage variant="pip" initialFocusKey={stageFocusKey} onFocusKeyChange={setStageFocusKey} />,
pip.pipWindow.document.body,
)}
<ForcedMuteWatcher event={chat.lastForcedMute} />
</LiveKitRoom>
</div>
)

View File

@@ -133,9 +133,32 @@ 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` — вместо самой карусели, потому что над
@@ -300,6 +323,12 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
.tb-btn.is-sharing span.label { color: var(--color-room-mic-on); font-weight: 700; }
.tb-btn:disabled { opacity: 0.6; cursor: default; }
/* Кнопка «Рука» — та же зелёная подсветка активного состояния, что у
«Демонстрации» (задача B1): своя поднятая рука — такой же позитивный
индикатор «я сейчас что-то сигнализирую комнате». */
.tb-btn.is-hand-raised .icon-shell { background: var(--color-room-mic-on); border-color: var(--color-room-mic-on); color: #10331f; }
.tb-btn.is-hand-raised span.label { color: var(--color-room-mic-on); font-weight: 700; }
.tb-btn.danger .icon-shell { border-color: var(--color-room-danger); color: var(--color-room-danger); background: transparent; }
.tb-btn.danger:hover .icon-shell { background: var(--color-room-danger-bg); color: #fff; border-color: var(--color-room-danger-bg); }
.tb-btn.danger span.label { color: var(--color-room-danger); font-weight: 700; }
@@ -497,6 +526,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;
@@ -508,6 +580,66 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
}
}
/* ---------- Панель очереди поднятых рук (`HandQueuePanel`, задача B1) ----------
* Контейнер — `.chat-panel` (та же геометрия и мобильное поведение), список
* — свой. */
.hand-queue-list {
list-style: none;
margin: 0;
padding: var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-2);
overflow-y: auto;
}
.hand-queue-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-md);
background: var(--color-room-tile);
}
.hand-queue-position {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--color-room-mic-on);
color: #10331f;
font: var(--text-caption);
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.hand-queue-name {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
font: var(--text-body);
color: var(--color-room-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hand-queue-name svg { width: 16px; height: 16px; flex-shrink: 0; color: var(--color-room-mic-on); }
.hand-queue-lower {
flex-shrink: 0;
padding: 6px 10px;
border-radius: var(--radius-md);
border: 1px solid var(--color-room-tile-border);
background: transparent;
color: var(--color-room-text-secondary);
font: var(--text-caption);
text-transform: none;
letter-spacing: normal;
cursor: pointer;
}
.hand-queue-lower:hover { background: var(--color-room-tile-hover); color: var(--color-room-text-primary); }
/*
* ---------- Диалог «Настройки устройств» ----------
* Нет отдельного макета для диалога в design/mockups/room.html — собран из
@@ -696,6 +828,54 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
outline-offset: -2px;
}
/* Бейдж поднятой руки (задача B1) — левый верхний угол, зеркально булавке
закрепления (правый верхний). В отличие от булавки ВСЕГДА видим, пока
рука поднята, — это статус для ВСЕХ участников, а не собственный
элемент управления, видимый по наведению. */
.room-hand-badge {
position: absolute;
top: 0.25rem;
left: 0.25rem;
z-index: 5;
display: flex;
padding: 0.25rem;
border-radius: calc(var(--lk-border-radius, 0.5rem) / 2);
background: var(--color-room-mic-on);
color: #10331f;
}
.room-hand-badge svg { width: 18px; height: 18px; }
/* Кнопки принудительного мьюта организатором (задача B2) — нижний правый
угол чужой плитки, видны по наведению (как булавка закрепления) —
элемент управления, а не статус, прятать по умолчанию уместно. */
.room-organizer-controls {
position: absolute;
bottom: 0.25rem;
right: 0.25rem;
z-index: 5;
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s ease-in-out;
transition-delay: 0.2s;
}
.lk-participant-tile:hover .room-organizer-controls,
.lk-participant-tile:focus-within .room-organizer-controls { opacity: 1; transition-delay: 0s; }
@media (hover: none) {
.room-organizer-controls { opacity: 1; transition-delay: 0s; }
}
.room-organizer-controls button {
display: flex;
padding: 0.25rem;
border: none;
border-radius: calc(var(--lk-border-radius, 0.5rem) / 2);
background: rgba(0, 0, 0, 0.5);
color: var(--color-room-text-primary);
cursor: pointer;
}
.room-organizer-controls button:hover { background: var(--color-room-danger-bg); color: #fff; }
.room-organizer-controls svg { width: 18px; height: 18px; }
/* ---------- Заглушка «конференция в мини-окне» (Document PiP) ---------- */
.room-pip-placeholder {
flex: 1;