Транспорт — существующий аутентифицированный 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-обработки, без которых кнопки принудительного мьюта не
скомпилировались бы; сама реализация мьюта — следующим коммитом.
Булавка закрепления раньше рендерилась только в «Стандарте» — в «Плитках»
и «Живых плитках» её не было вовсе, хотя крупной плитки для показа
закреплённого там и так нет. Теперь кнопка есть на любой плитке в любом
режиме, а сам клик закрепления (не открепления) переключает вид на
«Стандарт» — иначе закреплённого негде показать крупно.
Открепление обратно на предыдущий вид не переключает — это разовое
действие, а не устойчивый выбор пользователя, дёргать раскладку туда-обратно
не нужно. Переключение при закреплении не сохраняется в localStorage
(в отличие от явного выбора режима в тулбаре/настройках) по той же причине.
Заодно закрывает задачу «закрепление на мобильном»: раньше кнопки не было
в плиточных режимах даже там, а на тач-устройствах она и так всегда видна
(`@media (hover: none)`), отдельной мобильной доработки не потребовалось.
RoomTopbar рендерил `JoinOut.room_name` — техническое имя комнаты LiveKit
вида `hC-Dmos9KEM`, которое туда попало по ошибке (перепутано с
человекочитаемым title конференции). Теперь топбар получает `title`
(из резолва номера/ссылки или из карточки конференции в лобби) и падает
на «Конференция без названия», если организатор его не задал. Номер и
slug остаются на своих местах — в инвайт-чипе и в адресе, где они нужны
функционально.
Раньше LiveKitRoom публиковал оба трека сразу при подключении (audio/video
без значения — булев true). Теперь участник заходит с выключенными
микрофоном и камерой и включает их сам уже в комнате; кнопки тулбара
(useTrackToggle) отражают фактическое состояние и работают штатно.
Оба флага в LiveKit по умолчанию выключены: каждый клиент был подписан на
полное качество всех чужих треков независимо от размера плитки, а каждый
паблишер слал все слои симулкаста, даже если их никто не смотрит. На тесте
28.07 (19 участников, ~8 камер) это дало устойчивые 140–169 Мбит/с исходящего
трафика при пике 240, 662 события `remote bwe: channel congestion detected` и
146 переходов аллокатора STABLE → DEFICIENT — то есть видимый участникам лаг.
Замер на локальном стенде (9 участников, паблишеры 720p, одинаковый состав
комнаты, приращение bytesReceived по getStats клиента):
без флагов 5 потоков 1280x720 @33 fps — 11 110 кбит/с
с флагами 4 потока 320x150 @17 fps — 778 кбит/с
Заодно начинает экономить уже написанный код, который до сих пор не давал
выигрыша: «скрыть остальных» не рендерит карусель (TX контейнера LiveKit
0.76 → 0.03 Мбит/с), пагинация StageGrid рендерит только текущую страницу,
а pauseVideoInBackground (дефолт true) работает лишь при adaptiveStream.
Демонстрация экрана, режимы показа и листание страниц проверены — регрессий нет.
В Chrome мини-плеер показывал самого пользователя вместо того, что он видел
крупно. Сцена в мини-окне — отдельный экземпляр RoomStage (портал в PiP-окно),
и состояние фокуса он начинал с нуля: демонстрации нет, никто прямо сейчас не
говорит — pickStageFocus доходил до последнего фолбэка localKey, то есть до
«показать себя». В Safari бага не было видно: там Document PiP не
используется, а video-фолбэк берёт <video> прямо из фокус-плитки основного
окна.
Ключ фокуса теперь передаётся наружу (onFocusKeyChange), живёт в состоянии
RoomPage и достаётся следующему экземпляру сцены (initialFocusKey). Работает
в обе стороны — возврат из мини-плеера тоже не сбрасывает фокус. Правила
выбора фокуса (pickStageFocus) не тронуты.
Раскладка сцены больше не выбирается автоматически — пользователь выбирает
один из трёх режимов:
- «Стандарт» — как раньше: крупная плитка плюс карусель остальных сбоку,
кого показать крупно, по-прежнему решает pickStageFocus;
- «Плитки» — все участники равными плитками, без выделенного крупного;
- «Живые плитки» — сетка только из тех, у кого включена камера, остальные
в карусели сбоку; если камеру не включил никто, сетка была бы пустой —
режим вырождается в «Плитки».
Демонстрация экрана перебивает выбранный режим: пока в комнате есть активный
шэр, сцена ведёт себя как «Стандарт» (смысл плиточных режимов — равноправие
участников, а демонстрация неравноправна по определению). Режим при этом
живёт в состоянии RoomPage, поэтому по завершении шэра вид сам возвращается
к выбранному.
Скрытие остальных убирает карусель, основная область занимает сцену целиком.
Скрыть можно из меню «Вид», из шторки настроек на мобильном и кнопкой прямо
над колонкой миниатюр; вернуть — кнопкой «Показать остальных (N)» на сцене,
которая видна всё время, пока кто-то скрыт. В режиме «Плитки» скрывать
нечего, переключатель там заблокирован с пояснением.
Переключатель на широком экране — кнопка «Вид» с поповером; на мобильном её
нет (тулбар там и так ужат до пяти кнопок), те же настройки идут секцией в
шторке. Видимость решается условным рендерингом в React, а не новым
CSS-правилом поверх медиазапроса — в room.css за это уже была битва
специфичности.
Режим сохраняется между заходами в комнату (localStorage, своим модулем —
LocalUserChoices у LiveKit фиксированной структуры, поля под раскладку там
нет). Скрытие не сохраняется: разовое действие по ходу разговора, войти в
новую конференцию без половины участников — сюрприз, а не удобство.
Сетка собрана своим StageGrid поверх тех же публичных хуков, что использует
GridLayout: библиотечный компонент не пробрасывает gridLayouts, а её набор
раскладок требует 560px уже для 2x2 — на телефоне это две плитки на страницу.
Свой набор даёт 2x2 от 360px и портретную 2x3. Индикатор страниц тоже свой:
PaginationControl/PaginationIndicator из пакета не экспортируются.
Настройка «Эталон mail-домена» теперь хранит список доменов вместо
одного — email при регистрации принимается, если совпадает с любым из
них. Старое значение в БД ({"domain": str|None}) читается прозрачно
(обратная совместимость без Alembic-миграции) и переписывается в новую
форму ({"domains": [...]}) при первом же сохранении настроек. В админке
добавление/удаление доменов — списком чипов; на экране регистрации
подсказка о несовпадении домена перечисляет все эталонные варианты.
Третий селект в DeviceSettingsDialog (динамики/наушники/bluetooth) рядом
с уже существующими микрофоном и камерой — на useMediaDeviceSelect(kind:
'audiooutput'). Выбор персистится отдельно от usePersistentUserChoices
(LocalUserChoices LiveKit про аудиовыход не знает) и применяется заново
при следующем подключении через RoomOptions.audioOutput.
Поддержка определяется по наличию setSinkId в рантайме, не по User-Agent:
там, где браузер не умеет переключать вывод (iOS Safari), список скрыт,
вместо него — подсказка, что маршрутизация звука на стороне системы.
Раньше ConferenceFormCard всегда висела в сайдбаре двухколоночной сетки
.layout-grid; теперь открывается в модалке (по образцу
ConferenceOccurrenceDialog: modal-overlay/modal-panel, закрытие по клику на
оверлей, Escape и возврат фокуса — новый общий хук useModalDismiss).
.layout-grid стала одноколоночной — календарь занимает всю ширину.