feat(room): полноэкранный режим на мобильном с выезжающим тулбаром
Вход — кнопка «Экран» в тулбаре, теперь видна и на мобильном (по аналогии с десктопом, а не спрятана в шторку настроек). В полноэкранном режиме топбар и тулбар выходят из потока и лежат оверлеем поверх сцены: тап/клик по сцене вне элементов управления показывает их, сами прячутся через несколько секунд бездействия; на десктопе дополнительно — наведение мыши в нижнюю полосу экрана. Работает одинаково на мобильном и десктопе. Кнопка настроек устройств в тулбаре на мобильном подписана «Настройки» вместо «Устройства». Раскладка кнопок мобильного тулбара, когда они не помещаются в один ряд (7+, обычный случай с «Экраном» и «Очередью» у организатора), стала равномерной сеткой на 4 колонки вместо переноса «как получится» через flex-wrap.
This commit is contained in:
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -1088,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) и опциональными
|
||||
«Очередью» (только организатору)/«Чатом» на мобильном может набраться до
|
||||
7–8 кнопок разом, в один ряд по 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); }
|
||||
|
||||
Reference in New Issue
Block a user