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-обработки, без которых кнопки принудительного мьюта не
скомпилировались бы; сама реализация мьюта — следующим коммитом.
This commit is contained in:
2026-08-01 22:06:43 +03:00
parent 42bfb88a22
commit 8e5eda88a2
14 changed files with 1147 additions and 62 deletions

View File

@@ -323,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; }
@@ -574,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 — собран из
@@ -762,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;