Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0162cc8a6d | |||
| 5d39e0f076 | |||
| 43cc1ae3e8 | |||
| 82204553e2 | |||
| 3290eb5d07 | |||
| f627d827af | |||
| 5e6c4f8bfd | |||
| 270926cc96 | |||
| e018837a1d |
@@ -112,7 +112,7 @@ SMTP_TIMEOUT_S=30
|
|||||||
# --- Версия инстанса (релиз v0.0.1) ---
|
# --- Версия инстанса (релиз v0.0.1) ---
|
||||||
# install.sh копирует значение из корневого файла VERSION при каждой
|
# install.sh копирует значение из корневого файла VERSION при каждой
|
||||||
# установке/обновлении — руками менять не нужно.
|
# установке/обновлении — руками менять не нужно.
|
||||||
VIDCONF_VERSION=0.0.13
|
VIDCONF_VERSION=0.0.15
|
||||||
|
|
||||||
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
||||||
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
||||||
|
|||||||
50
CHANGELOG.md
50
CHANGELOG.md
@@ -3,6 +3,56 @@
|
|||||||
Формат основан на [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.15] — 2026-07-30
|
||||||
|
|
||||||
|
Шесть доработок UI комнаты конференции.
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
- Выбор эмодзи в чате — поповер с небольшим собственным набором популярных
|
||||||
|
эмодзи (без библиотеки-пикера), вставка в позицию курсора.
|
||||||
|
- Кнопка микрофона в мини-окне (Document PiP) — раньше в PiP не было
|
||||||
|
никакого способа замьютить себя, не разворачивая основное окно.
|
||||||
|
- Закрепление участника доступно из любого режима показа («Стандарт»,
|
||||||
|
«Плитки», «Живые плитки») — раньше булавка рендерилась только в
|
||||||
|
«Стандарте». Само закрепление автоматически переключает вид на «Стандарт»
|
||||||
|
(закреплённого иначе негде показать крупно); открепление вид назад не
|
||||||
|
меняет.
|
||||||
|
|
||||||
|
### Изменено
|
||||||
|
- Участник заходит в конференцию с выключенными микрофоном и камерой,
|
||||||
|
включает их сам уже в комнате.
|
||||||
|
- В топбаре комнаты вместо технического `room_name` LiveKit (вида
|
||||||
|
`hC-Dmos9KEM`) показывается название конференции; при его отсутствии —
|
||||||
|
«Конференция без названия». Номер и ссылка-приглашение не задеты.
|
||||||
|
|
||||||
|
## [0.0.14] — 2026-07-28
|
||||||
|
|
||||||
|
TURN-фолбэк для участников из сетей с жёстким NAT.
|
||||||
|
|
||||||
|
### Исправлено
|
||||||
|
- LiveKit теперь анонсирует клиентам внешний TURN-сервер (`rtc.turn_servers`
|
||||||
|
в конфигурации, UDP и TCP на 3478). Раньше coturn поднимался и был healthy,
|
||||||
|
но клиенты о нём не знали: внешний TURN в конфиге объявлен не был, встроенный
|
||||||
|
выключен, а фронтенд `iceServers` не задаёт. За всё время работы в логах
|
||||||
|
coturn не было ни одной аллокации — то есть relay не использовался никогда,
|
||||||
|
и участники из сетей, где прямое UDP-соединение не проходит, теряли связь
|
||||||
|
(`PEER_CONNECTION_DISCONNECTED`). На нагрузочном тесте 28.07 все такие
|
||||||
|
разрывы пришлись на внешних участников и ни одного — на офисных.
|
||||||
|
- Credentials TURN генерируются по механизму TURN REST API из общего
|
||||||
|
`TURN_STATIC_AUTH_SECRET`, то есть тот же секрет, что и у coturn.
|
||||||
|
|
||||||
|
### Изменено
|
||||||
|
- `deploy/render-templates.sh` подставляет `TURN_EXTERNAL_IP` и
|
||||||
|
`TURN_STATIC_AUTH_SECRET` также в конфигурацию LiveKit.
|
||||||
|
- Раздел 8 руководства по развёртыванию переписан: TURN больше не «план на
|
||||||
|
будущее», а рабочая конфигурация. Отдельно описано правило ufw для
|
||||||
|
relay-диапазона `49160:49200/udp` — без него TURN отвечает на запросы, но
|
||||||
|
релей не работает, причём в логах coturn при этом тишина.
|
||||||
|
|
||||||
|
TURN over TLS (5349/443) по-прежнему не настроен: требует монтирования
|
||||||
|
сертификата в coturn. Неработающий `turns:` намеренно не анонсируется, иначе
|
||||||
|
клиент ждал бы таймаута перед переходом к рабочему кандидату.
|
||||||
|
|
||||||
## [0.0.13] — 2026-07-28
|
## [0.0.13] — 2026-07-28
|
||||||
|
|
||||||
Снижение нагрузки на сеть: клиент перестаёт получать полное качество всех
|
Снижение нагрузки на сеть: клиент перестаёт получать полное качество всех
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
name: vidconf
|
name: vidconf
|
||||||
|
|
||||||
|
# Глубина логов рассчитана на РАЗБОР ИНЦИДЕНТОВ, а не только на просмотр
|
||||||
|
# последних сообщений. При 10 МБ × 3 (прежнее значение) логи LiveKit на
|
||||||
|
# конференции в полсотни человек перезаписывались за часы — а именно по ним
|
||||||
|
# восстанавливаются вещи, которых нет в метриках: сколько камер было включено
|
||||||
|
# одновременно, кого и почему отключило, какие события congestion шли.
|
||||||
|
# 50 МБ × 5 = 250 МБ на контейнер; на сервере с 15 ГБ свободного места это
|
||||||
|
# незаметно, зато ретроспектива живёт неделями.
|
||||||
x-logging: &default-logging
|
x-logging: &default-logging
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "50m"
|
||||||
max-file: "3"
|
max-file: "5"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -82,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.13}
|
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.15}
|
||||||
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
|
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
|
||||||
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
|
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
|
||||||
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
|
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
|
||||||
@@ -664,6 +671,16 @@ services:
|
|||||||
# Тег закреплён по версии (не `:latest`) — та же причина, что и у llm-образов.
|
# Тег закреплён по версии (не `:latest`) — та же причина, что и у llm-образов.
|
||||||
image: prom/prometheus:v3.13.1
|
image: prom/prometheus:v3.13.1
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# Первые два флага — дефолт образа; повторяем их явно, потому что
|
||||||
|
# `command` перекрывает CMD целиком. Третий — срок хранения: дефолтных
|
||||||
|
# 15 суток мало, когда нагрузку набирают неделями (наблюдение за
|
||||||
|
# реальными конференциями вместо разового теста), и разбирать её потом
|
||||||
|
# приходится задним числом. 30 суток при нынешних 100 МБ TSDB стоят
|
||||||
|
# копеек — база растёт медленнее, чем кажется.
|
||||||
|
command:
|
||||||
|
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||||
|
- '--storage.tsdb.path=/prometheus'
|
||||||
|
- '--storage.tsdb.retention.time=30d'
|
||||||
volumes:
|
volumes:
|
||||||
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
- ./monitoring/alerts.yml:/etc/prometheus/alerts.yml:ro
|
- ./monitoring/alerts.yml:/etc/prometheus/alerts.yml:ro
|
||||||
|
|||||||
@@ -32,6 +32,40 @@ rtc:
|
|||||||
use_external_ip: ${LIVEKIT_USE_EXTERNAL_IP}
|
use_external_ip: ${LIVEKIT_USE_EXTERNAL_IP}
|
||||||
node_ip: ${LIVEKIT_NODE_IP}
|
node_ip: ${LIVEKIT_NODE_IP}
|
||||||
|
|
||||||
|
# Внешний TURN (сервис coturn, профиль `media`) — АНОНС КЛИЕНТАМ.
|
||||||
|
# Сам SFU через эти серверы не ходит: LiveKit лишь отдаёт их браузеру в
|
||||||
|
# списке ICE-серверов при подключении (см. iceServersForParticipant в
|
||||||
|
# pkg/service/roommanager.go), а клиент уже решает, нужен ли ему relay.
|
||||||
|
#
|
||||||
|
# Зачем. До 28.07.2026 coturn работал, но КЛИЕНТЫ О НЁМ НЕ ЗНАЛИ: секция
|
||||||
|
# `turn` ниже выключена (встроенный TURN не поднимаем), внешний в конфиге
|
||||||
|
# объявлен не был, а фронтенд `iceServers` не задаёт. За всё время работы
|
||||||
|
# в логах coturn — ноль ALLOCATE. Итог: у клиентов из сетей с жёстким NAT
|
||||||
|
# не было relay-фолбэка вообще, только прямой UDP и TCP 7881. Именно так
|
||||||
|
# объясняются `PEER_CONNECTION_DISCONNECTED` на нагрузочном тесте — все
|
||||||
|
# у внешних участников, ни одного у офисных (.forcc/LOAD-FINDINGS.md,
|
||||||
|
# причина C).
|
||||||
|
#
|
||||||
|
# `secret` обязан совпадать с `static-auth-secret` в turnserver.conf —
|
||||||
|
# оба рендерятся из одного TURN_STATIC_AUTH_SECRET (deploy/render-templates.sh).
|
||||||
|
# Логин/пароль LiveKit генерирует сам по механизму TURN REST API.
|
||||||
|
#
|
||||||
|
# UDP и TCP на 3478 — оба порта уже открыты в ufw. TLS (5349) намеренно не
|
||||||
|
# объявляем: в turnserver.conf сертификаты не смонтированы, и анонс
|
||||||
|
# неработающего `turns:` заставил бы клиента впустую ждать таймаута,
|
||||||
|
# прежде чем перейти к рабочему кандидату.
|
||||||
|
turn_servers:
|
||||||
|
- host: ${TURN_EXTERNAL_IP}
|
||||||
|
port: 3478
|
||||||
|
protocol: udp
|
||||||
|
secret: ${TURN_STATIC_AUTH_SECRET}
|
||||||
|
ttl: 14400
|
||||||
|
- host: ${TURN_EXTERNAL_IP}
|
||||||
|
port: 3478
|
||||||
|
protocol: tcp
|
||||||
|
secret: ${TURN_STATIC_AUTH_SECRET}
|
||||||
|
ttl: 14400
|
||||||
|
|
||||||
# Redis обязателен для сервиса egress (см. deploy/egress/) — он использует
|
# Redis обязателен для сервиса egress (см. deploy/egress/) — он использует
|
||||||
# его как pub/sub и key-value хранилище состояния запущенных записей;
|
# его как pub/sub и key-value хранилище состояния запущенных записей;
|
||||||
# без него egress не может получать room/track-события от LiveKit
|
# без него egress не может получать room/track-события от LiveKit
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ envsubst '${TURN_STATIC_AUTH_SECRET} ${TURN_REALM} ${TURN_EXTERNAL_IP}' \
|
|||||||
< "$SCRIPT_DIR/coturn/turnserver.conf.template" > "$SCRIPT_DIR/coturn/turnserver.conf"
|
< "$SCRIPT_DIR/coturn/turnserver.conf.template" > "$SCRIPT_DIR/coturn/turnserver.conf"
|
||||||
echo "[render] deploy/coturn/turnserver.conf готов"
|
echo "[render] deploy/coturn/turnserver.conf готов"
|
||||||
|
|
||||||
envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD}' \
|
# TURN_EXTERNAL_IP и TURN_STATIC_AUTH_SECRET нужны и здесь: с 0.0.14 LiveKit
|
||||||
|
# анонсирует клиентам внешний coturn (секция `rtc.turn_servers`), и секрет
|
||||||
|
# обязан совпадать с `static-auth-secret` в turnserver.conf выше.
|
||||||
|
envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD} ${TURN_EXTERNAL_IP} ${TURN_STATIC_AUTH_SECRET}' \
|
||||||
< "$SCRIPT_DIR/livekit/livekit.yaml.template" > "$SCRIPT_DIR/livekit/livekit.yaml"
|
< "$SCRIPT_DIR/livekit/livekit.yaml.template" > "$SCRIPT_DIR/livekit/livekit.yaml"
|
||||||
echo "[render] deploy/livekit/livekit.yaml готов"
|
echo "[render] deploy/livekit/livekit.yaml готов"
|
||||||
|
|
||||||
|
|||||||
@@ -82,9 +82,14 @@ ufw allow 80/tcp # HTTP (редирект на HTTPS + ACME-challenge)
|
|||||||
ufw allow 443/tcp # HTTPS
|
ufw allow 443/tcp # HTTPS
|
||||||
ufw allow 7881/tcp # LiveKit RTC TCP fallback (профиль media)
|
ufw allow 7881/tcp # LiveKit RTC TCP fallback (профиль media)
|
||||||
ufw allow 54000:54100/udp # LiveKit WebRTC media (ICE), см. docker-compose.yml
|
ufw allow 54000:54100/udp # LiveKit WebRTC media (ICE), см. docker-compose.yml
|
||||||
# TURN (coturn) — только если включаете раздел 8:
|
# TURN (coturn) — только если включаете раздел 8. Нужны ОБА пункта:
|
||||||
|
# сигнальные порты И диапазон relay-аллокаций (min-port/max-port из
|
||||||
|
# deploy/coturn/turnserver.conf). Без второго TURN отвечает на запросы, но
|
||||||
|
# сам релей не работает — клиент получает кандидата и не может им
|
||||||
|
# воспользоваться, а в логах coturn при этом тишина.
|
||||||
# ufw allow 3478/tcp
|
# ufw allow 3478/tcp
|
||||||
# ufw allow 3478/udp
|
# ufw allow 3478/udp
|
||||||
|
# ufw allow 49160:49200/udp
|
||||||
|
|
||||||
# Мониторинг (профиль `monitoring`): node-exporter работает в host-сети —
|
# Мониторинг (профиль `monitoring`): node-exporter работает в host-сети —
|
||||||
# иначе он отдаёт сетевые метрики собственного контейнера вместо метрик
|
# иначе он отдаёт сетевые метрики собственного контейнера вместо метрик
|
||||||
@@ -320,29 +325,48 @@ firewall) хватает для подавляющего большинства
|
|||||||
(характерный симптом — конференция подключается по signaling, `connection
|
(характерный симптом — конференция подключается по signaling, `connection
|
||||||
state: connected`, но собеседник не видит видео/не слышит звук).
|
state: connected`, но собеседник не видит видео/не слышит звук).
|
||||||
|
|
||||||
По умолчанию `deploy/livekit/livekit.yaml.template` содержит
|
**С версии 0.0.14 LiveKit анонсирует coturn клиентам** — секция
|
||||||
`turn.enabled: false`, и `rtc.turn_servers` не задан — standalone coturn
|
`rtc.turn_servers` в `deploy/livekit/livekit.yaml.template` (UDP и TCP на
|
||||||
поднимается (профиль `media`), но LiveKit не раздаёт его клиентам как
|
3478, credentials по механизму TURN REST API из общего
|
||||||
ICE-фолбэк.
|
`TURN_STATIC_AUTH_SECRET`). Встроенный TURN LiveKit при этом остаётся
|
||||||
|
выключенным (`turn.enabled: false`), чтобы не поднимать два TURN-сервера.
|
||||||
|
|
||||||
Включение (правки шаблона `deploy/livekit/livekit.yaml.template` +
|
⚠️ **Чем это было до 0.0.14, если вы обновляетесь со старой версии.** coturn
|
||||||
редеплой; **код-фикс не входит в это руководство без запроса** — обсудите
|
поднимался и был healthy, но клиенты о нём не знали: в конфиге LiveKit
|
||||||
с командой перед изменением):
|
внешний TURN объявлен не был, а фронтенд `iceServers` не задаёт. За всё
|
||||||
|
время работы в логах coturn не было ни одного ALLOCATE — то есть relay не
|
||||||
|
использовался никогда, и участники из сетей с жёстким NAT просто теряли
|
||||||
|
соединение (`PEER_CONNECTION_DISCONNECTED`).
|
||||||
|
|
||||||
1. Открыть 443 для TURN/TLS (наиболее надёжный фолбэк — TURN через тот же
|
Что нужно проверить на своей инсталляции:
|
||||||
порт, что и остальной HTTPS-трафик, редко блокируется firewall'ами):
|
|
||||||
потребует отдельного TLS-сертификата для coturn (`cert-file`/`pkey-file`
|
1. **Порты в ufw — оба пункта** (см. шаг 1): `3478/tcp` + `3478/udp` для
|
||||||
в `deploy/coturn/turnserver.conf.template`) — можно переиспользовать тот
|
сигнализации и `49160:49200/udp` для relay-аллокаций. Диапазон должен
|
||||||
же Let's Encrypt сертификат, что и nginx (тот же `/etc/letsencrypt`, уже
|
совпадать с `min-port`/`max-port` в
|
||||||
смонтированный в nginx — coturn сейчас его не монтирует, потребуется
|
`deploy/coturn/turnserver.conf.template`. Без него TURN отвечает на
|
||||||
доп. volume).
|
запросы, но релей не работает — самый неприятный вариант, потому что в
|
||||||
2. В `livekit.yaml.template` включить `turn.enabled: true` и/или явно
|
логах coturn при этом тишина.
|
||||||
прописать `rtc.turn_servers` со статическими credentials
|
2. **`TURN_EXTERNAL_IP` в `.env`** — реальный внешний IP или домен сервера.
|
||||||
(`TURN_STATIC_AUTH_SECRET` уже есть в `.env`).
|
Именно это значение уезжает клиентам как адрес TURN-сервера, поэтому
|
||||||
3. `ufw allow 3478/tcp` + `ufw allow 3478/udp` (шаг 1, закомментированные
|
`127.0.0.1` из dev-дефолта сделает анонс бесполезным.
|
||||||
строки) — сейчас coturn поднят, но порт не проверялся как обязательный.
|
3. После правок — `./deploy/render-templates.sh` (перерендерит конфиги из
|
||||||
4. Передеплой (`docker compose ... up -d --force-recreate livekit coturn`)
|
шаблонов), затем `docker compose ... up -d --force-recreate livekit`.
|
||||||
и повторный кросс-сетевой тест именно с проблемной сетью.
|
⚠️ Перезапуск LiveKit **разрывает все активные конференции** — выбирайте
|
||||||
|
окно.
|
||||||
|
4. Проверка, что релей заработал: провести звонок из проблемной сети и
|
||||||
|
убедиться, что в логах появились аллокации:
|
||||||
|
`docker logs vidconf-coturn-1 --since 10m 2>&1 | grep -ci allocate`.
|
||||||
|
Ноль при живом звонке из-за NAT означает, что до coturn не дошли —
|
||||||
|
смотрите ufw и `TURN_EXTERNAL_IP`.
|
||||||
|
|
||||||
|
**TURN over TLS (порт 5349 или 443) — не настроен.** Это самый надёжный
|
||||||
|
фолбэк (проходит там, где режут UDP и нестандартные порты), но требует
|
||||||
|
смонтировать в coturn TLS-сертификат: раскомментировать `cert`/`pkey` в
|
||||||
|
`deploy/coturn/turnserver.conf.template`, добавить volume с
|
||||||
|
`/etc/letsencrypt` (nginx его уже монтирует, coturn — нет), открыть порт и
|
||||||
|
не забыть про перезапуск coturn при обновлении сертификата. Пока этого нет,
|
||||||
|
`turns:` намеренно не анонсируется: анонс неработающего адреса заставил бы
|
||||||
|
клиента ждать таймаута перед переходом к рабочему кандидату.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
|
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
|
||||||
import { AlertCircle, Send, X } from 'lucide-react'
|
import { AlertCircle, Send, SmilePlus, X } from 'lucide-react'
|
||||||
import type { ChatConnectionStatus, ChatMessageOut } from '@/hooks/useChat'
|
import type { ChatConnectionStatus, ChatMessageOut } from '@/hooks/useChat'
|
||||||
import { formatLocalTime } from '@/lib/localTime'
|
import { formatLocalTime } from '@/lib/localTime'
|
||||||
|
import { useModalDismiss } from '@/hooks/useModalDismiss'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на
|
||||||
|
* сотни килобайт ради десятка кнопок в поповере.
|
||||||
|
*/
|
||||||
|
const EMOJI_OPTIONS = [
|
||||||
|
'😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢',
|
||||||
|
'😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️',
|
||||||
|
'🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝',
|
||||||
|
]
|
||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
messages: ChatMessageOut[]
|
messages: ChatMessageOut[]
|
||||||
@@ -38,12 +49,21 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
|
|||||||
}
|
}
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
|
// Авторасширение textarea до 4 строк, дальше — внутренний скролл. Эффектом,
|
||||||
|
// а не только в обработчике ввода, — так пересчёт срабатывает и после
|
||||||
|
// программной вставки эмодзи, и после очистки поля отправкой.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = textareaRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = `${Math.min(el.scrollHeight, 96)}px`
|
||||||
|
}, [draft])
|
||||||
|
|
||||||
function handleSend() {
|
function handleSend() {
|
||||||
const text = draft.trim()
|
const text = draft.trim()
|
||||||
if (!text || status !== 'open') return
|
if (!text || status !== 'open') return
|
||||||
onSend(text)
|
onSend(text)
|
||||||
setDraft('')
|
setDraft('')
|
||||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
@@ -55,10 +75,27 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
|
|||||||
|
|
||||||
function handleDraftChange(event: ChangeEvent<HTMLTextAreaElement>) {
|
function handleDraftChange(event: ChangeEvent<HTMLTextAreaElement>) {
|
||||||
setDraft(event.target.value)
|
setDraft(event.target.value)
|
||||||
// Авторасширение textarea до 4 строк, дальше — внутренний скролл.
|
}
|
||||||
const el = event.target
|
|
||||||
el.style.height = 'auto'
|
// Вставка эмодзи в позицию курсора, а не в конец поля, — ожидаемое поведение
|
||||||
el.style.height = `${Math.min(el.scrollHeight, 96)}px`
|
// при редактировании текста в середине.
|
||||||
|
function insertEmoji(emoji: string) {
|
||||||
|
const el = textareaRef.current
|
||||||
|
if (!el) {
|
||||||
|
setDraft((current) => current + emoji)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const start = el.selectionStart ?? draft.length
|
||||||
|
const end = el.selectionEnd ?? draft.length
|
||||||
|
setDraft(draft.slice(0, start) + emoji + draft.slice(end))
|
||||||
|
// value обновится реальным React-рендером только на следующий тик —
|
||||||
|
// курсор возвращаем после него, иначе setSelectionRange встанет на
|
||||||
|
// ещё старую (более короткую) строку.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const pos = start + emoji.length
|
||||||
|
el.focus()
|
||||||
|
el.setSelectionRange(pos, pos)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -101,6 +138,7 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
|
|||||||
handleSend()
|
handleSend()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<EmojiPickerButton onSelect={insertEmoji} disabled={status !== 'open'} />
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
rows={1}
|
rows={1}
|
||||||
@@ -118,3 +156,77 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
|
|||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EmojiPickerButtonProps {
|
||||||
|
onSelect: (emoji: string) => void
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кнопка с поповером выбора эмодзи слева от поля ввода. Поповер открывается
|
||||||
|
* ВВЕРХ от кнопки (как `.tb-menu` тулбара) — на мобильном чат-панель занята
|
||||||
|
* полем ввода снизу, а место над тулбаром свободно, так поповер никогда не
|
||||||
|
* перекрывает textarea.
|
||||||
|
*/
|
||||||
|
function EmojiPickerButton({ onSelect, disabled }: EmojiPickerButtonProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Закрытие по клику вне — тем же паттерном, что меню «Вид» (StageViewMenu):
|
||||||
|
// слушатель на document, пока поповер открыт.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function handlePointerDown(event: MouseEvent) {
|
||||||
|
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handlePointerDown)
|
||||||
|
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-emoji-wrap" ref={wrapRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`chat-emoji-trigger${open ? ' is-open' : ''}`}
|
||||||
|
aria-label="Вставить эмодзи"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<SmilePlus className="lucide" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<EmojiPopover
|
||||||
|
onSelect={(emoji) => {
|
||||||
|
onSelect(emoji)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmojiPopoverProps {
|
||||||
|
onSelect: (emoji: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Отдельный компонент — чтобы `useModalDismiss` (Escape + возврат фокуса) жил ровно то время, пока поповер открыт. */
|
||||||
|
function EmojiPopover({ onSelect, onClose }: EmojiPopoverProps) {
|
||||||
|
useModalDismiss(onClose)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-emoji-popover" role="dialog" aria-label="Выбор эмодзи">
|
||||||
|
{EMOJI_OPTIONS.map((emoji) => (
|
||||||
|
<button key={emoji} type="button" className="chat-emoji-option" onClick={() => onSelect(emoji)}>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react'
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
import { EyeOff, Users } from 'lucide-react'
|
import { EyeOff, Mic, MicOff, Users } from 'lucide-react'
|
||||||
import { Track, type Participant } from 'livekit-client'
|
import { Track, type Participant } from 'livekit-client'
|
||||||
import {
|
import {
|
||||||
CarouselLayout,
|
CarouselLayout,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
isTrackReference,
|
isTrackReference,
|
||||||
useRoomContext,
|
useRoomContext,
|
||||||
useSpeakingParticipants,
|
useSpeakingParticipants,
|
||||||
|
useTrackToggle,
|
||||||
useTracks,
|
useTracks,
|
||||||
type TrackReferenceOrPlaceholder,
|
type TrackReferenceOrPlaceholder,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
@@ -93,6 +94,29 @@ function hasLiveVideo(track: TrackReferenceOrPlaceholder): boolean {
|
|||||||
return isTrackReference(track) && !track.publication.isMuted
|
return isTrackReference(track) && !track.publication.isMuted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кнопка микрофона поверх единственной плитки мини-плеера (задача A5): в
|
||||||
|
* Document PiP своего тулбара нет вовсе, а звонок продолжается — без неё
|
||||||
|
* замьютить себя можно, только развернув основное окно. `useTrackToggle` —
|
||||||
|
* тот же хук, что и в `RoomToolbar`, читает/меняет состояние ЧЕРЕЗ
|
||||||
|
* `RoomContext`, а не через DOM, поэтому работает одинаково что в основном
|
||||||
|
* окне, что в портале, рендерящем эту сцену внутрь `pipWindow.document.body`
|
||||||
|
* (React-контекст порталом не разрывается, в отличие от DOM-дерева).
|
||||||
|
*/
|
||||||
|
function PipMicToggle() {
|
||||||
|
const mic = useTrackToggle({ source: Track.Source.Microphone })
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
{...mic.buttonProps}
|
||||||
|
className={`room-pip-mic-toggle${mic.enabled ? '' : ' is-off'}`}
|
||||||
|
aria-label={mic.enabled ? 'Выключить микрофон' : 'Включить микрофон'}
|
||||||
|
>
|
||||||
|
{mic.enabled ? <Mic className="lucide" aria-hidden="true" /> : <MicOff className="lucide" aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Основная сцена конференции: превью остальных участников + крупная плитка
|
* Основная сцена конференции: превью остальных участников + крупная плитка
|
||||||
* активного спикера (FocusLayoutContainer + CarouselLayout при нескольких
|
* активного спикера (FocusLayoutContainer + CarouselLayout при нескольких
|
||||||
@@ -179,6 +203,7 @@ export function RoomStage({
|
|||||||
onHideOthers,
|
onHideOthers,
|
||||||
initialFocusKey = null,
|
initialFocusKey = null,
|
||||||
onFocusKeyChange,
|
onFocusKeyChange,
|
||||||
|
onPinFocus,
|
||||||
}: {
|
}: {
|
||||||
variant?: 'full' | 'pip'
|
variant?: 'full' | 'pip'
|
||||||
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
|
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
|
||||||
@@ -193,6 +218,14 @@ export function RoomStage({
|
|||||||
initialFocusKey?: string | null
|
initialFocusKey?: string | null
|
||||||
/** Сообщать наружу текущий фокус, чтобы его пережил переезд сцены в мини-плеер и обратно. */
|
/** Сообщать наружу текущий фокус, чтобы его пережил переезд сцены в мини-плеер и обратно. */
|
||||||
onFocusKeyChange?: (key: string | null) => void
|
onFocusKeyChange?: (key: string | null) => void
|
||||||
|
/**
|
||||||
|
* Участника только что закрепили (не открепили) в режиме без крупной
|
||||||
|
* плитки — сцена сама переключиться не может (режим живёт в `RoomPage`),
|
||||||
|
* поэтому просит переключить на `standard`, где закреплённого есть куда
|
||||||
|
* показать крупно. Открепление НЕ вызывает колбэк — вид назад не
|
||||||
|
* переключаем (см. докстринг `RoomPage`, обоснование решения в коммите).
|
||||||
|
*/
|
||||||
|
onPinFocus?: () => void
|
||||||
}) {
|
}) {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const isCompact = useIsCompactViewport()
|
const isCompact = useIsCompactViewport()
|
||||||
@@ -337,9 +370,16 @@ export function RoomStage({
|
|||||||
* Закрепить/открепить плитку: повторное нажатие на уже закреплённой снимает
|
* Закрепить/открепить плитку: повторное нажатие на уже закреплённой снимает
|
||||||
* закрепление. Ключ приходит из самой плитки (она знает свой трек из
|
* закрепление. Ключ приходит из самой плитки (она знает свой трек из
|
||||||
* контекста — в карусели/гриде плитки рендерятся шаблоном, без пропсов).
|
* контекста — в карусели/гриде плитки рендерятся шаблоном, без пропсов).
|
||||||
|
*
|
||||||
|
* Закрепление доступно из ЛЮБОГО режима показа (задача 3.1 → 3.3): в
|
||||||
|
* плиточных режимах крупной плитки нет, поэтому само закрепление (не
|
||||||
|
* открепление) просит `RoomPage` переключить вид на `standard` —
|
||||||
|
* закреплённого иначе негде показать крупно.
|
||||||
*/
|
*/
|
||||||
function handleTogglePin(key: string) {
|
function handleTogglePin(key: string) {
|
||||||
setPinnedKey((prev) => (prev === key ? null : key))
|
const willPin = pinnedKey !== key
|
||||||
|
setPinnedKey(willPin ? key : null)
|
||||||
|
if (willPin) onPinFocus?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Мини-плеер показывает ТОЛЬКО активное окно — без карусели/
|
// Мини-плеер показывает ТОЛЬКО активное окно — без карусели/
|
||||||
@@ -350,6 +390,7 @@ export function RoomStage({
|
|||||||
return (
|
return (
|
||||||
<section className="stage room-single-tile">
|
<section className="stage room-single-tile">
|
||||||
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
|
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
|
||||||
|
<PipMicToggle />
|
||||||
<RoomAudioRenderer />
|
<RoomAudioRenderer />
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
@@ -372,23 +413,23 @@ export function RoomStage({
|
|||||||
: []
|
: []
|
||||||
: carouselTracks
|
: carouselTracks
|
||||||
const showCarousel = !hideOthers && sideTracks.length > 0
|
const showCarousel = !hideOthers && sideTracks.length > 0
|
||||||
// Закрепление имеет смысл только там, где есть «крупная плитка» —
|
// Булавка закрепления есть на КАЖДОЙ плитке во всех режимах (задача 3.3):
|
||||||
// в плиточных режимах фокуса нет, поэтому и булавки на плитках нет.
|
// сама кнопка вызывает переключение на `standard`, где закреплённый и
|
||||||
const pinProps =
|
// попадёт в фокус (см. `handleTogglePin`/`onPinFocus`).
|
||||||
effectiveMode === 'standard' ? { pinnedKey, onTogglePin: handleTogglePin } : {}
|
const pinProps = { pinnedKey, onTogglePin: handleTogglePin }
|
||||||
|
|
||||||
function renderMain(): ReactNode {
|
function renderMain(): ReactNode {
|
||||||
if (effectiveMode === 'tiles') {
|
if (effectiveMode === 'tiles') {
|
||||||
return (
|
return (
|
||||||
<StageGrid tracks={tracks}>
|
<StageGrid tracks={tracks}>
|
||||||
<RoomParticipantTile />
|
<RoomParticipantTile {...pinProps} />
|
||||||
</StageGrid>
|
</StageGrid>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (effectiveMode === 'live-tiles') {
|
if (effectiveMode === 'live-tiles') {
|
||||||
return (
|
return (
|
||||||
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks}>
|
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks}>
|
||||||
<RoomParticipantTile />
|
<RoomParticipantTile {...pinProps} />
|
||||||
</StageGrid>
|
</StageGrid>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import { useState } from 'react'
|
|||||||
import { pluralizeParticipants } from '@/lib/pluralize'
|
import { pluralizeParticipants } from '@/lib/pluralize'
|
||||||
|
|
||||||
interface RoomTopbarProps {
|
interface RoomTopbarProps {
|
||||||
roomName: string
|
/**
|
||||||
|
* Название конференции (`ConferenceOut.title`/`ResolveOut.title`) —
|
||||||
|
* `null`/не задано, если организатор его не указал. НЕ путать с
|
||||||
|
* `JoinOut.room_name` (техническое имя комнаты LiveKit вида `hC-Dmos9KEM`,
|
||||||
|
* которое сюда раньше и попадало по ошибке, см. задачу A6) — тот нужен
|
||||||
|
* только серверу и в UI показывать нечего.
|
||||||
|
*/
|
||||||
|
title: string | null
|
||||||
/** Slug/номер конференции из адреса — для инвайт-чипа (копирование ссылки). */
|
/** Slug/номер конференции из адреса — для инвайт-чипа (копирование ссылки). */
|
||||||
slug?: string
|
slug?: string
|
||||||
number?: string
|
number?: string
|
||||||
@@ -19,7 +26,7 @@ interface RoomTopbarProps {
|
|||||||
* тёмных токенов темы `room` (см. `--color-room-tile*`), без новых
|
* тёмных токенов темы `room` (см. `--color-room-tile*`), без новых
|
||||||
* цветов и форм.
|
* цветов и форм.
|
||||||
*/
|
*/
|
||||||
export function RoomTopbar({ roomName, slug, number }: RoomTopbarProps) {
|
export function RoomTopbar({ title, slug, number }: RoomTopbarProps) {
|
||||||
const participants = useParticipants()
|
const participants = useParticipants()
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
@@ -39,7 +46,7 @@ export function RoomTopbar({ roomName, slug, number }: RoomTopbarProps) {
|
|||||||
return (
|
return (
|
||||||
<header className="room-topbar">
|
<header className="room-topbar">
|
||||||
<div className="room-title-block">
|
<div className="room-title-block">
|
||||||
<h1>{roomName}</h1>
|
<h1>{title ?? 'Конференция без названия'}</h1>
|
||||||
<p>
|
<p>
|
||||||
<span className="rec-dot" aria-hidden="true" />
|
<span className="rec-dot" aria-hidden="true" />
|
||||||
Конференция активна · <Users className="lucide" style={{ width: 14, height: 14 }} aria-hidden="true" />{' '}
|
Конференция активна · <Users className="lucide" style={{ width: 14, height: 14 }} aria-hidden="true" />{' '}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export function JoinPage() {
|
|||||||
state: {
|
state: {
|
||||||
livekitUrl: data.livekit_url,
|
livekitUrl: data.livekit_url,
|
||||||
token: data.token,
|
token: data.token,
|
||||||
roomName: data.room_name,
|
title: resolved.title,
|
||||||
conferenceId: data.conference_id,
|
conferenceId: data.conference_id,
|
||||||
chatEnabled: data.chat_enabled,
|
chatEnabled: data.chat_enabled,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function LobbyPage() {
|
|||||||
state: {
|
state: {
|
||||||
livekitUrl: conference.join.livekit_url,
|
livekitUrl: conference.join.livekit_url,
|
||||||
token: conference.join.token,
|
token: conference.join.token,
|
||||||
roomName: conference.join.room_name,
|
title: conference.title,
|
||||||
conferenceId: conference.join.conference_id,
|
conferenceId: conference.join.conference_id,
|
||||||
chatEnabled: conference.join.chat_enabled,
|
chatEnabled: conference.join.chat_enabled,
|
||||||
number: conference.number,
|
number: conference.number,
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '
|
|||||||
interface RoomJoinState {
|
interface RoomJoinState {
|
||||||
livekitUrl: string
|
livekitUrl: string
|
||||||
token: string
|
token: string
|
||||||
roomName: string
|
/** Название конференции (`ConferenceOut.title`/`ResolveOut.title`) — для топбара, см. `RoomTopbar` и задачу A6. */
|
||||||
|
title?: string | null
|
||||||
conferenceId?: string
|
conferenceId?: string
|
||||||
/** Номер конференции — для инвайт-чипа в топбаре (см. RoomTopbar). Есть только сразу после создания. */
|
/** Номер конференции — для инвайт-чипа в топбаре (см. RoomTopbar). Есть только сразу после создания. */
|
||||||
number?: string
|
number?: string
|
||||||
@@ -92,7 +93,7 @@ export function RoomPage() {
|
|||||||
setJoinState({
|
setJoinState({
|
||||||
livekitUrl: result.livekit_url,
|
livekitUrl: result.livekit_url,
|
||||||
token: result.token,
|
token: result.token,
|
||||||
roomName: result.room_name,
|
title: info.title,
|
||||||
conferenceId: result.conference_id,
|
conferenceId: result.conference_id,
|
||||||
chatEnabled: result.chat_enabled,
|
chatEnabled: result.chat_enabled,
|
||||||
})
|
})
|
||||||
@@ -168,6 +169,15 @@ export function RoomPage() {
|
|||||||
saveStageLayoutMode(mode)
|
saveStageLayoutMode(mode)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Закрепление участника из плиточного режима переключает вид на «Стандарт»
|
||||||
|
// (см. докстринг `RoomStage`, `onPinFocus`) — но это НЕ то же самое, что
|
||||||
|
// явный выбор пользователя: переключение не сохраняется в localStorage
|
||||||
|
// (в отличие от `handleLayoutModeChange`), иначе разовое закрепление в этой
|
||||||
|
// встрече поменяло бы устойчивое предпочтение режима для всех следующих.
|
||||||
|
const handlePinFocus = useCallback(() => {
|
||||||
|
setLayoutMode('standard')
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Ключ трека, который сцена показывает крупно. Живёт ЗДЕСЬ, а не только
|
// Ключ трека, который сцена показывает крупно. Живёт ЗДЕСЬ, а не только
|
||||||
// внутри `RoomStage`, потому что при открытии мини-плеера сцена
|
// внутри `RoomStage`, потому что при открытии мини-плеера сцена
|
||||||
// размонтируется в основном окне и монтируется заново в PiP-окне (портал
|
// размонтируется в основном окне и монтируется заново в PiP-окне (портал
|
||||||
@@ -240,13 +250,13 @@ export function RoomPage() {
|
|||||||
serverUrl={joinState.livekitUrl}
|
serverUrl={joinState.livekitUrl}
|
||||||
token={joinState.token}
|
token={joinState.token}
|
||||||
connect
|
connect
|
||||||
audio
|
audio={false}
|
||||||
video
|
video={false}
|
||||||
options={roomOptions}
|
options={roomOptions}
|
||||||
onDisconnected={handleDisconnected}
|
onDisconnected={handleDisconnected}
|
||||||
>
|
>
|
||||||
<div data-lk-theme="default" className="room-shell">
|
<div data-lk-theme="default" className="room-shell">
|
||||||
<RoomTopbar roomName={joinState.roomName} slug={slug} number={joinState.number} />
|
<RoomTopbar title={joinState.title ?? null} slug={slug} number={joinState.number} />
|
||||||
<div className="room-main">
|
<div className="room-main">
|
||||||
{pip.mode === 'document' ? (
|
{pip.mode === 'document' ? (
|
||||||
// Сцена сейчас рисуется в PiP-окне (через createPortal ниже) —
|
// Сцена сейчас рисуется в PiP-окне (через createPortal ниже) —
|
||||||
@@ -266,6 +276,7 @@ export function RoomPage() {
|
|||||||
onHideOthers={() => setHideOthers(true)}
|
onHideOthers={() => setHideOthers(true)}
|
||||||
initialFocusKey={stageFocusKey}
|
initialFocusKey={stageFocusKey}
|
||||||
onFocusKeyChange={setStageFocusKey}
|
onFocusKeyChange={setStageFocusKey}
|
||||||
|
onPinFocus={handlePinFocus}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{chatVisible && chatOpen && (
|
{chatVisible && chatOpen && (
|
||||||
|
|||||||
@@ -133,9 +133,32 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
* `.stage-tiles` в основном окне) плюс подстраховочный `min-height`, чтобы
|
* `.stage-tiles` в основном окне) плюс подстраховочный `min-height`, чтобы
|
||||||
* плитка не схлопывалась, если видео ещё не выдало реальные размеры.
|
* плитка не схлопывалась, если видео ещё не выдало реальные размеры.
|
||||||
*/
|
*/
|
||||||
.room-single-tile { flex: 1; min-height: 0; display: flex; }
|
.room-single-tile { position: relative; flex: 1; min-height: 0; display: flex; }
|
||||||
.room-single-tile .lk-participant-tile { flex: 1; min-height: 220px; width: 100%; }
|
.room-single-tile .lk-participant-tile { flex: 1; min-height: 220px; width: 100%; }
|
||||||
|
|
||||||
|
/* Кнопка микрофона мини-плеера (задача A5) — своего тулбара в PiP нет,
|
||||||
|
кнопка лежит поверх единственной плитки левым нижним углом (правый занят
|
||||||
|
штатным `.lk-focus-toggle-button`/нашей булавкой закрепления — тут её нет,
|
||||||
|
в PiP закреплять нечего, но геометрия зеркалит `.room-pin-toggle`). */
|
||||||
|
.room-pip-mic-toggle {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.5rem;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
z-index: 5;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-room-mic-on);
|
||||||
|
color: #10331f;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.room-pip-mic-toggle svg { width: 18px; height: 18px; }
|
||||||
|
.room-pip-mic-toggle.is-off { background: var(--color-room-danger-bg); color: #fff; }
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ---------- Колонка миниатюр (кнопка «Скрыть» + карусель) ----------
|
* ---------- Колонка миниатюр (кнопка «Скрыть» + карусель) ----------
|
||||||
* Прямой ребёнок `.lk-focus-layout` — вместо самой карусели, потому что над
|
* Прямой ребёнок `.lk-focus-layout` — вместо самой карусели, потому что над
|
||||||
@@ -497,6 +520,49 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
}
|
}
|
||||||
.chat-input-row button:disabled { opacity: 0.5; cursor: default; }
|
.chat-input-row button:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.chat-emoji-wrap { position: relative; display: flex; flex-shrink: 0; }
|
||||||
|
.chat-emoji-trigger {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-room-tile);
|
||||||
|
border: 1px solid var(--color-room-tile-border);
|
||||||
|
color: var(--color-room-text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chat-emoji-trigger:hover { color: var(--color-room-text-primary); }
|
||||||
|
.chat-emoji-trigger.is-open { color: var(--color-room-mic-on); border-color: var(--color-room-speaker-ring); }
|
||||||
|
.chat-emoji-trigger:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.chat-emoji-popover {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + var(--space-2));
|
||||||
|
left: 0;
|
||||||
|
z-index: 50;
|
||||||
|
width: 224px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-room-tile-border);
|
||||||
|
background: var(--color-room-surface-raised);
|
||||||
|
box-shadow: var(--shadow-room-panel);
|
||||||
|
}
|
||||||
|
.chat-emoji-option {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chat-emoji-option:hover { background: var(--color-room-tile); }
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.chat-panel {
|
.chat-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in New Issue
Block a user