7 Commits

Author SHA1 Message Date
aee76329c4 release: версия 0.0.28 2026-08-04 18:29:22 +03:00
10a3f8b3b4 feat(room): очередь поднятых рук видна всем + отключаемый модуль
Раньше HandQueueMenu.tsx рендерился только организатору — теперь очередь
видит любой участник, но опустить чужую руку по-прежнему может только
организатор (сервер это уже проверял, менял только фронт). Кнопка
«Опустить» показывается у записи, только если это своя рука или
пользователь — организатор.

Модуль «поднятие руки» (кнопка «Рука» + очередь целиком) — отключаемый
в админке (instance_settings.hand_queue, дефолт enabled=true, как у
chat_enabled). Настройка едет участнику в JoinOut ещё до входа в
комнату; выключенный модуль гасит кнопки и на фронте, и на бэке —
raise_hand/lower_hand отклоняются кодом hand_queue_disabled, если
модуль выключен, даже если у клиента на руках старый JoinOut.
2026-08-04 18:27:14 +03:00
65fbcf952c release: версия 0.0.27
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-04 02:52:49 +03:00
b44652d6a6 fix(room): разрыв связи выбрасывал участника в лобби вместо возврата в конференцию
Телефон с погасшим экраном (и просто свёрнутый браузер) выпадал из
конференции: Chrome срезает фоновой вкладке ресурсы, ICE перестаёт
отвечать, и LiveKit закрывает участника через 5 с после потери
соединения. Замерено на проде: 37 с после блокировки экрана, 23 с
после сворачивания браузера. Восстановить сессию после этого нельзя
(сервер отвечает "could not restart participant") — нужен полный
повторный вход, и livekit-client его пытается сделать сам, но его
бюджет повторов в фоновой вкладке успевает сгореть. Тогда приходило
событие Disconnected, и страница уводила пользователя в лобби.

Теперь непреднамеренный разрыв не уводит со страницы, а сбрасывает
joinState — дальше работает уже написанный путь авто-перезахода:
резолв конференции, свежий токен, вход заново. Намеренный выход
отличается по флагу от кнопки "Выйти", а не по коду причины: причина
CLIENT_INITIATED приходит и от кнопки, и от самого livekit-client,
который при заморозке вкладки (событие freeze) вызывает disconnect()
сам — и эта его подписка не отключается опцией disconnectOnPageLeave.

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

Кнопка настроек устройств в тулбаре на мобильном подписана «Настройки»
вместо «Устройства». Раскладка кнопок мобильного тулбара, когда они не
помещаются в один ряд (7+, обычный случай с «Экраном» и «Очередью» у
организатора), стала равномерной сеткой на 4 колонки вместо переноса
«как получится» через flex-wrap.
2026-08-04 00:00:45 +03:00
06455f2401 fix(room): шторка настроек не закрывалась свайпом вниз
Обработчики висели только на ручке `.room-sheet-handle` (40×4px) —
попасть в неё пальцем практически невозможно, и палец почти всегда
приземлялся на панель, где обработчиков не было вовсе. Свайп теперь
закрывает шторку при жесте по любому месту панели, но только когда
содержимое проскроллено в самый верх — иначе палец должен листать
список устройств, как в любом стандартном bottom sheet.
2026-08-03 23:59:26 +03:00
28 changed files with 689 additions and 98 deletions

View File

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

View File

@@ -3,6 +3,92 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/). проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [0.0.28] — 2026-08-04
Очередь поднятых рук видна всем участникам + отключаемый модуль.
### Добавлено
- Очередь поднятых рук больше не спрятана от обычных участников — её
видит любой, кто в конференции, не только организатор. Опустить чужую
руку по-прежнему может только организатор: сервер это уже проверял
(`api/chat.py`, задача B1), правки — только на фронте, кнопка
«Опустить» показывается у записи, если это своя рука либо пользователь
сам организатор.
- «Поднятие руки» (кнопка «Рука» + очередь целиком) — отключаемый модуль
в админке (`instance_settings.hand_queue`, дефолт `enabled=true`
поведение существующих инсталляций не меняется). Настройка едет
участнику в `JoinOut` (как `chat_enabled`) до входа в комнату.
Выключенный модуль гасит кнопки на фронте и отклоняет
`raise_hand`/`lower_hand` на сервере кодом `hand_queue_disabled`
вторая линия защиты для клиента со старым `JoinOut` на руках.
Переключение применяется со следующего входа в комнату (та же
застылость на время жизни соединения, что и у `chat_enabled`).
## [0.0.27] — 2026-08-04
Разрыв связи больше не выбрасывает участника из конференции.
### Исправлено
- Телефон с погасшим экраном выпадал из конференции, а при возвращении
мог оказаться в лобби вместо комнаты. Причина установлена по логам
прода: Chrome срезает ресурсы фоновой вкладке, телефон перестаёт
отвечать по ICE, и LiveKit закрывает участника через 5 с после потери
соединения (замерено: 37 с после блокировки экрана, 23 с после
сворачивания браузера — то есть достаточно просто убрать вкладку в
фон, гасить экран не обязательно). Восстановить сессию после этого
нельзя — участника на сервере уже нет; нужен полный повторный вход,
и `livekit-client` пытается сделать его сам, но его бюджет повторов
(10 попыток за ~44 с) в фоновой вкладке успевает сгореть. Тогда
приходило событие `Disconnected`, и страница комнаты уводила
пользователя в лобби.
Теперь непреднамеренный разрыв не уводит со страницы: сбрасывается
состояние входа, и работает уже имевшийся путь авто-перезахода —
резолв конференции, свежий токен, вход заново. Намеренный выход
отличается по нажатию кнопки «Выйти», а не по коду причины: причину
`CLIENT_INITIATED` присылает и кнопка, и сам `livekit-client`, который
при заморозке вкладки (событие `freeze`) вызывает `disconnect()`
самостоятельно. Разрывы, после которых возвращаться нельзя (участника
выгнал организатор, конференция закрыта, вход той же личностью с
другого устройства), уводят в лобби как раньше. От цикла «вошёл —
сразу выбросило» защищает лимит в 5 перезаходов подряд; соединение,
прожившее дольше 30 с, счётчик обнуляет.
## [0.0.26] — 2026-08-04
Мобильная комната: свайп шторки настроек и полноэкранный режим.
### Исправлено
- Шторка «Настройки» на мобильном не закрывалась свайпом вниз почти никогда:
обработчики висели только на ручке-волоске (`.room-sheet-handle`,
40×4px) — палец в неё практически невозможно попасть, и палец почти
всегда приземлялся на панель, где обработчиков не было вовсе. Теперь
свайп закрывает шторку при жесте по любому месту панели, но только
когда её содержимое проскроллено в самый верх (`scrollTop === 0` на
начало жеста) — иначе свайп вниз листает список устройств, как и должен
стандартный bottom sheet.
### Добавлено
- Полноэкранный режим комнаты — на мобильном кнопка «Экран» в тулбаре (как
на десктопе), при активации топбар и тулбар уходят из потока и лежат
оверлеем поверх сцены: показываются по тапу/клику по сцене вне элементов
управления и сами прячутся через несколько секунд бездействия. На
десктопе — та же логика (топбар/тулбар тоже прячутся в полноэкранном
режиме), дополнительный способ вернуть их — навести мышь в нижнюю полосу
экрана.
### Изменено
- Кнопка настроек устройств в тулбаре на мобильном подписана «Настройки»
вместо «Устройства».
- Раскладка кнопок мобильного тулбара, когда они не помещаются в один ряд
(7 и больше — с полноэкранным режимом и «Очередью» у организатора это
обычный случай), стала равномерной сеткой на 4 колонки (7 → 4+3,
8 → 4+4) вместо переноса «как получится» через `flex-wrap`.
- `deploy/render-templates.sh` умеет читать значения из файла, заданного
переменной `ENV_FILE`, а не только из корневого `.env` (см. коммит
`3847476`, вошёл в этот релиз) — для локальных стендов, где корневой
`.env` указывает на боевые адреса.
## [0.0.25] — 2026-08-03 ## [0.0.25] — 2026-08-03
Два дефекта мини-окна конференции (Document PiP), оба видны только в Chrome. Два дефекта мини-окна конференции (Document PiP), оба видны только в Chrome.

View File

@@ -1 +1 @@
0.0.25 0.0.28

View File

