4 Commits

Author SHA1 Message Date
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
3847476798 chore(deploy): render-templates.sh умеет читать другой файл значений
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Путь к файлу со значениями был жёстко зашит как `<корень>/.env`. На
машине разработчика корневой `.env` держит боевые адреса
(LIVEKIT_NODE_IP/TURN_EXTERNAL_IP смотрят на прод), поэтому рендерить из
него конфиги для локального стенда нельзя, а подменить нечем — локальные
сессии дважды повторяли логику скрипта вручную через envsubst, что
означало расхождение с реальным рендером при первой же правке шаблонов.

Теперь источник значений задаётся переменной ENV_FILE:

    ENV_FILE=.env.local ./deploy/render-templates.sh

Поведение по умолчанию не меняется — тот же корневой `.env`. install.sh
свою переменную ENV_FILE не экспортирует, так что она сюда не протекает;
вызов из install.sh и рендер на сервере работают как раньше. Скрипт
дополнительно печатает, из какого файла взяты значения, и подсказывает
про ENV_FILE, если файл не найден.
2026-08-03 18:45:20 +03:00
11 changed files with 290 additions and 41 deletions

View File

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

View File

@@ -3,6 +3,41 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [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
Два дефекта мини-окна конференции (Document PiP), оба видны только в Chrome.

View File

@@ -1 +1 @@
0.0.25
0.0.26

View File

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

View File

@@ -9,16 +9,32 @@
# запускайте ПЕРЕД `docker compose up` (и после каждого изменения .env,
# влияющего на эти конфиги) — из корня репозитория:
# ./deploy/render-templates.sh
#
# Источник значений можно подменить переменной ENV_FILE:
# ENV_FILE=.env.local ./deploy/render-templates.sh
# Это нужно для локальных стендов: корневой `.env` рабочего чекаута обычно
# держит боевые значения (LIVEKIT_NODE_IP/TURN_EXTERNAL_IP смотрят на прод), и
# рендерить конфиги из него для локального запуска нельзя. Без этой переменной
# путь был жёстко зашит, и локальные сессии повторяли логику скрипта вручную
# через envsubst — расхождение с реальным рендером ждало своего часа.
# По умолчанию — как раньше, `<корень репозитория>/.env`, поэтому install.sh
# и сервер ничего не замечают (свою переменную ENV_FILE install.sh не
# экспортирует, так что она сюда не протекает).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/../.env"
# Относительный путь разрешаем от текущей директории вызова (`ENV_FILE=.env.local`
# из корня репозитория), абсолютный — как есть.
ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/../.env}"
if [ ! -f "$ENV_FILE" ]; then
echo "render-templates.sh: не найден $ENV_FILE — сначала запустите ./install.sh или скопируйте .env.example в .env" >&2
echo "render-templates.sh: другой файл значений можно задать так: ENV_FILE=.env.local $0" >&2
exit 1
fi
echo "[render] источник значений: $ENV_FILE"
# Читаем только нужные ключи через grep/cut (НЕ `source .env`) — .env содержит
# значения вроде `SMTP_FROM=VidConf <no-reply@vidconf.example>`, где `<` —
# валидный литерал для docker-compose/pydantic, но невалидный bash-синтаксис

View File

@@ -191,6 +191,14 @@ chmod 600 .env # секреты внутри — только root может
собирает образы, поднимает `postgres`/`redis`, применяет миграции Alembic +
seed, поднимает остальной стек (`up -d --wait`).
По умолчанию `render-templates.sh` читает корневой `.env`. Другой файл
значений задаётся переменной окружения — это нужно, когда рядом с рабочим
`.env` (боевые адреса) поднимается локальный стенд:
```bash
ENV_FILE=.env.local ./deploy/render-templates.sh
```
**Не запускайте `docker compose` вручную без `--env-file .env`** — без него
compose не подхватывает корневой `.env` (файл на уровень выше
`deploy/docker-compose.yml`) и подставляет небезопасные дефолты из самого

View File

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

View File

