feat(room): полноэкранный режим на мобильном с выезжающим тулбаром

Вход — кнопка «Экран» в тулбаре, теперь видна и на мобильном (по аналогии
с десктопом, а не спрятана в шторку настроек). В полноэкранном режиме
топбар и тулбар выходят из потока и лежат оверлеем поверх сцены: тап/клик
по сцене вне элементов управления показывает их, сами прячутся через
несколько секунд бездействия; на десктопе дополнительно — наведение мыши
в нижнюю полосу экрана. Работает одинаково на мобильном и десктопе.

Кнопка настроек устройств в тулбаре на мобильном подписана «Настройки»
вместо «Устройства». Раскладка кнопок мобильного тулбара, когда они не
помещаются в один ряд (7+, обычный случай с «Экраном» и «Очередью» у
организатора), стала равномерной сеткой на 4 колонки вместо переноса
«как получится» через flex-wrap.
This commit is contained in:
2026-08-04 00:00:45 +03:00
parent 06455f2401
commit ed6f9fff44
4 changed files with 191 additions and 19 deletions

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>