@@ -456,6 +456,7 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
"""Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI.""" """Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI."""
return SettingsOut( return SettingsOut(
chat_enabled=cfg.chat.enabled, chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
transcription_enabled=cfg.transcriber.enabled, transcription_enabled=cfg.transcriber.enabled,
ai_level=cfg.ai_level, ai_level=cfg.ai_level,
ai_levels=detect_ai_levels(cfg), ai_levels=detect_ai_levels(cfg),

View File

@@ -71,6 +71,7 @@ async def chat_websocket(
except ChatAuthError as exc: except ChatAuthError as exc:
await _close_quietly(websocket, exc.close_code) await _close_quietly(websocket, exc.close_code)
return return
hand_queue_enabled = await service.hand_queue_enabled()
pubsub = redis_client.pubsub() pubsub = redis_client.pubsub()
channel = chat_channel(conference.id) channel = chat_channel(conference.id)
@@ -94,7 +95,11 @@ async def chat_websocket(
async with asyncio.TaskGroup() as tg: async with asyncio.TaskGroup() as tg:
tg.create_task(_pump_pubsub_to_websocket(websocket, pubsub, channel, 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)) tg.create_task(
_pump_websocket_to_service(
websocket, service, conference, identity, hand_queue_enabled
)
)
except* WebSocketDisconnect: except* WebSocketDisconnect:
# Штатное закрытие соединения клиентом — не ошибка. # Штатное закрытие соединения клиентом — не ошибка.
pass pass
@@ -163,9 +168,20 @@ async def _pump_pubsub_to_websocket(
async def _pump_websocket_to_service( async def _pump_websocket_to_service(
websocket: WebSocket, service: ChatService, conference: Conference, identity: ChatIdentity websocket: WebSocket,
service: ChatService,
conference: Conference,
identity: ChatIdentity,
hand_queue_enabled: bool,
) -> None: ) -> None:
"""Читать сообщения клиента (текст чата / поднять-опустить руку), валидировать и обработать.""" """Читать сообщения клиента (текст чата / поднять-опустить руку), валидировать и обработать.
`hand_queue_enabled` — снятый один раз при подключении тоггл модуля
«поднятие руки» (см. `ChatService.hand_queue_enabled`): при `False`
`raise_hand`/`lower_hand` отклоняются кодом `hand_queue_disabled` — вторая
линия защиты сверх того, что фронт при выключенном модуле вообще не
рисует кнопки (см. `RoomToolbar`/`HandQueueMenu`).
"""
is_organizer = conference.owner_id is not None and conference.owner_id == identity.user_id is_organizer = conference.owner_id is not None and conference.owner_id == identity.user_id
while True: while True:
raw = await websocket.receive_text() raw = await websocket.receive_text()
@@ -178,11 +194,21 @@ async def _pump_websocket_to_service(
if isinstance(envelope, ChatMessageIn): if isinstance(envelope, ChatMessageIn):
await service.persist_and_publish(conference, identity=identity, text=envelope.text) await service.persist_and_publish(conference, identity=identity, text=envelope.text)
elif isinstance(envelope, RaiseHandIn): elif isinstance(envelope, RaiseHandIn):
if not hand_queue_enabled:
await websocket.send_json(
ChatErrorOut(code="hand_queue_disabled").model_dump(mode="json")
)
continue
await hand_queue.raise_hand( await hand_queue.raise_hand(
conference.id, identity=_identity_key(identity), name=identity.author_name conference.id, identity=_identity_key(identity), name=identity.author_name
) )
await hand_queue.publish_snapshot(conference.id) await hand_queue.publish_snapshot(conference.id)
else: else:
if not hand_queue_enabled:
await websocket.send_json(
ChatErrorOut(code="hand_queue_disabled").model_dump(mode="json")
)
continue
target = envelope.identity or _identity_key(identity) target = envelope.identity or _identity_key(identity)
if target != _identity_key(identity) and not is_organizer: if target != _identity_key(identity) and not is_organizer:
await websocket.send_json( await websocket.send_json(

View File

@@ -33,12 +33,19 @@ class ChatConfig(BaseModel):
enabled: bool = True enabled: bool = True
class HandQueueConfig(BaseModel):
"""Конфигурация переключателя модуля «поднятие руки» (кнопка + очередь целиком)."""
enabled: bool = True
class PluginsConfig(BaseModel): class PluginsConfig(BaseModel):
"""Корневая модель конфигурации для `config/plugins.yaml`.""" """Корневая модель конфигурации для `config/plugins.yaml`."""
transcriber: TranscriberConfig = Field(default_factory=TranscriberConfig) transcriber: TranscriberConfig = Field(default_factory=TranscriberConfig)
summarizer: SummarizerConfig = Field(default_factory=SummarizerConfig) summarizer: SummarizerConfig = Field(default_factory=SummarizerConfig)
chat: ChatConfig = Field(default_factory=ChatConfig) chat: ChatConfig = Field(default_factory=ChatConfig)
hand_queue: HandQueueConfig = Field(default_factory=HandQueueConfig)
def load_plugins_config(path: str | Path) -> PluginsConfig: def load_plugins_config(path: str | Path) -> PluginsConfig:
@@ -92,6 +99,7 @@ class InstanceConfig(BaseModel):
transcriber: TranscriberConfig transcriber: TranscriberConfig
summarizer: SummarizerConfig summarizer: SummarizerConfig
chat: ChatConfig chat: ChatConfig
hand_queue: HandQueueConfig = Field(default_factory=HandQueueConfig)
ai_level: AiLevel = "min" ai_level: AiLevel = "min"
summary_recipients: SummaryRecipientsMode = "all" summary_recipients: SummaryRecipientsMode = "all"
display_timezone: str = "Europe/Moscow" display_timezone: str = "Europe/Moscow"

View File

@@ -147,6 +147,7 @@ class SettingsOut(BaseModel):
""" """
chat_enabled: bool chat_enabled: bool
hand_queue_enabled: bool
transcription_enabled: bool transcription_enabled: bool
ai_level: AiLevel ai_level: AiLevel
ai_levels: list[AiLevelStatus] ai_levels: list[AiLevelStatus]

View File

@@ -130,6 +130,9 @@ class JoinOut(BaseModel):
# Тоггл инстанса `chat.enabled` на момент входа — клиент решает, # Тоггл инстанса `chat.enabled` на момент входа — клиент решает,
# показывать ли UI чата, не дожидаясь ошибки WS-подключения. # показывать ли UI чата, не дожидаясь ошибки WS-подключения.
chat_enabled: bool chat_enabled: bool
# Тоггл инстанса `hand_queue.enabled` на момент входа — гасит модуль
# «поднятие руки» целиком (кнопка + очередь), см. `HandQueueConfig`.
hand_queue_enabled: bool
# Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются # Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются
# прямо в join-ответе, а не только в админке: участнику нужно иметь их # прямо в join-ответе, а не только в админке: участнику нужно иметь их
# на руках ДО публикации своего трека (см. `services/conference_access.py`). # на руках ДО публикации своего трека (см. `services/conference_access.py`).

View File

@@ -148,6 +148,15 @@ class ChatService:
raise WrongRoomError raise WrongRoomError
return conference return conference
async def hand_queue_enabled(self) -> bool:
"""Тоггл инстанса `hand_queue.enabled` — снимается один раз при подключении WS
(см. `api/chat.py::chat_websocket`), а не на каждое сообщение: та же
осознанная «застылость» на время жизни соединения, что и у
`is_organizer` в `_pump_websocket_to_service` — переключение модуля
администратором применяется со следующего подключения."""
cfg = await InstanceSettingsService(self._session).get()
return cfg.hand_queue.enabled
async def history(self, conference: Conference) -> list[ChatMessageOut]: async def history(self, conference: Conference) -> list[ChatMessageOut]:
"""Последние сообщения открытой сессии конференции (пусто, если сессии ещё нет).""" """Последние сообщения открытой сессии конференции (пусто, если сессии ещё нет)."""
session_record = await self._sessions.get_open_by_conference(conference.id) session_record = await self._sessions.get_open_by_conference(conference.id)

View File

@@ -53,6 +53,7 @@ def build_join(
identity: str, identity: str,
name: str, name: str,
chat_enabled: bool, chat_enabled: bool,
hand_queue_enabled: bool,
publish_quality_cap: PublishQualityCap, publish_quality_cap: PublishQualityCap,
stage_max_tiles: StageMaxTiles, stage_max_tiles: StageMaxTiles,
avatar_url: str | None = None, avatar_url: str | None = None,
@@ -61,9 +62,10 @@ def build_join(
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции. """Построить ответ join: LiveKit access-токен для входа в комнату конференции.
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4). Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
`chat_enabled`/`publish_quality_cap`/`stage_max_tiles` — снятые вызывающей `chat_enabled`/`hand_queue_enabled`/`publish_quality_cap`/`stage_max_tiles`
стороной значения `instance_settings`: читаются здесь параметрами, а не — снятые вызывающей стороной значения `instance_settings`: читаются здесь
заново из БД, чтобы не плодить отдельный запрос настроек на каждый join. параметрами, а не заново из БД, чтобы не плодить отдельный запрос настроек
на каждый join.
`avatar_url`/`is_organizer` `avatar_url`/`is_organizer`
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}` прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
— поля добавляются, только если заданы (гость без аватара и не-организатор — поля добавляются, только если заданы (гость без аватара и не-организатор
@@ -91,6 +93,7 @@ def build_join(
room_name=conference.slug, room_name=conference.slug,
conference_id=conference.id, conference_id=conference.id,
chat_enabled=chat_enabled, chat_enabled=chat_enabled,
hand_queue_enabled=hand_queue_enabled,
publish_quality_cap=publish_quality_cap, publish_quality_cap=publish_quality_cap,
stage_max_tiles=stage_max_tiles, stage_max_tiles=stage_max_tiles,
) )

View File

@@ -155,6 +155,7 @@ class ConferenceService:
identity=str(owner_id), identity=str(owner_id),
name=owner_name, name=owner_name,
chat_enabled=cfg.chat.enabled, chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap, publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles, stage_max_tiles=cfg.media_limits.stage_max_tiles,
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path), avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
@@ -251,6 +252,7 @@ class ConferenceService:
identity=str(user.id), identity=str(user.id),
name=user.name_user, name=user.name_user,
chat_enabled=cfg.chat.enabled, chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap, publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles, stage_max_tiles=cfg.media_limits.stage_max_tiles,
avatar_url=resolve_avatar_url(self._media_root, user.avatar_path), avatar_url=resolve_avatar_url(self._media_root, user.avatar_path),
@@ -275,6 +277,7 @@ class ConferenceService:
identity=f"guest:{guest.id}", identity=f"guest:{guest.id}",
name=data.display_name, name=data.display_name,
chat_enabled=cfg.chat.enabled, chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap, publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles, stage_max_tiles=cfg.media_limits.stage_max_tiles,
) )

View File

@@ -1,7 +1,7 @@
"""Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап. """Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап.
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`, Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
`ai_level`, `summary_recipients`, `display_timezone`, `hand_queue`, `ai_level`, `summary_recipients`, `display_timezone`,
`registration_team_choice`, `registration_email_domain`, `contact_email`, `registration_team_choice`, `registration_email_domain`, `contact_email`,
`media_limits`) — `media_limits`) —
новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`) новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
@@ -28,6 +28,7 @@ from core.config import Settings
from core.plugins.config import ( from core.plugins.config import (
AiLevel, AiLevel,
ChatConfig, ChatConfig,
HandQueueConfig,
InstanceConfig, InstanceConfig,
MediaLimitsConfig, MediaLimitsConfig,
PluginsConfig, PluginsConfig,
@@ -45,6 +46,7 @@ from services.ai_tiers import TIERS
_KEY_TRANSCRIBER = "transcriber" _KEY_TRANSCRIBER = "transcriber"
_KEY_SUMMARIZER = "summarizer" _KEY_SUMMARIZER = "summarizer"
_KEY_CHAT = "chat" _KEY_CHAT = "chat"
_KEY_HAND_QUEUE = "hand_queue"
_KEY_AI_LEVEL = "ai_level" _KEY_AI_LEVEL = "ai_level"
_KEY_SUMMARY_RECIPIENTS = "summary_recipients" _KEY_SUMMARY_RECIPIENTS = "summary_recipients"
_KEY_DISPLAY_TIMEZONE = "display_timezone" _KEY_DISPLAY_TIMEZONE = "display_timezone"
@@ -100,6 +102,7 @@ class SettingsUpdateIn(BaseModel):
""" """
chat_enabled: bool | None = None chat_enabled: bool | None = None
hand_queue_enabled: bool | None = None
transcription_enabled: bool | None = None transcription_enabled: bool | None = None
ai_level: AiLevel | None = None ai_level: AiLevel | None = None
summary_recipients: SummaryRecipientsMode | None = None summary_recipients: SummaryRecipientsMode | None = None
@@ -154,6 +157,7 @@ def build_bootstrap_defaults(
_KEY_TRANSCRIBER: plugins.transcriber.model_dump(mode="json"), _KEY_TRANSCRIBER: plugins.transcriber.model_dump(mode="json"),
_KEY_SUMMARIZER: plugins.summarizer.model_dump(mode="json"), _KEY_SUMMARIZER: plugins.summarizer.model_dump(mode="json"),
_KEY_CHAT: plugins.chat.model_dump(mode="json"), _KEY_CHAT: plugins.chat.model_dump(mode="json"),
_KEY_HAND_QUEUE: plugins.hand_queue.model_dump(mode="json"),
_KEY_AI_LEVEL: dict(_DEFAULT_AI_LEVEL_VALUE), _KEY_AI_LEVEL: dict(_DEFAULT_AI_LEVEL_VALUE),
_KEY_SUMMARY_RECIPIENTS: dict(_DEFAULT_SUMMARY_RECIPIENTS_VALUE), _KEY_SUMMARY_RECIPIENTS: dict(_DEFAULT_SUMMARY_RECIPIENTS_VALUE),
_KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE), _KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE),
@@ -286,6 +290,10 @@ class InstanceSettingsService:
cfg.chat = ChatConfig(enabled=patch.chat_enabled) cfg.chat = ChatConfig(enabled=patch.chat_enabled)
await self._set(_KEY_CHAT, cfg.chat.model_dump(mode="json")) await self._set(_KEY_CHAT, cfg.chat.model_dump(mode="json"))
if patch.hand_queue_enabled is not None:
cfg.hand_queue = HandQueueConfig(enabled=patch.hand_queue_enabled)
await self._set(_KEY_HAND_QUEUE, cfg.hand_queue.model_dump(mode="json"))
if patch.registration_team_choice is not None: if patch.registration_team_choice is not None:
cfg.registration_team_choice = patch.registration_team_choice cfg.registration_team_choice = patch.registration_team_choice
await self._set( await self._set(
@@ -399,6 +407,7 @@ async def load_effective_config(session: AsyncSession) -> InstanceConfig:
transcriber=plugins.transcriber, transcriber=plugins.transcriber,
summarizer=plugins.summarizer, summarizer=plugins.summarizer,
chat=plugins.chat, chat=plugins.chat,
hand_queue=plugins.hand_queue,
) )
else: else:
cfg = _build_config(rows) cfg = _build_config(rows)
@@ -492,6 +501,7 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
transcriber=TranscriberConfig.model_validate(rows.get(_KEY_TRANSCRIBER, {})), transcriber=TranscriberConfig.model_validate(rows.get(_KEY_TRANSCRIBER, {})),
summarizer=SummarizerConfig.model_validate(rows.get(_KEY_SUMMARIZER, {})), summarizer=SummarizerConfig.model_validate(rows.get(_KEY_SUMMARIZER, {})),
chat=ChatConfig.model_validate(rows.get(_KEY_CHAT, {})), chat=ChatConfig.model_validate(rows.get(_KEY_CHAT, {})),
hand_queue=HandQueueConfig.model_validate(rows.get(_KEY_HAND_QUEUE, {})),
ai_level=rows.get(_KEY_AI_LEVEL, _DEFAULT_AI_LEVEL_VALUE).get("level", "min"), ai_level=rows.get(_KEY_AI_LEVEL, _DEFAULT_AI_LEVEL_VALUE).get("level", "min"),
summary_recipients=rows.get(_KEY_SUMMARY_RECIPIENTS, _DEFAULT_SUMMARY_RECIPIENTS_VALUE).get( summary_recipients=rows.get(_KEY_SUMMARY_RECIPIENTS, _DEFAULT_SUMMARY_RECIPIENTS_VALUE).get(
"mode", "all" "mode", "all"

View File

@@ -646,12 +646,17 @@ async def test_put_settings_partial_update(
response = await client.put( response = await client.put(
"/api/v1/admin/settings", "/api/v1/admin/settings",
json={"chat_enabled": False, "display_timezone": "Asia/Yekaterinburg"}, json={
"chat_enabled": False,
"hand_queue_enabled": False,
"display_timezone": "Asia/Yekaterinburg",
},
headers=_auth_headers(admin), headers=_auth_headers(admin),
) )
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
body = response.json() body = response.json()
assert body["chat_enabled"] is False assert body["chat_enabled"] is False
assert body["hand_queue_enabled"] is False
assert body["display_timezone"] == "Asia/Yekaterinburg" assert body["display_timezone"] == "Asia/Yekaterinburg"
assert body["ai_level"] == "min" assert body["ai_level"] == "min"
assert body["transcription_queue_served"] is False assert body["transcription_queue_served"] is False

View File

@@ -8,13 +8,15 @@ import uuid
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
import httpx
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from core.security import hash_password from core.security import create_access_token, hash_password
from models.conference import Conference from models.conference import Conference
from models.guest import GuestAccess from models.guest import GuestAccess
from models.user import User from models.user import User
from services.conference_ids import generate_number, generate_slug from services.conference_ids import generate_number, generate_slug
from services.instance_settings import InstanceSettingsService, SettingsUpdateIn
from services.livekit_tokens import create_room_access_token from services.livekit_tokens import create_room_access_token
from tests.conftest import ASGIWebSocketSession from tests.conftest import ASGIWebSocketSession
@@ -270,3 +272,66 @@ async def test_organizer_joining_late_sees_already_raised_hands(
initial_queue = await _connect_auth_and_queue(ws_owner, _user_token(conference, owner)) initial_queue = await _connect_auth_and_queue(ws_owner, _user_token(conference, owner))
assert _identities(initial_queue) == [str(alice.id)] assert _identities(initial_queue) == [str(alice.id)]
# --- Отключаемый модуль (`instance_settings.hand_queue`) ---------------------
async def test_hand_queue_disabled_rejects_raise_and_lower(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""Выключенный модуль — вторая линия защиты сверх фронта: `raise_hand`/
`lower_hand` отклоняются кодом `hand_queue_disabled`, очередь не меняется."""
conference = await _make_conference(db_session)
alice = await _make_user(db_session, name="Alice")
await db_session.commit()
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
ws = ws_client(_chat_path(conference.id))
await _connect_auth_and_queue(ws, _user_token(conference, alice))
await ws.send_json({"type": "raise_hand"})
error = await ws.receive_json()
assert error == {"type": "error", "code": "hand_queue_disabled"}
await ws.send_json({"type": "lower_hand"})
error = await ws.receive_json()
assert error == {"type": "error", "code": "hand_queue_disabled"}
async def test_join_out_reflects_hand_queue_enabled_toggle(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session, name="Toggle Tester")
await db_session.commit()
headers = {"Authorization": f"Bearer {create_access_token(user.id, user.role)}"}
response = await client.post(
"/api/v1/conferences", json={"title": "Standup"}, headers=headers
)
assert response.status_code == 201, response.text
assert response.json()["join"]["hand_queue_enabled"] is True
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
response = await client.post(
"/api/v1/conferences", json={"title": "Standup 2"}, headers=headers
)
assert response.status_code == 201, response.text
assert response.json()["join"]["hand_queue_enabled"] is False
async def test_guest_join_out_reflects_hand_queue_enabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session)
await db_session.commit()
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Dave"}
)
assert response.status_code == 200, response.text
assert response.json()["hand_queue_enabled"] is False

View File

@@ -65,6 +65,7 @@ _MANAGED_KEYS = (
"transcriber", "transcriber",
"summarizer", "summarizer",
"chat", "chat",
"hand_queue",
"ai_level", "ai_level",
"summary_recipients", "summary_recipients",
"display_timezone", "display_timezone",
@@ -121,6 +122,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
"transcriber", "transcriber",
"summarizer", "summarizer",
"chat", "chat",
"hand_queue",
"ai_level", "ai_level",
"summary_recipients", "summary_recipients",
"display_timezone", "display_timezone",
@@ -132,6 +134,9 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
cfg = await service.get() cfg = await service.get()
assert cfg.transcriber.provider == "faster_whisper_cpu" assert cfg.transcriber.provider == "faster_whisper_cpu"
assert cfg.ai_level == "min" assert cfg.ai_level == "min"
# Дефолт обязан сохранять поведение существующих инсталляций — модуль
# «поднятие руки» был доступен всегда, тоггл включён по умолчанию.
assert cfg.hand_queue.enabled is True
assert cfg.summary_recipients == "all" assert cfg.summary_recipients == "all"
assert cfg.display_timezone == "Europe/Moscow" assert cfg.display_timezone == "Europe/Moscow"
assert cfg.registration_team_choice is False assert cfg.registration_team_choice is False
@@ -162,6 +167,22 @@ async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
assert cfg.display_timezone == "Asia/Yekaterinburg" assert cfg.display_timezone == "Asia/Yekaterinburg"
async def test_update_hand_queue_enabled(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(SettingsUpdateIn(hand_queue_enabled=False))
assert cfg.hand_queue.enabled is False
cfg = await service.get()
assert cfg.hand_queue.enabled is False
cfg = await service.update(SettingsUpdateIn(hand_queue_enabled=True))
assert cfg.hand_queue.enabled is True
@pytest.mark.parametrize( @pytest.mark.parametrize(
("preset", "chat_enabled", "ai_enabled", "ai_level"), ("preset", "chat_enabled", "ai_enabled", "ai_level"),
[ [

View File

@@ -33,3 +33,6 @@ summarizer:
chat: chat:
enabled: true enabled: true
hand_queue:
enabled: true

View File

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

View File

@@ -19,6 +19,8 @@ export interface AiLevelStatus {
/** Эффективные настройки инстанса. */ /** Эффективные настройки инстанса. */
export interface SettingsOut { export interface SettingsOut {
chat_enabled: boolean chat_enabled: boolean
/** Включён ли модуль «поднятие руки» — кнопка «Рука» и очередь целиком. */
hand_queue_enabled: boolean
/** Единый переключатель модуля AI (транскрибация + суммаризация). */ /** Единый переключатель модуля AI (транскрибация + суммаризация). */
transcription_enabled: boolean transcription_enabled: boolean
/** Есть ли хотя бы один Celery-воркер, обслуживающий очередь транскрибации. */ /** Есть ли хотя бы один Celery-воркер, обслуживающий очередь транскрибации. */
@@ -48,6 +50,7 @@ export interface SettingsOut {
/** Тело частичного обновления настроек инстанса — все поля опциональны. */ /** Тело частичного обновления настроек инстанса — все поля опциональны. */
export interface SettingsUpdateIn { export interface SettingsUpdateIn {
chat_enabled?: boolean chat_enabled?: boolean
hand_queue_enabled?: boolean
transcription_enabled?: boolean transcription_enabled?: boolean
/** Недоступный уровень (см. `ai_levels`) — backend отвечает 400. */ /** Недоступный уровень (см. `ai_levels`) — backend отвечает 400. */
ai_level?: AiLevel ai_level?: AiLevel

View File

@@ -61,6 +61,8 @@ export interface ConferenceJoinData {
conference_id: string conference_id: string
/** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */ /** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */
chat_enabled: boolean chat_enabled: boolean
/** Включён ли модуль «поднятие руки» — при `false` кнопка «Рука» и очередь не рендерятся. */
hand_queue_enabled: boolean
/** Потолок качества публикации видео на момент входа — см. `PublishQualityCap`. */ /** Потолок качества публикации видео на момент входа — см. `PublishQualityCap`. */
publish_quality_cap: PublishQualityCap publish_quality_cap: PublishQualityCap
/** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */ /** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */

View File

@@ -68,6 +68,7 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
const { user } = useAuth() const { user } = useAuth()
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled) const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
const [handQueueEnabled, setHandQueueEnabled] = useState(data.hand_queue_enabled)
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled) const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
const [aiLevel, setAiLevel] = useState<AiLevel>(data.ai_level) const [aiLevel, setAiLevel] = useState<AiLevel>(data.ai_level)
const [recipients, setRecipients] = useState<SummaryRecipientsMode>(data.summary_recipients) const [recipients, setRecipients] = useState<SummaryRecipientsMode>(data.summary_recipients)
@@ -132,6 +133,7 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
// валился бы в 400, блокируя правку вообще любой другой настройки. // валился бы в 400, блокируя правку вообще любой другой настройки.
const payload: SettingsUpdateIn = {} const payload: SettingsUpdateIn = {}
if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled
if (handQueueEnabled !== data.hand_queue_enabled) payload.hand_queue_enabled = handQueueEnabled
if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled
if (aiLevel !== data.ai_level) payload.ai_level = aiLevel if (aiLevel !== data.ai_level) payload.ai_level = aiLevel
if (recipients !== data.summary_recipients) payload.summary_recipients = recipients if (recipients !== data.summary_recipients) payload.summary_recipients = recipients
@@ -175,6 +177,21 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
</label> </label>
</div> </div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Поднятие руки</strong>
<span>Кнопка «Рука» и очередь поднятых рук в комнате целиком</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={handQueueEnabled}
onChange={(e) => setHandQueueEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="toggle-row"> <div className="toggle-row">
<div className="toggle-copy"> <div className="toggle-copy">
<strong>Транскрибация и суммаризация (AI)</strong> <strong>Транскрибация и суммаризация (AI)</strong>

View File

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

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Hand, ListOrdered } from 'lucide-react' import { Hand, ListOrdered } from 'lucide-react'
import { useLocalParticipant } from '@livekit/components-react'
import { useIsOrganizer } from '@/hooks/useIsOrganizer' import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat' import type { HandQueueEntry } from '@/hooks/useChat'
@@ -9,8 +10,15 @@ interface HandQueueMenuProps {
} }
/** /**
* Кнопка «Очередь» в тулбаре с поповером над ней — видна только организатору * Кнопка «Очередь» в тулбаре с поповером над ней — видна ВСЕМ участникам
* (задача B1). Тот же самодостаточный паттерн, что и `StageViewMenu` («Вид»): * (сессия «28-hand-queue-for-all»: раньше очередь видел только организатор).
* Опустить чужую запись может по-прежнему только организатор — обычный
* участник видит кнопку «Опустить» только напротив СВОЕЙ записи (или не
* видит её вовсе, если сам руку не поднимал): сервер (`api/chat.py`) всё
* равно отклонит попытку опустить чужую руку кодом `forbidden`, но мёртвая
* кнопка, которая молча не работает, хуже отсутствующей.
*
* Тот же самодостаточный паттерн, что и `StageViewMenu` («Вид»):
* собственное состояние открытия, закрытие по клику вне/Escape, поповер * собственное состояние открытия, закрытие по клику вне/Escape, поповер
* `.tb-menu` над кнопкой — а не боковая панель на весь экран (как чат): * `.tb-menu` над кнопкой — а не боковая панель на весь экран (как чат):
* очередь рук — короткий список, а не история переписки, разворачивать её * очередь рук — короткий список, а не история переписки, разворачивать её
@@ -19,11 +27,12 @@ interface HandQueueMenuProps {
* Размер поповера подстраивается под число записей — `.hand-queue-list` * Размер поповера подстраивается под число записей — `.hand-queue-list`
* растёт вместе со списком и не даёт пустого места при 12 поднятых руках, * растёт вместе со списком и не даёт пустого места при 12 поднятых руках,
* но не бесконечно: после ~10 строк список упирается в `max-height` и дальше * но не бесконечно: после ~10 строк список упирается в `max-height` и дальше
* скроллится (см. room.css) — иначе организатор на энергичной встрече * скроллится (см. room.css) — иначе участник на энергичной встрече получил
* получил бы поповер выше экрана. * бы поповер выше экрана.
*/ */
export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) { export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
const isOrganizer = useIsOrganizer() const isOrganizer = useIsOrganizer()
const { localParticipant } = useLocalParticipant()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null) const wrapRef = useRef<HTMLDivElement>(null)
@@ -47,8 +56,6 @@ export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
} }
}, [open]) }, [open])
if (!isOrganizer) return null
return ( return (
<div className="tb-menu-wrap" ref={wrapRef}> <div className="tb-menu-wrap" ref={wrapRef}>
<button <button
@@ -74,22 +81,28 @@ export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
<p className="chat-empty">Пока никто не поднял руку</p> <p className="chat-empty">Пока никто не поднял руку</p>
) : ( ) : (
<ol className="hand-queue-list"> <ol className="hand-queue-list">
{queue.map((entry, index) => ( {queue.map((entry, index) => {
<li className="hand-queue-item" key={entry.identity}> const isOwn = entry.identity === localParticipant.identity
<span className="hand-queue-position">{index + 1}</span> const canLower = isOrganizer || isOwn
<span className="hand-queue-name"> return (
<Hand className="lucide" aria-hidden="true" /> <li className="hand-queue-item" key={entry.identity}>
{entry.name} <span className="hand-queue-position">{index + 1}</span>
</span> <span className="hand-queue-name">
<button <Hand className="lucide" aria-hidden="true" />
type="button" {entry.name}
className="hand-queue-lower" </span>
onClick={() => onLower(entry.identity)} {canLower && (
> <button
Опустить type="button"
</button> className="hand-queue-lower"
</li> onClick={() => onLower(entry.identity)}
))} >
Опустить
</button>
)}
</li>
)
})}
</ol> </ol>
)} )}
</div> </div>

View File

@@ -58,6 +58,8 @@ interface RoomToolbarProps extends StageViewProps {
pipSupported: boolean pipSupported: boolean
pipActive: boolean pipActive: boolean
onTogglePiP: () => void onTogglePiP: () => void
/** `JoinOut.hand_queue_enabled` — при `false` кнопка «Рука» и очередь не рендерятся вовсе. */
handQueueEnabled: boolean
/** /**
* Очередь поднятых рук целиком (задача B1, `useChat().handQueue`) — сама * Очередь поднятых рук целиком (задача B1, `useChat().handQueue`) — сама
* решает, поднята ли СВОЯ рука (сравнивая с `localParticipant.identity` * решает, поднята ли СВОЯ рука (сравнивая с `localParticipant.identity`
@@ -68,6 +70,16 @@ interface RoomToolbarProps extends StageViewProps {
onLowerHand: () => void onLowerHand: () => void
/** Опустить ЧУЖУЮ руку по identity — только организатору (панель очереди, `HandQueueMenu`). */ /** Опустить ЧУЖУЮ руку по identity — только организатору (панель очереди, `HandQueueMenu`). */
onLowerHandById: (identity: string) => void onLowerHandById: (identity: string) => void
/** Тулбар в оверлее полноэкранного режима — см. докстринг `RoomTopbar.overlayVisible`, тот же механизм. */
overlayVisible?: boolean
/**
* Пользователь нажал «Выйти» — вызывается ПЕРЕД тем, как `DisconnectButton`
* отключит комнату (обработчики в `mergeProps` вызываются цепочкой). Нужен
* `RoomPage`, чтобы отличить намеренный выход от разрыва: причина
* `CLIENT_INITIATED` приходит и от кнопки, и от livekit-client при заморозке
* вкладки — см. докстринг `handleDisconnected`.
*/
onLeave?: () => void
} }
/** /**
@@ -77,16 +89,22 @@ interface RoomToolbarProps extends StageViewProps {
* панели чата, стилизованные по design/mockups/room.html. * панели чата, стилизованные по design/mockups/room.html.
* *
* Кнопка «Вид» (режимы показа и скрытие остальных) рендерится ТОЛЬКО на * Кнопка «Вид» (режимы показа и скрытие остальных) рендерится ТОЛЬКО на
* широком экране — условным рендерингом, а не скрытием через CSS: тулбар на * широком экране — условным рендерингом, а не скрытием через CSS: демонстрация
* мобильном и так ужат до пяти «безусловных» кнопок (демонстрация/ * и мини-плеер скрыты на узком экране через CSS (см. `styles/room.css`), а те
* полноэкранный режим/мини-плеер скрыты на узком экране через CSS, см. * же настройки показа сцены доступны секцией «Вид» в шторке настроек
* `styles/room.css`), а те же настройки там доступны секцией «Вид» в шторке * (`DeviceSettingsDialog`). «Рука» — сознательное исключение из этой
* настроек (`DeviceSettingsDialog`). «Рука» — сознательное исключение из этой
* экономии: поднять руку посреди разговора — действие со временем жизни в * экономии: поднять руку посреди разговора — действие со временем жизни в
* секунды, прятать его в шторку настроек означало бы делать его практически * секунды, прятать его в шторку настроек означало бы делать его практически
* недоступным с телефона. «Очередь» показывается только организатору — * недоступным с телефона. «Очередь» видна ЛЮБОМУ участнику (сессия
* встречается редко, но по той же причине оставлена в тулбаре, а не в * «28-hand-queue-for-all»: раньше только организатору), по той же причине
* шторке: организатору с телефона тоже нужно видеть очередь сразу. * оставлена в тулбаре, а не в шторке: любому участнику с телефона тоже нужно
* видеть очередь сразу. Обе кнопки целиком гасятся `handQueueEnabled`
* (`JoinOut.hand_queue_enabled`, отключаемый модуль в админке).
* Полноэкранный режим на мобильном ОСТАЁТСЯ в тулбаре (не спрятан в шторку,
* как демонстрация/мини-плеер) — по решению оператора вход в него должен
* быть по аналогии с десктопом; если из-за этого кнопки не помещаются в один
* ряд, `mobileButtonCount`/`.tb-wrap-grid` ниже раскладывают их равномерной
* сеткой, а не как получится через `flex-wrap`.
*/ */
export function RoomToolbar({ export function RoomToolbar({
chatVisible, chatVisible,
@@ -100,6 +118,7 @@ export function RoomToolbar({
pipSupported, pipSupported,
pipActive, pipActive,
onTogglePiP, onTogglePiP,
handQueueEnabled,
handQueue, handQueue,
onRaiseHand, onRaiseHand,
onLowerHand, onLowerHand,
@@ -108,11 +127,31 @@ export function RoomToolbar({
onLayoutModeChange, onLayoutModeChange,
hideOthers, hideOthers,
onHideOthersChange, onHideOthersChange,
overlayVisible,
onLeave,
}: RoomToolbarProps) { }: RoomToolbarProps) {
const toast = useToast() const toast = useToast()
const isCompact = useIsCompactViewport() const isCompact = useIsCompactViewport()
const { localParticipant } = useLocalParticipant() const { localParticipant } = useLocalParticipant()
const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity) const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity)
// Сколько кнопок реально видно на мобильном (демонстрация/мини-окно там
// скрыты через CSS всегда, «Вид» не рендерится вовсе — см. докстринг) —
// считаем в JS, а не через CSS-селекторы вида `:nth-child`: у «Очереди»
// в DOM есть своя обёртка `.tb-menu-wrap`, из-за которой позиция остальных
// кнопок «плывёт», и подсчёт по структуре DOM был бы хрупким. От порога
// зависит `.tb-wrap-grid` в room.css — ниже он переключает перенос на
// 2 строки с «как поместится» (flex-wrap) на равномерную сетку 4 колонки
// (7 → 4+3, 8 → 4+4). «Рука»/«Очередь» — обе теперь видны любому участнику
// (сессия «28-hand-queue-for-all»), их наличие зависит только от
// `handQueueEnabled`, не от роли.
const mobileButtonCount =
2 /* микрофон, камера */ +
(handQueueEnabled ? 2 : 0) /* рука, очередь */ +
1 /* настройки */ +
(fullscreenSupported ? 1 : 0) +
(chatVisible ? 1 : 0) +
1 /* выйти */
const mic = useTrackToggle({ source: Track.Source.Microphone }) const mic = useTrackToggle({ source: Track.Source.Microphone })
const camera = useTrackToggle({ source: Track.Source.Camera }) const camera = useTrackToggle({ source: Track.Source.Camera })
const screenShare = useTrackToggle({ const screenShare = useTrackToggle({
@@ -129,7 +168,9 @@ export function RoomToolbar({
}) })
return ( return (
<footer className="room-toolbar"> <footer
className={`room-toolbar${overlayVisible ? ' is-visible' : ''}${mobileButtonCount >= 7 ? ' tb-wrap-grid' : ''}`}
>
<button <button
type="button" type="button"
{...mic.buttonProps} {...mic.buttonProps}
@@ -178,23 +219,25 @@ export function RoomToolbar({
<span className="label">Демонстрация</span> <span className="label">Демонстрация</span>
</button> </button>
<button {handQueueEnabled && (
type="button" <button
className={`tb-btn${handRaised ? ' is-hand-raised' : ''}`} type="button"
aria-pressed={handRaised} className={`tb-btn${handRaised ? ' is-hand-raised' : ''}`}
aria-label={handRaised ? 'Опустить руку' : 'Поднять руку'} aria-pressed={handRaised}
onClick={() => (handRaised ? onLowerHand() : onRaiseHand())} aria-label={handRaised ? 'Опустить руку' : 'Поднять руку'}
> onClick={() => (handRaised ? onLowerHand() : onRaiseHand())}
<span className="icon-shell"> >
<Hand className="lucide" aria-hidden="true" /> <span className="icon-shell">
{handQueue.length > 0 && ( <Hand className="lucide" aria-hidden="true" />
<span className="badge-count">{handQueue.length > 9 ? '9+' : handQueue.length}</span> {handQueue.length > 0 && (
)} <span className="badge-count">{handQueue.length > 9 ? '9+' : handQueue.length}</span>
</span> )}
<span className="label">Рука</span> </span>
</button> <span className="label">Рука</span>
</button>
)}
<HandQueueMenu queue={handQueue} onLower={onLowerHandById} /> {handQueueEnabled && <HandQueueMenu queue={handQueue} onLower={onLowerHandById} />}
{!isCompact && ( {!isCompact && (
<StageViewMenu <StageViewMenu
@@ -214,7 +257,10 @@ export function RoomToolbar({
<span className="icon-shell"> <span className="icon-shell">
<Settings className="lucide" aria-hidden="true" /> <Settings className="lucide" aria-hidden="true" />
</span> </span>
<span className="label">Устройства</span> {/* На мобильном подпись шире по смыслу («Настройки»): там же в шторке
секция «Вид», а отдельной кнопки под неё в тулбаре нет. На десктопе
панель — только настройки устройств, подпись это отражает. */}
<span className="label">{isCompact ? 'Настройки' : 'Устройства'}</span>
</button> </button>
{fullscreenSupported && ( {fullscreenSupported && (
@@ -276,7 +322,7 @@ export function RoomToolbar({
</button> </button>
)} )}
<DisconnectButton className="tb-btn danger" aria-label="Выйти из конференции"> <DisconnectButton className="tb-btn danger" aria-label="Выйти из конференции" onClick={onLeave}>
<span className="icon-shell"> <span className="icon-shell">
<LogOut className="lucide" aria-hidden="true" /> <LogOut className="lucide" aria-hidden="true" />
</span> </span>

View File

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

View File

@@ -146,6 +146,7 @@ export function JoinPage() {
title: resolved.title, title: resolved.title,
conferenceId: data.conference_id, conferenceId: data.conference_id,
chatEnabled: data.chat_enabled, chatEnabled: data.chat_enabled,
handQueueEnabled: data.hand_queue_enabled,
publishQualityCap: data.publish_quality_cap, publishQualityCap: data.publish_quality_cap,
stageMaxTiles: data.stage_max_tiles, stageMaxTiles: data.stage_max_tiles,
}, },

View File

@@ -43,6 +43,7 @@ export function LobbyPage() {
title: conference.title, title: conference.title,
conferenceId: conference.join.conference_id, conferenceId: conference.join.conference_id,
chatEnabled: conference.join.chat_enabled, chatEnabled: conference.join.chat_enabled,
handQueueEnabled: conference.join.hand_queue_enabled,
publishQualityCap: conference.join.publish_quality_cap, publishQualityCap: conference.join.publish_quality_cap,
stageMaxTiles: conference.join.stage_max_tiles, stageMaxTiles: conference.join.stage_max_tiles,
number: conference.number, number: conference.number,

View File

@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'
import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { PictureInPicture2 } from 'lucide-react' import { PictureInPicture2 } from 'lucide-react'
import { LiveKitRoom, RoomAudioRenderer, usePersistentUserChoices } from '@livekit/components-react' import { LiveKitRoom, RoomAudioRenderer, usePersistentUserChoices } from '@livekit/components-react'
import type { RoomOptions } from 'livekit-client' import { DisconnectReason, type RoomOptions } from 'livekit-client'
import '@livekit/components-styles' import '@livekit/components-styles'
import '@/styles/room.css' import '@/styles/room.css'
import { joinConference, resolveConference, type PublishQualityCap } from '@/api/conferences' import { joinConference, resolveConference, type PublishQualityCap } from '@/api/conferences'
@@ -22,6 +22,29 @@ import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
import { buildPublishDefaults } from '@/lib/publishQualityCap' import { buildPublishDefaults } from '@/lib/publishQualityCap'
import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode' import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode'
/** Сколько мс держать топбар/тулбар видимыми в полноэкранном режиме без взаимодействия, прежде чем спрятать их снова. */
const FULLSCREEN_CONTROLS_AUTO_HIDE_MS = 4000
/** Полоса у нижнего края экрана (px) — наведение мыши в неё в полноэкранном режиме на десктопе показывает тулбар без клика. */
const FULLSCREEN_FOOTER_HOVER_ZONE_PX = 72
/**
* Сколько раз подряд страница возвращается в конференцию сама после разрыва,
* которого пользователь не просил. Ограничение — защита от бесконечного цикла
* «вошёл → сразу выбросило»: например, если конференция кончилась ровно в
* момент разрыва или сервер по какой-то причине не принимает участника.
* Исчерпав бюджет, страница ведёт себя как раньше — уводит в лобби.
*/
const AUTO_REJOIN_LIMIT = 5
/**
* Соединение, прожившее дольше этого времени, считается состоявшимся: бюджет
* авто-перезаходов обнуляется. Без этого редкие разрывы за длинную встречу
* (телефон засыпал пять раз за час) постепенно съели бы лимит и шестое
* пробуждение выбросило бы участника в лобби.
*/
const AUTO_REJOIN_BUDGET_RESET_MS = 30_000
interface RoomJoinState { interface RoomJoinState {
livekitUrl: string livekitUrl: string
token: string token: string
@@ -32,6 +55,8 @@ interface RoomJoinState {
number?: string number?: string
/** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */ /** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */
chatEnabled?: boolean chatEnabled?: boolean
/** `JoinOut.hand_queue_enabled` — при `false` кнопка «Рука» и очередь не рендерятся. */
handQueueEnabled?: boolean
/** /**
* Рычаги нагрузки медиа (`JoinOut.publish_quality_cap`/`stage_max_tiles`, * Рычаги нагрузки медиа (`JoinOut.publish_quality_cap`/`stage_max_tiles`,
* `instance_settings.media_limits`) — приезжают вместе с токеном, ДО * `instance_settings.media_limits`) — приезжают вместе с токеном, ДО
@@ -72,13 +97,88 @@ export function RoomPage() {
const [joinState, setJoinState] = useState<RoomJoinState | null>(initialState) const [joinState, setJoinState] = useState<RoomJoinState | null>(initialState)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Пользователь нажал «Выйти» — ЕДИНСТВЕННЫЙ признак, по которому уход со
// страницы считается намеренным. Отдельный флаг нужен потому, что причина
// `CLIENT_INITIATED` приходит не только от кнопки: livekit-client вешает
// обработчик на событие `freeze` (Page Lifecycle) и при заморозке вкладки
// сам зовёт `room.disconnect()` — с той же самой причиной. Android Chrome
// фоновую вкладку замораживает, так что без этого флага заснувший телефон
// выглядел бы ровно как «пользователь вышел» и уезжал бы в лобби.
const leaveRequestedRef = useRef(false)
// Когда установилось текущее соединение — чтобы отличить «разрыв после
// нормальной работы» от «выбрасывает сразу после входа» (см. AUTO_REJOIN_*).
const connectedAtRef = useRef(0)
const autoRejoinCountRef = useRef(0)
const handleLeaveClick = useCallback(() => {
leaveRequestedRef.current = true
}, [])
const handleConnected = useCallback(() => {
connectedAtRef.current = Date.now()
// Мы снова в комнате — намерение выйти, если оно откуда-то осталось
// невыполненным, к этому соединению уже не относится.
leaveRequestedRef.current = false
}, [])
// useCallback: стабильная ссылка на обработчик — иначе на каждом рендере // useCallback: стабильная ссылка на обработчик — иначе на каждом рендере
// RoomPage пересобирался бы объект-пропс `onDisconnected`, а с ним и // RoomPage пересобирался бы объект-пропс `onDisconnected`, а с ним и
// внутренний useEffect @livekit/components-react (он в списке зависимостей // внутренний useEffect @livekit/components-react (он в списке зависимостей
// хука useLiveKitRoom), лишний раз пересоздавая подписки на события Room. // хука useLiveKitRoom), лишний раз пересоздавая подписки на события Room.
const handleDisconnected = useCallback(() => { //
navigate(status === 'authenticated' ? '/lobby' : '/join') // Разрыв ≠ выход. До 0.0.27 любое отключение уводило со страницы, и на
}, [navigate, status]) // мобильном это ломало обычный сценарий: экран телефона гаснет, Chrome
// срезает фоновой вкладке ресурсы, ICE перестаёт отвечать — и LiveKit
// закрывает участника через 5 с после потери соединения
// (`PEER_CONNECTION_DISCONNECTED`, замерено на проде: 37 с после блокировки
// экрана, 23 с после сворачивания браузера). Восстановить сессию после
// этого нельзя — участника на сервере уже нет (`could not restart
// participant`), нужен полный повторный вход. livekit-client такой вход
// пытается сделать сам, но его бюджет повторов (10 попыток, ~44 с) в
// фоновой вкладке успевает сгореть — и тогда приходит `Disconnected`,
// после которого пользователь оказывался в лобби вместо конференции.
//
// Теперь непреднамеренный разрыв просто сбрасывает `joinState`, а дальше
// работает уже написанный путь авто-перезахода (эффект ниже): страница
// молча резолвит конференцию, берёт свежий токен и входит заново. Никакого
// второго механизма восстановления не появляется.
const handleDisconnected = useCallback(
(reason?: DisconnectReason) => {
const leaveForGood = () => navigate(status === 'authenticated' ? '/lobby' : '/join')
if (leaveRequestedRef.current) {
leaveForGood()
return
}
// Разрывы, после которых возвращаться некуда или нельзя: участника
// выгнал организатор, конференцию закрыли, либо этой же личностью вошли
// с другого устройства (перезаход устроил бы пинг-понг между ними).
if (
reason === DisconnectReason.PARTICIPANT_REMOVED ||
reason === DisconnectReason.ROOM_DELETED ||
reason === DisconnectReason.ROOM_CLOSED ||
reason === DisconnectReason.DUPLICATE_IDENTITY
) {
leaveForGood()
return
}
const sessionMs = connectedAtRef.current ? Date.now() - connectedAtRef.current : 0
if (sessionMs > AUTO_REJOIN_BUDGET_RESET_MS) {
autoRejoinCountRef.current = 0
}
if (autoRejoinCountRef.current >= AUTO_REJOIN_LIMIT) {
leaveForGood()
return
}
autoRejoinCountRef.current += 1
connectedAtRef.current = 0
setJoinState(null)
},
[navigate, status],
)
useEffect(() => { useEffect(() => {
if (joinState || !slug) return if (joinState || !slug) return
@@ -110,6 +210,7 @@ export function RoomPage() {
title: info.title, title: info.title,
conferenceId: result.conference_id, conferenceId: result.conference_id,
chatEnabled: result.chat_enabled, chatEnabled: result.chat_enabled,
handQueueEnabled: result.hand_queue_enabled,
publishQualityCap: result.publish_quality_cap, publishQualityCap: result.publish_quality_cap,
stageMaxTiles: result.stage_max_tiles, stageMaxTiles: result.stage_max_tiles,
}) })
@@ -162,10 +263,16 @@ export function RoomPage() {
const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable
// Identity участников с поднятой рукой — множеством, для дешёвого `.has()` // Identity участников с поднятой рукой — множеством, для дешёвого `.has()`
// на каждой плитке сцены (см. `RoomParticipantTile`). // на каждой плитке сцены (см. `RoomParticipantTile`). Выключенный модуль
// (`handQueueEnabled=false`) гасит и эти значки — иначе на плитке мог бы
// повиснуть индикатор от руки, поднятой до отключения модуля (сервер
// хранит очередь в Redis независимо от тоггла, см. `services/hand_queue.py`).
const raisedHandIdentities = useMemo( const raisedHandIdentities = useMemo(
() => new Set(chat.handQueue.map((entry) => entry.identity)), () =>
[chat.handQueue], joinState?.handQueueEnabled
? new Set(chat.handQueue.map((entry) => entry.identity))
: new Set<string>(),
[chat.handQueue, joinState?.handQueueEnabled],
) )
// Корневой контейнер комнаты — цель для fullscreen и источник video-элемента // Корневой контейнер комнаты — цель для fullscreen и источник video-элемента
@@ -175,6 +282,73 @@ export function RoomPage() {
const pip = useRoomPiP(roomRootRef) const pip = useRoomPiP(roomRootRef)
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
// Топбар и тулбар в полноэкранном режиме — оверлей поверх сцены (см.
// `.room-fullscreen-overlay` в room.css), а не часть потока: показываются
// по тапу/клику по сцене вне элементов управления и прячутся сами через
// FULLSCREEN_CONTROLS_AUTO_HIDE_MS бездействия. На десктопе есть ещё второй
// способ показать их — навести мышь в нижнюю полосу экрана (без клика),
// как в большинстве видеоплееров; клик остаётся основным способом на
// тач-устройствах, где наведения не бывает. Обычный (не полноэкранный)
// режим этот стейт не использует вовсе.
const [fullscreenControlsVisible, setFullscreenControlsVisible] = useState(true)
// Счётчик «попроси показать и отсчитать заново» — растёт на каждый клик/
// наведение, даже если панель УЖЕ видима (иначе непрерывное наведение не
// продлевало бы таймер: setState(true) поверх уже true не меняет состояние
// и не перезапускает эффект ниже).
const [fullscreenControlsTick, setFullscreenControlsTick] = useState(0)
// Показ при входе в полноэкранный режим — «подгонка состояния во время
// рендера» (см. тот же приём у `chatSeenCount` выше), а не setState в теле
// эффекта (react-hooks/set-state-in-effect): само планирование таймера
// авто-скрытия — ниже, отдельным эффектом, и не вызывает setState
// синхронно в своём теле.
const [prevFullscreenActive, setPrevFullscreenActive] = useState(fullscreen.active)
if (fullscreen.active !== prevFullscreenActive) {
setPrevFullscreenActive(fullscreen.active)
if (fullscreen.active) {
setFullscreenControlsVisible(true)
setFullscreenControlsTick((tick) => tick + 1)
}
}
useEffect(() => {
if (!fullscreen.active || !fullscreenControlsVisible) return
const timer = setTimeout(() => setFullscreenControlsVisible(false), FULLSCREEN_CONTROLS_AUTO_HIDE_MS)
return () => clearTimeout(timer)
}, [fullscreen.active, fullscreenControlsVisible, fullscreenControlsTick])
// Наведение мыши в нижнюю полосу экрана — только десктопный способ показать
// элементы управления без клика; на тач-устройствах `mousemove` в таком виде
// не приходит, слушатель им не вредит и не мешает.
useEffect(() => {
if (!fullscreen.active) return
function handleMouseMove(e: MouseEvent) {
if (window.innerHeight - e.clientY <= FULLSCREEN_FOOTER_HOVER_ZONE_PX) {
setFullscreenControlsVisible(true)
setFullscreenControlsTick((tick) => tick + 1)
}
}
window.addEventListener('mousemove', handleMouseMove)
return () => window.removeEventListener('mousemove', handleMouseMove)
}, [fullscreen.active])
// Клик/тап по сцене переключает видимость — но не по элементам управления
// внутри неё (кнопки плиток, булавка закрепления, «Показать остальных» и
// т.п.: `closest` поднимается от места клика и гасит переключение, если
// по дороге встретился интерактивный элемент) и не по открытой панели чата
// (`.chat-panel` — та рендерится внутри того же `.room-main`, но клики по
// тексту сообщений не должны прятать/показывать тулбар).
const handleStageAreaClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!fullscreen.active) return
const target = e.target as HTMLElement
if (target.closest('button, a, input, select, textarea, [role="dialog"], .chat-panel')) return
setFullscreenControlsVisible((visible) => !visible)
setFullscreenControlsTick((tick) => tick + 1)
},
[fullscreen.active],
)
// Вид сцены живёт здесь, а не в `RoomStage`: переключатели — в тулбаре и в // Вид сцены живёт здесь, а не в `RoomStage`: переключатели — в тулбаре и в
// шторке настроек, а сцена их только читает (общий предок). // шторке настроек, а сцена их только читает (общий предок).
// //
@@ -281,7 +455,7 @@ export function RoomPage() {
} }
return ( return (
<div data-theme="room" ref={roomRootRef}> <div data-theme="room" ref={roomRootRef} className={fullscreen.active ? 'room-fullscreen-overlay' : undefined}>
<LiveKitRoom <LiveKitRoom
serverUrl={joinState.livekitUrl} serverUrl={joinState.livekitUrl}
token={joinState.token} token={joinState.token}
@@ -289,6 +463,7 @@ export function RoomPage() {
audio={false} audio={false}
video={false} video={false}
options={roomOptions} options={roomOptions}
onConnected={handleConnected}
onDisconnected={handleDisconnected} onDisconnected={handleDisconnected}
> >
{/* Звук комнаты рендерится ЗДЕСЬ, а не внутри `RoomStage`, и ровно {/* Звук комнаты рендерится ЗДЕСЬ, а не внутри `RoomStage`, и ровно
@@ -307,8 +482,13 @@ export function RoomPage() {
непрерывно, весь цикл «открыл мини-окно → вернул» их не касается. */} непрерывно, весь цикл «открыл мини-окно → вернул» их не касается. */}
<RoomAudioRenderer /> <RoomAudioRenderer />
<div data-lk-theme="default" className="room-shell"> <div data-lk-theme="default" className="room-shell">
<RoomTopbar title={joinState.title ?? null} slug={slug} number={joinState.number} /> <RoomTopbar
<div className="room-main"> title={joinState.title ?? null}
slug={slug}
number={joinState.number}
overlayVisible={fullscreenControlsVisible}
/>
<div className="room-main" onClick={handleStageAreaClick}>
{pip.mode === 'document' ? ( {pip.mode === 'document' ? (
// Сцена сейчас рисуется в PiP-окне (через createPortal ниже) — // Сцена сейчас рисуется в PiP-окне (через createPortal ниже) —
// основное окно вместо неё показывает заглушку с возвратом. // основное окно вместо неё показывает заглушку с возвратом.
@@ -357,6 +537,7 @@ export function RoomPage() {
pipSupported={pip.supported} pipSupported={pip.supported}
pipActive={pip.active} pipActive={pip.active}
onTogglePiP={pip.toggle} onTogglePiP={pip.toggle}
handQueueEnabled={Boolean(joinState.handQueueEnabled)}
handQueue={chat.handQueue} handQueue={chat.handQueue}
onRaiseHand={chat.raiseHand} onRaiseHand={chat.raiseHand}
onLowerHand={() => chat.lowerHand()} onLowerHand={() => chat.lowerHand()}
@@ -365,6 +546,8 @@ export function RoomPage() {
onLayoutModeChange={handleLayoutModeChange} onLayoutModeChange={handleLayoutModeChange}
hideOthers={hideOthers} hideOthers={hideOthers}
onHideOthersChange={setHideOthers} onHideOthersChange={setHideOthers}
overlayVisible={fullscreenControlsVisible}
onLeave={handleLeaveClick}
/> />
</div> </div>
{settingsOpen && ( {settingsOpen && (

View File

@@ -803,10 +803,16 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
* Тот же диалог (`DeviceSettingsDialog`), что модалка выше, — только контейнер * Тот же диалог (`DeviceSettingsDialog`), что модалка выше, — только контейнер
* снизу вместо центра экрана: `.room-sheet-overlay`/`.room-sheet-panel` вместо * снизу вместо центра экрана: `.room-sheet-overlay`/`.room-sheet-panel` вместо
* `.room-modal-overlay`/`.room-modal-panel`, разметка полей (`.room-field` и * `.room-modal-overlay`/`.room-modal-panel`, разметка полей (`.room-field` и
* ниже) общая. Ручка `.room-sheet-handle` — свайп вниз для закрытия (JS считает * ниже) общая. Свайп вниз для закрытия JS на самой панели (не на ручке —
* смещение пальца и сам решает, закрывать или вернуть панель на место; * см. докстринг `DeviceSettingsDialog`) считает смещение пальца и сам решает,
* `transition` тут — только пружина возврата, во время самого драга * закрывать или вернуть панель на место; `transition` тут — только пружина
* компонент подставляет инлайновый `transition: none`). * возврата, во время самого драга компонент подставляет инлайновый
* `transition: none`. `overscroll-behavior-y: contain` — чтобы при свайпе
* вниз от самого верха списка устройств iOS/Chrome не показывали заодно
* ещё и нативный эффект растяжения скролла поверх нашей анимации панели.
* Ручка `.room-sheet-handle` — теперь чисто визуальная подсказка (сама
* панель ловит жест где угодно), `touch-action: none` на ней оставлен —
* безвредно и снимает системные жесты с этой узкой полоски.
*/ */
.room-sheet-overlay { .room-sheet-overlay {
position: fixed; position: fixed;
@@ -827,6 +833,7 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
width: 100%; width: 100%;
max-height: 80vh; max-height: 80vh;
overflow-y: auto; overflow-y: auto;
overscroll-behavior-y: contain;
transition: transform 160ms ease-out; transition: transform 160ms ease-out;
} }
.room-sheet-handle { .room-sheet-handle {
@@ -1081,22 +1088,79 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
в самой конференции, а вот чип «№ … / Пригласить» с телефона нужен чаще в самой конференции, а вот чип «№ … / Пригласить» с телефона нужен чаще
(скопировать ссылку и позвать участника), поэтому он остаётся. (скопировать ссылку и позвать участника), поэтому он остаётся.
Тулбар: демонстрация экрана, полноэкранный режим и мини-окно с телефона Тулбар: демонстрация экрана и мини-окно с телефона практически не нужны —
практически не нужны, а все 8 кнопок в ширину экрана физически не скрыты. Полноэкранный режим ОСТАЁТСЯ виден, как на десктопе (решение
помещаются — скрываем их и уплотняем оставшиеся пять оператора 03.08: вход в него — по аналогии с десктопным приложением, не
(микрофон/камера/устройства/чат/выход). `flex-wrap` + `min-width: 0` — спрятан в шторку настроек) — вместе с «Рукой» (0.0.16) и опциональными
страховка на совсем узких экранах, чтобы футер ни при каких подписях не «Очередью» (только организатору)/«Чатом» на мобильном может набраться до
вылезал за ширину окна. */ 78 кнопок разом, в один ряд по 360px они уже не помещаются.
`.room-toolbar.tb-wrap-grid` — класс считает JS (`RoomToolbar.tsx`, по
фактическому числу видимых на мобильном кнопок, а не селекторами
`:nth-child` — организатору достаётся ДОПОЛНИТЕЛЬНАЯ обёртка
`.tb-menu-wrap` вокруг кнопки «Очередь», из-за неё позиция по DOM «плывёт»)
— переключает раскладку на равномерную сетку 4 колонки: 7 кнопок ложатся
4+3, 8 — 4+4, а не как получится через `flex-wrap` (например 6+1, если
просто позволить браузеру перенести лишние). До 6 кнопок раскладка —
обычный flex, они влезают в 360px одним рядом без переноса вовсе. */
@media (max-width: 600px) { @media (max-width: 600px) {
.room-topbar { padding: var(--space-3) var(--space-4); justify-content: flex-end; } .room-topbar { padding: var(--space-3) var(--space-4); justify-content: flex-end; }
.room-title-block { display: none; } .room-title-block { display: none; }
.tb-btn--screenshare, .tb-btn--screenshare,
.tb-btn--fullscreen,
.tb-btn--pip { display: none; } .tb-btn--pip { display: none; }
.room-toolbar { padding: var(--space-3) var(--space-2); gap: 2px; flex-wrap: wrap; } .room-toolbar { padding: var(--space-3) var(--space-2); gap: 2px; flex-wrap: wrap; }
.room-toolbar.tb-wrap-grid { display: grid; grid-template-columns: repeat(4, 1fr); justify-items: center; }
.tb-btn { min-width: 0; padding: 6px 6px; } .tb-btn { min-width: 0; padding: 6px 6px; }
.tb-btn .icon-shell { width: 40px; height: 40px; } .tb-btn .icon-shell { width: 40px; height: 40px; }
.tb-btn span.label { font-size: 11px; } .tb-btn span.label { font-size: 11px; }
} }
/*
* ---------- Полноэкранный режим: топбар и тулбар прячутся в оверлей ----------
* Работает на ЛЮБОЙ ширине экрана (не только мобильной) — как только
* `document.fullscreenElement`, корневой контейнер комнаты получает класс
* `.room-fullscreen-overlay` (`RoomPage.tsx`, вместе с `useFullscreen`).
* Топбар и тулбар выходят из flow-потока `.room-shell` — единственный
* оставшийся в потоке `.room-main` (`flex: 1`) сам растягивается на всю
* высоту, отдельного правила не нужно — и ложатся оверлеем поверх сцены
* сверху/снизу. Появляются по клику/тапу вне элементов управления
* (`RoomPage.tsx`, `handleStageAreaClick`) или — на десктопе — при наведении
* мыши в нижнюю полосу экрана (`FULLSCREEN_FOOTER_HOVER_ZONE_PX`), прячутся
* по повторному клику или сами через `FULLSCREEN_CONTROLS_AUTO_HIDE_MS`
* бездействия.
*
* z-index 150 — выше сцены, но НИЖЕ модалки/шторки настроек (200): если из
* уже открытого оверлея снова открыть «Настройки», диалог должен лечь
* поверх тулбара, а не под него.
*
* Кнопка «Экран» (выход) на мобильном видна и без полноэкранного режима
* (см. блок выше — по аналогии с десктопом), поэтому здесь её отдельно
* возвращать не нужно.
*/
.room-fullscreen-overlay .room-topbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 150;
background: rgba(20, 22, 26, 0.92);
backdrop-filter: blur(8px);
transform: translateY(-100%);
transition: transform 200ms ease-out;
}
.room-fullscreen-overlay .room-topbar.is-visible { transform: translateY(0); }
.room-fullscreen-overlay .room-toolbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 150;
background: rgba(20, 22, 26, 0.92);
backdrop-filter: blur(8px);
padding-bottom: calc(var(--space-3) + env(safe-area-inset-bottom));
transform: translateY(100%);
transition: transform 200ms ease-out;
}
.room-fullscreen-overlay .room-toolbar.is-visible { transform: translateY(0); }