@@ -17,6 +17,7 @@ import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat'
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
import { HandQueueMenu } from '@/components/room/HandQueueMenu'
@@ -68,6 +69,8 @@ interface RoomToolbarProps extends StageViewProps {
onLowerHand: () => void
/** Опустить ЧУЖУЮ руку по identity — только организатору (панель очереди, `HandQueueMenu`). */
onLowerHandById: (identity: string) => void
/** Тулбар в оверлее полноэкранного режима — см. докстринг `RoomTopbar.overlayVisible`, тот же механизм. */
overlayVisible?: boolean
}
/**
@@ -77,16 +80,20 @@ interface RoomToolbarProps extends StageViewProps {
* панели чата, стилизованные по design/mockups/room.html.
*
* Кнопка «Вид» (режимы показа и скрытие остальных) рендерится ТОЛЬКО на
* широком экране — условным рендерингом, а не скрытием через CSS: тулбар на
* мобильном и так ужат до пяти «безусловных» кнопок (демонстрация/
* полноэкранный режим/мини-плеер скрыты на узком экране через CSS, см.
* `styles/room.css`), а те же настройки там доступны секцией «Вид» в шторке
* настроек (`DeviceSettingsDialog`). «Рука» — сознательное исключение из этой
* широком экране — условным рендерингом, а не скрытием через CSS: демонстрация
* и мини-плеер скрыты на узком экране через CSS (см. `styles/room.css`), а те
* же настройки показа сцены доступны секцией «Вид» в шторке настроек
* (`DeviceSettingsDialog`). «Рука» — сознательное исключение из этой
* экономии: поднять руку посреди разговора — действие со временем жизни в
* секунды, прятать его в шторку настроек означало бы делать его практически
* недоступным с телефона. «Очередь» показывается только организатору —
* встречается редко, но по той же причине оставлена в тулбаре, а не в
* шторке: организатору с телефона тоже нужно видеть очередь сразу.
* Полноэкранный режим на мобильном ОСТАЁТСЯ в тулбаре (не спрятан в шторку,
* как демонстрация/мини-плеер) — по решению оператора вход в него должен
* быть по аналогии с десктопом; если из-за этого кнопки не помещаются в один
* ряд, `mobileButtonCount`/`.tb-wrap-grid` ниже раскладывают их равномерной
* сеткой, а не как получится через `flex-wrap`.
*/
export function RoomToolbar({
chatVisible,
@@ -108,11 +115,29 @@ export function RoomToolbar({
onLayoutModeChange,
hideOthers,
onHideOthersChange,
overlayVisible,
}: RoomToolbarProps) {
const toast = useToast()
const isCompact = useIsCompactViewport()
const isOrganizer = useIsOrganizer()
const { localParticipant } = useLocalParticipant()
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).
const mobileButtonCount =
3 /* микрофон, камера, рука */ +
(isOrganizer ? 1 : 0) /* очередь */ +
1 /* настройки */ +
(fullscreenSupported ? 1 : 0) +
(chatVisible ? 1 : 0) +
1 /* выйти */
const mic = useTrackToggle({ source: Track.Source.Microphone })
const camera = useTrackToggle({ source: Track.Source.Camera })
const screenShare = useTrackToggle({
@@ -129,7 +154,9 @@ export function RoomToolbar({
})
return (
<footer className="room-toolbar">
<footer
className={`room-toolbar${overlayVisible ? ' is-visible' : ''}${mobileButtonCount >= 7 ? ' tb-wrap-grid' : ''}`}
>
<button
type="button"
{...mic.buttonProps}
@@ -214,7 +241,10 @@ export function RoomToolbar({
<span className="icon-shell">
<Settings className="lucide" aria-hidden="true" />
</span>
<span className="label">Устройства</span>
{/* На мобильном подпись шире по смыслу («Настройки»): там же в шторке
секция «Вид», а отдельной кнопки под неё в тулбаре нет. На десктопе
панель — только настройки устройств, подпись это отражает. */}
<span className="label">{isCompact ? 'Настройки' : 'Устройства'}</span>
</button>
{fullscreenSupported && (

View File

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

View File

@@ -22,6 +22,12 @@ import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
import { buildPublishDefaults } from '@/lib/publishQualityCap'
import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode'
/** Сколько мс держать топбар/тулбар видимыми в полноэкранном режиме без взаимодействия, прежде чем спрятать их снова. */
const FULLSCREEN_CONTROLS_AUTO_HIDE_MS = 4000
/** Полоса у нижнего края экрана (px) — наведение мыши в неё в полноэкранном режиме на десктопе показывает тулбар без клика. */
const FULLSCREEN_FOOTER_HOVER_ZONE_PX = 72
interface RoomJoinState {
livekitUrl: string
token: string
@@ -175,6 +181,73 @@ export function RoomPage() {
const pip = useRoomPiP(roomRootRef)
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`: переключатели — в тулбаре и в
// шторке настроек, а сцена их только читает (общий предок).
//
@@ -281,7 +354,7 @@ export function RoomPage() {
}
return (
<div data-theme="room" ref={roomRootRef}>
<div data-theme="room" ref={roomRootRef} className={fullscreen.active ? 'room-fullscreen-overlay' : undefined}>
<LiveKitRoom
serverUrl={joinState.livekitUrl}
token={joinState.token}
@@ -307,8 +380,13 @@ export function RoomPage() {
непрерывно, весь цикл «открыл мини-окно → вернул» их не касается. */}
<RoomAudioRenderer />
<div data-lk-theme="default" className="room-shell">
<RoomTopbar title={joinState.title ?? null} slug={slug} number={joinState.number} />
<div className="room-main">
<RoomTopbar
title={joinState.title ?? null}
slug={slug}
number={joinState.number}
overlayVisible={fullscreenControlsVisible}
/>
<div className="room-main" onClick={handleStageAreaClick}>
{pip.mode === 'document' ? (
// Сцена сейчас рисуется в PiP-окне (через createPortal ниже) —
// основное окно вместо неё показывает заглушку с возвратом.
@@ -365,6 +443,7 @@ export function RoomPage() {
onLayoutModeChange={handleLayoutModeChange}
hideOthers={hideOthers}
onHideOthersChange={setHideOthers}
overlayVisible={fullscreenControlsVisible}
/>
</div>
{settingsOpen && (

View File

@@ -803,10 +803,16 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
* Тот же диалог (`DeviceSettingsDialog`), что модалка выше, — только контейнер
* снизу вместо центра экрана: `.room-sheet-overlay`/`.room-sheet-panel` вместо
* `.room-modal-overlay`/`.room-modal-panel`, разметка полей (`.room-field` и
* ниже) общая. Ручка `.room-sheet-handle` — свайп вниз для закрытия (JS считает
* смещение пальца и сам решает, закрывать или вернуть панель на место;
* `transition` тут — только пружина возврата, во время самого драга
* компонент подставляет инлайновый `transition: none`).
* ниже) общая. Свайп вниз для закрытия JS на самой панели (не на ручке —
* см. докстринг `DeviceSettingsDialog`) считает смещение пальца и сам решает,
* закрывать или вернуть панель на место; `transition` тут — только пружина
* возврата, во время самого драга компонент подставляет инлайновый
* `transition: none`. `overscroll-behavior-y: contain` — чтобы при свайпе
* вниз от самого верха списка устройств iOS/Chrome не показывали заодно
* ещё и нативный эффект растяжения скролла поверх нашей анимации панели.
* Ручка `.room-sheet-handle` — теперь чисто визуальная подсказка (сама
* панель ловит жест где угодно), `touch-action: none` на ней оставлен —
* безвредно и снимает системные жесты с этой узкой полоски.
*/
.room-sheet-overlay {
position: fixed;
@@ -827,6 +833,7 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
width: 100%;
max-height: 80vh;
overflow-y: auto;
overscroll-behavior-y: contain;
transition: transform 160ms ease-out;
}
.room-sheet-handle {
@@ -1081,22 +1088,79 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
в самой конференции, а вот чип «№ … / Пригласить» с телефона нужен чаще
(скопировать ссылку и позвать участника), поэтому он остаётся.
Тулбар: демонстрация экрана, полноэкранный режим и мини-окно с телефона
практически не нужны, а все 8 кнопок в ширину экрана физически не
помещаются — скрываем их и уплотняем оставшиеся пять
(микрофон/камера/устройства/чат/выход). `flex-wrap` + `min-width: 0` —
страховка на совсем узких экранах, чтобы футер ни при каких подписях не
вылезал за ширину окна. */
Тулбар: демонстрация экрана и мини-окно с телефона практически не нужны —
скрыты. Полноэкранный режим ОСТАЁТСЯ виден, как на десктопе (решение
оператора 03.08: вход в него — по аналогии с десктопным приложением, не
спрятан в шторку настроек) — вместе с «Рукой» (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) {
.room-topbar { padding: var(--space-3) var(--space-4); justify-content: flex-end; }
.room-title-block { display: none; }
.tb-btn--screenshare,
.tb-btn--fullscreen,
.tb-btn--pip { display: none; }
.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 .icon-shell { width: 40px; height: 40px; }
.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); }