feat(room): выбор режима показа участников и скрытие остальных
Раскладка сцены больше не выбирается автоматически — пользователь выбирает
один из трёх режимов:
- «Стандарт» — как раньше: крупная плитка плюс карусель остальных сбоку,
кого показать крупно, по-прежнему решает pickStageFocus;
- «Плитки» — все участники равными плитками, без выделенного крупного;
- «Живые плитки» — сетка только из тех, у кого включена камера, остальные
в карусели сбоку; если камеру не включил никто, сетка была бы пустой —
режим вырождается в «Плитки».
Демонстрация экрана перебивает выбранный режим: пока в комнате есть активный
шэр, сцена ведёт себя как «Стандарт» (смысл плиточных режимов — равноправие
участников, а демонстрация неравноправна по определению). Режим при этом
живёт в состоянии RoomPage, поэтому по завершении шэра вид сам возвращается
к выбранному.
Скрытие остальных убирает карусель, основная область занимает сцену целиком.
Скрыть можно из меню «Вид», из шторки настроек на мобильном и кнопкой прямо
над колонкой миниатюр; вернуть — кнопкой «Показать остальных (N)» на сцене,
которая видна всё время, пока кто-то скрыт. В режиме «Плитки» скрывать
нечего, переключатель там заблокирован с пояснением.
Переключатель на широком экране — кнопка «Вид» с поповером; на мобильном её
нет (тулбар там и так ужат до пяти кнопок), те же настройки идут секцией в
шторке. Видимость решается условным рендерингом в React, а не новым
CSS-правилом поверх медиазапроса — в room.css за это уже была битва
специфичности.
Режим сохраняется между заходами в комнату (localStorage, своим модулем —
LocalUserChoices у LiveKit фиксированной структуры, поля под раскладку там
нет). Скрытие не сохраняется: разовое действие по ходу разговора, войти в
новую конференцию без половины участников — сюрприз, а не удобство.
Сетка собрана своим StageGrid поверх тех же публичных хуков, что использует
GridLayout: библиотечный компонент не пробрасывает gridLayouts, а её набор
раскладок требует 560px уже для 2x2 — на телефоне это две плитки на страницу.
Свой набор даёт 2x2 от 360px и портретную 2x3. Индикатор страниц тоже свой:
PaginationControl/PaginationIndicator из пакета не экспортируются.
This commit is contained in:
@@ -1,16 +1,16 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
|
||||
import { useModalDismiss } from '@/hooks/useModalDismiss'
|
||||
import { StageViewOptions, type StageViewProps } from '@/components/room/StageViewOptions'
|
||||
import { isAudioOutputSelectable, saveAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
||||
|
||||
interface DeviceSettingsDialogProps {
|
||||
interface DeviceSettingsDialogProps extends StageViewProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** Совпадает с мобильным брейкпоинтом комнаты (`room.css`, `max-width: 600px`) — ниже него панель рендерится шторкой снизу вместо модалки. */
|
||||
const COMPACT_VIEWPORT_QUERY = '(max-width: 600px)'
|
||||
/** Свайп ручки шторки вниз дальше этого порога (px) закрывает панель, меньше — она возвращается на место. */
|
||||
const SHEET_DISMISS_THRESHOLD_PX = 80
|
||||
|
||||
@@ -19,20 +19,6 @@ function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string):
|
||||
return device.label || `${fallback} ${index + 1}`
|
||||
}
|
||||
|
||||
/** Живое отслеживание мобильной ширины — та же схема, что системная тема в `useTheme.ts` (matchMedia + change-листенер). */
|
||||
function useIsCompactViewport(): boolean {
|
||||
const [isCompact, setIsCompact] = useState(() => window.matchMedia(COMPACT_VIEWPORT_QUERY).matches)
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(COMPACT_VIEWPORT_QUERY)
|
||||
const handleChange = (event: MediaQueryListEvent) => setIsCompact(event.matches)
|
||||
media.addEventListener('change', handleChange)
|
||||
return () => media.removeEventListener('change', handleChange)
|
||||
}, [])
|
||||
|
||||
return isCompact
|
||||
}
|
||||
|
||||
/**
|
||||
* Панель «Настройки устройств» — три селекта на хуках `@livekit/components-react`:
|
||||
* список устройств и переключение целиком в `useMediaDeviceSelect` (сама
|
||||
@@ -47,12 +33,23 @@ function useIsCompactViewport(): boolean {
|
||||
* модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз за ручку.
|
||||
* Десктоп не меняется.
|
||||
*
|
||||
* Там же, и только там, первой секцией идёт «Вид» (режим показа участников и
|
||||
* скрытие остальных): на мобильном тулбар ужат до пяти кнопок, отдельной
|
||||
* кнопки «Вид» там нет — см. `RoomToolbar`. Поэтому и заголовок панели на
|
||||
* мобильном шире по смыслу («Настройки», а не «Настройки устройств»).
|
||||
*
|
||||
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без явно
|
||||
* переданного `room` берёт активную комнату из `RoomContext` — вне контекста
|
||||
* он создал бы отдельный, ни с чем не связанный `Room()` и переключал бы
|
||||
* устройство «в никуда».
|
||||
*/
|
||||
export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
|
||||
export function DeviceSettingsDialog({
|
||||
onClose,
|
||||
layoutMode,
|
||||
onLayoutModeChange,
|
||||
hideOthers,
|
||||
onHideOthersChange,
|
||||
}: DeviceSettingsDialogProps) {
|
||||
const toast = useToast()
|
||||
const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices()
|
||||
const mic = useMediaDeviceSelect({ kind: 'audioinput' })
|
||||
@@ -143,12 +140,26 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
|
||||
)}
|
||||
|
||||
<div className="room-modal-head">
|
||||
<h2 id="device-settings-title">Настройки устройств</h2>
|
||||
<h2 id="device-settings-title">{isCompact ? 'Настройки' : 'Настройки устройств'}</h2>
|
||||
<button type="button" className="room-modal-close" aria-label="Закрыть" onClick={onClose}>
|
||||
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Обёртка НЕ `.room-field`: там `label { display: block }` со
|
||||
специфичностью выше, чем у `.stage-view-mode` — пункты режимов
|
||||
рассыпались бы в столбик (кружок отдельно, текст под ним). */}
|
||||
{isCompact && (
|
||||
<div className="stage-view-section">
|
||||
<StageViewOptions
|
||||
layoutMode={layoutMode}
|
||||
onLayoutModeChange={onLayoutModeChange}
|
||||
hideOthers={hideOthers}
|
||||
onHideOthersChange={onHideOthersChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="room-field">
|
||||
<label htmlFor="device-settings-mic">Микрофон</label>
|
||||
<select
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { EyeOff, Users } from 'lucide-react'
|
||||
import { Track, type Participant } from 'livekit-client'
|
||||
import {
|
||||
CarouselLayout,
|
||||
FocusLayoutContainer,
|
||||
GridLayout,
|
||||
RoomAudioRenderer,
|
||||
isTrackReference,
|
||||
useRoomContext,
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
type TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-react'
|
||||
import { RoomParticipantTile } from '@/components/room/RoomParticipantTile'
|
||||
import { StageGrid } from '@/components/room/StageGrid'
|
||||
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
|
||||
import { pickStageFocus, stageTrackKey } from '@/components/room/stageFocus'
|
||||
import type { StageLayoutMode } from '@/lib/stageLayoutMode'
|
||||
|
||||
/**
|
||||
* Стабильная (модульная, не пересоздаётся на каждый рендер) ссылка на
|
||||
@@ -80,10 +83,46 @@ function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant
|
||||
return holdMs <= 0 ? speakers : steady
|
||||
}
|
||||
|
||||
/**
|
||||
* Есть ли у трека ЖИВОЕ видео: это настоящий трек (не плейсхолдер выключенной
|
||||
* камеры) и он не в мьюте. По этому признаку режим «Живые плитки» делит
|
||||
* участников на сетку и карусель, а `pickStageFocus` отдаёт предпочтение
|
||||
* говорящему с картинкой.
|
||||
*/
|
||||
function hasLiveVideo(track: TrackReferenceOrPlaceholder): boolean {
|
||||
return isTrackReference(track) && !track.publication.isMuted
|
||||
}
|
||||
|
||||
/**
|
||||
* Основная сцена конференции: превью остальных участников + крупная плитка
|
||||
* активного спикера (FocusLayoutContainer + CarouselLayout при нескольких
|
||||
* участниках, GridLayout при одном/двух).
|
||||
* участниках, равномерная сетка при одном/двух).
|
||||
*
|
||||
* РЕЖИМЫ ПОКАЗА (`layoutMode`, выбор пользователя, см. `lib/stageLayoutMode.ts`):
|
||||
* - `standard` — как было до 0.0.11: крупная плитка (кого показать, решает
|
||||
* `pickStageFocus`) + карусель остальных сбоку;
|
||||
* - `tiles` — все участники равными плитками (`StageGrid`), без фокуса;
|
||||
* - `live-tiles` — сетка только из участников с включённой камерой,
|
||||
* остальные (плейсхолдер/мьют, см. `hasLiveVideo`) — в карусели сбоку.
|
||||
* Если камеру не включил никто, сетка была бы пустой, поэтому в этом
|
||||
* случае показываем всех — режим вырождается в `tiles`.
|
||||
*
|
||||
* ДЕМОНСТРАЦИЯ ЭКРАНА перебивает выбранный режим: пока в комнате есть хоть
|
||||
* одна активная демонстрация, сцена ведёт себя как `standard` (демонстрация
|
||||
* крупно, все камеры в карусели). Это сознательно: смысл плиточных режимов —
|
||||
* равноправие участников, а демонстрация по определению неравноправна, ради
|
||||
* неё её и включают. Режим при этом не меняется — он живёт в состоянии
|
||||
* `RoomPage`, поэтому по завершении демонстрации сцена сама возвращается к
|
||||
* выбранному пользователем виду.
|
||||
*
|
||||
* СКРЫТИЕ ОСТАЛЬНЫХ (`hideOthers`): карусель не рендерится вовсе, основная
|
||||
* область занимает всю сцену. Скрыть можно тремя способами — меню «Вид»,
|
||||
* шторка настроек на мобильном и кнопка «Скрыть» прямо над колонкой миниатюр
|
||||
* (`onHideOthers`, только широкий экран). Вернуть — кнопкой «Показать
|
||||
* остальных» на сцене (`onShowOthers`): она видна всегда, пока кто-то скрыт,
|
||||
* чтобы участники не «потерялись» без понятного способа их вернуть. В режиме
|
||||
* `tiles` скрывать нечего (карусели нет), переключатель там заблокирован —
|
||||
* см. `StageViewOptions`.
|
||||
*
|
||||
* Раскладка — вертикальная колонка миниатюр слева от основной сцены (не
|
||||
* горизонтальная лента, см. design/mockups/room.html после правки: узкая
|
||||
@@ -111,7 +150,8 @@ function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant
|
||||
*
|
||||
* Проп `variant="pip"` — для рендера
|
||||
* ВНУТРИ мини-плеера (Document PiP, портал в `RoomPage.tsx`). В этом режиме
|
||||
* показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида.
|
||||
* показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида;
|
||||
* режимы показа и скрытие остальных на мини-плеер не влияют вовсе.
|
||||
*
|
||||
* Фокус следует за активным спикером в ОБОИХ вариантах (`followSpeaker` у
|
||||
* `pickStageFocus`; для основного окна — с 0.0.6, задача 3.2), но по-разному:
|
||||
@@ -120,8 +160,25 @@ function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant
|
||||
* фокуса живую демонстрацию экрана (`holdScreenShare`) и умеет закрепление
|
||||
* участника (`pinnedKey`, задача 3.1) — кнопка-булавка на плитке.
|
||||
*/
|
||||
export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
|
||||
export function RoomStage({
|
||||
variant = 'full',
|
||||
layoutMode = 'standard',
|
||||
hideOthers = false,
|
||||
onShowOthers,
|
||||
onHideOthers,
|
||||
}: {
|
||||
variant?: 'full' | 'pip'
|
||||
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
|
||||
layoutMode?: StageLayoutMode
|
||||
/** Скрыть карусель остальных участников; игнорируется при `variant="pip"`. */
|
||||
hideOthers?: boolean
|
||||
/** Вернуть скрытых участников — кнопка на сцене (см. докстринг выше). */
|
||||
onShowOthers?: () => void
|
||||
/** Скрыть остальных — кнопка над каруселью (только широкий экран). */
|
||||
onHideOthers?: () => void
|
||||
}) {
|
||||
const room = useRoomContext()
|
||||
const isCompact = useIsCompactViewport()
|
||||
const tracks = useTracks(STAGE_TRACK_SOURCES, {
|
||||
onlySubscribed: false,
|
||||
})
|
||||
@@ -211,10 +268,7 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
|
||||
speakingCameraKeys,
|
||||
// Приоритет «говорящий с камерой выше говорящего без камеры» — только
|
||||
// основному окну: PiP по договорённости ведёт себя ровно как раньше.
|
||||
cameraKeysWithVideo:
|
||||
variant === 'pip'
|
||||
? []
|
||||
: cameraTracks.filter((t) => isTrackReference(t) && !t.publication.isMuted).map(stageTrackKey),
|
||||
cameraKeysWithVideo: variant === 'pip' ? [] : cameraTracks.filter(hasLiveVideo).map(stageTrackKey),
|
||||
prevKeys,
|
||||
prevFocusKey: focusKey,
|
||||
pinnedKey: pinnedAlive ? pinnedKey : null,
|
||||
@@ -273,29 +327,94 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Демонстрация экрана перебивает выбранный режим (обоснование — в докстринге).
|
||||
const effectiveMode: StageLayoutMode = hasScreenShare ? 'standard' : layoutMode
|
||||
const liveCameraTracks = cameraTracks.filter(hasLiveVideo)
|
||||
|
||||
// Кого показываем сбоку в карусели. В «плитках» — никого (все равноправны),
|
||||
// в «живых плитках» — тех, у кого камера выключена (а если камеры нет ни у
|
||||
// кого, карусель пуста: все ушли в сетку), в «стандарте» — всех, кроме
|
||||
// плитки в фокусе.
|
||||
const sideTracks: TrackReferenceOrPlaceholder[] =
|
||||
effectiveMode === 'tiles'
|
||||
? []
|
||||
: effectiveMode === 'live-tiles'
|
||||
? liveCameraTracks.length > 0
|
||||
? cameraTracks.filter((t) => !hasLiveVideo(t))
|
||||
: []
|
||||
: carouselTracks
|
||||
const showCarousel = !hideOthers && sideTracks.length > 0
|
||||
// Закрепление имеет смысл только там, где есть «крупная плитка» —
|
||||
// в плиточных режимах фокуса нет, поэтому и булавки на плитках нет.
|
||||
const pinProps =
|
||||
effectiveMode === 'standard' ? { pinnedKey, onTogglePin: handleTogglePin } : {}
|
||||
|
||||
function renderMain(): ReactNode {
|
||||
if (effectiveMode === 'tiles') {
|
||||
return (
|
||||
<StageGrid tracks={tracks}>
|
||||
<RoomParticipantTile />
|
||||
</StageGrid>
|
||||
)
|
||||
}
|
||||
if (effectiveMode === 'live-tiles') {
|
||||
return (
|
||||
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks}>
|
||||
<RoomParticipantTile />
|
||||
</StageGrid>
|
||||
)
|
||||
}
|
||||
// «Стандарт» с единственным участником (карусель пуста и скрывать нечего) —
|
||||
// прежнее поведение: равномерная сетка на всю сцену, а не фокус-плитка.
|
||||
if (sideTracks.length === 0 && !hideOthers) {
|
||||
return (
|
||||
<StageGrid tracks={tracks}>
|
||||
<RoomParticipantTile {...pinProps} />
|
||||
</StageGrid>
|
||||
)
|
||||
}
|
||||
// FocusLayout оригинала — лёгкая обёртка ровно над ParticipantTile
|
||||
// (см. её исходник), поэтому вместо неё используем свою обёртку
|
||||
// напрямую с тем же trackRef (аватар в фокус-плитке).
|
||||
return (
|
||||
focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} {...pinProps} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="stage">
|
||||
{!hasScreenShare && (!focusTrack || carouselTracks.length === 0) ? (
|
||||
<GridLayout tracks={tracks} className="stage-tiles">
|
||||
<RoomParticipantTile pinnedKey={pinnedKey} onTogglePin={handleTogglePin} />
|
||||
</GridLayout>
|
||||
) : (
|
||||
{showCarousel ? (
|
||||
<FocusLayoutContainer className="stage-tiles">
|
||||
<CarouselLayout tracks={carouselTracks}>
|
||||
<RoomParticipantTile pinnedKey={pinnedKey} onTogglePin={handleTogglePin} />
|
||||
</CarouselLayout>
|
||||
{/* FocusLayout оригинала — лёгкая обёртка ровно над ParticipantTile
|
||||
(см. её исходник), поэтому вместо неё используем свою обёртку
|
||||
напрямую с тем же trackRef (аватар в фокус-плитке). */}
|
||||
{focusTrack && (
|
||||
<RoomParticipantTile
|
||||
trackRef={focusTrack}
|
||||
onStopSharing={handleStopSharing}
|
||||
pinnedKey={pinnedKey}
|
||||
onTogglePin={handleTogglePin}
|
||||
/>
|
||||
)}
|
||||
{/* Обёртка колонки миниатюр: сама карусель не может быть прямым
|
||||
ребёнком грида, потому что над ней стоит кнопка «Скрыть». Из-за
|
||||
этого мобильный `.lk-carousel{order:1}` библиотеки перестаёт
|
||||
действовать — порядок переносим на `.stage-side` (см. room.css). */}
|
||||
<div className="stage-side">
|
||||
{/* Быстрый способ убрать карусель, не открывая меню «Вид». Только
|
||||
на широком экране: на мобильном колонка превращается в узкую
|
||||
полосу снизу, где кнопке не место, — там переключатель живёт в
|
||||
шторке настроек. Вернуть участников можно кнопкой на сцене. */}
|
||||
{!isCompact && onHideOthers && (
|
||||
<button type="button" className="stage-side-hide" onClick={onHideOthers}>
|
||||
<EyeOff className="lucide" aria-hidden="true" />
|
||||
<span>Скрыть</span>
|
||||
</button>
|
||||
)}
|
||||
<CarouselLayout tracks={sideTracks}>
|
||||
<RoomParticipantTile {...pinProps} />
|
||||
</CarouselLayout>
|
||||
</div>
|
||||
{renderMain()}
|
||||
</FocusLayoutContainer>
|
||||
) : (
|
||||
// Карусели нет — основная область занимает сцену целиком.
|
||||
<div className="stage-tiles stage-main-only">{renderMain()}</div>
|
||||
)}
|
||||
{hideOthers && sideTracks.length > 0 && (
|
||||
<button type="button" className="stage-show-others" onClick={onShowOthers}>
|
||||
<Users className="lucide" aria-hidden="true" />
|
||||
<span>Показать остальных ({sideTracks.length})</span>
|
||||
</button>
|
||||
)}
|
||||
<RoomAudioRenderer />
|
||||
</section>
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
|
||||
import { DisconnectButton, useTrackToggle } from '@livekit/components-react'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
|
||||
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
|
||||
|
||||
/**
|
||||
* Опции захвата демонстрации экрана: `audio: true` — звук
|
||||
@@ -36,7 +38,7 @@ const SCREEN_SHARE_CAPTURE_OPTIONS: ScreenShareCaptureOptions = {
|
||||
systemAudio: 'include',
|
||||
}
|
||||
|
||||
interface RoomToolbarProps {
|
||||
interface RoomToolbarProps extends StageViewProps {
|
||||
/** Показывать ли кнопку чата — `JoinOut.chat_enabled` И чат не помечен недоступным (close-код 4404). */
|
||||
chatVisible: boolean
|
||||
chatOpen: boolean
|
||||
@@ -56,10 +58,15 @@ interface RoomToolbarProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Нижний тулбар комнаты: микрофон/камера/демонстрация экрана/
|
||||
* Нижний тулбар комнаты: микрофон/камера/демонстрация экрана/вид сцены/
|
||||
* настройки устройств/полноэкранный режим/мини-плеер/чат/выход — собственные
|
||||
* кнопки на хуках LiveKit (useTrackToggle/DisconnectButton) и панели чата,
|
||||
* стилизованные по design/mockups/room.html.
|
||||
*
|
||||
* Кнопка «Вид» (режимы показа и скрытие остальных) рендерится ТОЛЬКО на
|
||||
* широком экране — условным рендерингом, а не скрытием через CSS: тулбар на
|
||||
* мобильном и так ужат до пяти кнопок, а те же настройки там доступны секцией
|
||||
* «Вид» в шторке настроек (`DeviceSettingsDialog`).
|
||||
*/
|
||||
export function RoomToolbar({
|
||||
chatVisible,
|
||||
@@ -73,8 +80,13 @@ export function RoomToolbar({
|
||||
pipSupported,
|
||||
pipActive,
|
||||
onTogglePiP,
|
||||
layoutMode,
|
||||
onLayoutModeChange,
|
||||
hideOthers,
|
||||
onHideOthersChange,
|
||||
}: RoomToolbarProps) {
|
||||
const toast = useToast()
|
||||
const isCompact = useIsCompactViewport()
|
||||
const mic = useTrackToggle({ source: Track.Source.Microphone })
|
||||
const camera = useTrackToggle({ source: Track.Source.Camera })
|
||||
const screenShare = useTrackToggle({
|
||||
@@ -140,6 +152,15 @@ export function RoomToolbar({
|
||||
<span className="label">Демонстрация</span>
|
||||
</button>
|
||||
|
||||
{!isCompact && (
|
||||
<StageViewMenu
|
||||
layoutMode={layoutMode}
|
||||
onLayoutModeChange={onLayoutModeChange}
|
||||
hideOthers={hideOthers}
|
||||
onHideOthersChange={onHideOthersChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="tb-btn"
|
||||
|
||||
113
frontend/src/components/room/StageGrid.tsx
Normal file
113
frontend/src/components/room/StageGrid.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useRef, type ReactNode, type RefObject } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
TrackLoop,
|
||||
useGridLayout,
|
||||
usePagination,
|
||||
useSwipe,
|
||||
type GridLayoutDefinition,
|
||||
type TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-react'
|
||||
|
||||
/**
|
||||
* Раскладки равномерной сетки — расширенный набор поверх библиотечного
|
||||
* `GRID_LAYOUTS` (@livekit/components-core, `helper/grid-layouts.ts`).
|
||||
*
|
||||
* Зачем свой набор. Библиотечный требует `minWidth: 560` уже для сетки 2×2,
|
||||
* поэтому на телефоне (~390px) максимальной раскладкой оказывается `1×2` — две
|
||||
* плитки на страницу, и десяток участников превращается в пять страниц
|
||||
* листания. Здесь добавлены два портретных варианта под узкий экран (2×2 и
|
||||
* 2×3): плитка шириной ~190px с именем и аватаром читается нормально, а
|
||||
* страниц становится втрое меньше.
|
||||
*
|
||||
* Как это читает `selectGridLayout` (см. её исходник): раскладки сортируются
|
||||
* по `maxTiles` (при равенстве — по `minWidth`), берётся первая, вмещающая всех
|
||||
* участников, при этом раскладка пропускается, если ДАЛЬШЕ есть другая с тем же
|
||||
* `maxTiles` и подходящей ориентацией контейнера. Отсюда пары «портрет/
|
||||
* ландшафт» на одно и то же число плиток: 1×2 / 2×1 и 2×3 / 3×2 — на широком
|
||||
* экране выигрывает горизонтальный вариант, на узком вертикальный. Если
|
||||
* контейнер не дотягивает до `minWidth`/`minHeight`, функция рекурсивно
|
||||
* спускается к меньшей раскладке, а лишние участники уходят на следующую
|
||||
* страницу (`usePagination`).
|
||||
*/
|
||||
const STAGE_GRID_LAYOUTS: GridLayoutDefinition[] = [
|
||||
{ columns: 1, rows: 1 },
|
||||
{ columns: 1, rows: 2, orientation: 'portrait' },
|
||||
{ columns: 2, rows: 1, orientation: 'landscape' },
|
||||
// 4 плитки помещаются и на телефоне: 390px / 2 ≈ 190px на плитку.
|
||||
{ columns: 2, rows: 2, minWidth: 360 },
|
||||
{ columns: 2, rows: 3, minWidth: 360, minHeight: 480, orientation: 'portrait' },
|
||||
{ columns: 3, rows: 2, minWidth: 700, orientation: 'landscape' },
|
||||
{ columns: 3, rows: 3, minWidth: 700 },
|
||||
{ columns: 4, rows: 4, minWidth: 960 },
|
||||
{ columns: 5, rows: 5, minWidth: 1100 },
|
||||
]
|
||||
|
||||
interface StageGridProps {
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
/** Шаблон плитки — рендерится для каждого трека страницы (как у `GridLayout`, через `TrackLoop`). */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Равномерная сетка плиток с пагинацией — собственная сборка вместо
|
||||
* библиотечного `GridLayout`.
|
||||
*
|
||||
* Причина ровно одна: `GridLayout` не пробрасывает `gridLayouts` в
|
||||
* `useGridLayout`, то есть набор раскладок у неё жёстко зашит (см.
|
||||
* `STAGE_GRID_LAYOUTS` выше). Всё остальное — те же публичные хуки, что
|
||||
* использует оригинал (`useGridLayout` + `usePagination` + `useSwipe` +
|
||||
* `TrackLoop`) и тот же класс `.lk-grid-layout`, поэтому и стили библиотеки, и
|
||||
* `useVisualStableUpdate` внутри пагинации работают как раньше. Отличается
|
||||
* только элемент управления страницами: библиотечные `PaginationIndicator`/
|
||||
* `PaginationControl` из пакета не экспортируются, вместо них — свой
|
||||
* `.stage-grid-pages` (кнопки со стрелками + счётчик, доступен и мышью, и с
|
||||
* клавиатуры; на тач-экране страницы листаются ещё и свайпом).
|
||||
*/
|
||||
export function StageGrid({ tracks, children }: StageGridProps) {
|
||||
const gridEl = useRef<HTMLDivElement | null>(null)
|
||||
// Хуки библиотеки объявлены с `RefObject<HTMLDivElement>` (типы React 18, где
|
||||
// `current` был readonly и тип вёл себя ковариантно). В типах React 19
|
||||
// `current` мутабельный, поэтому `RefObject<HTMLDivElement | null>` в такой
|
||||
// параметр уже не присваивается — приведение безопасно: оба хука только
|
||||
// читают `.current` (ResizeObserver и слушатели touch-событий).
|
||||
const gridRef = gridEl as RefObject<HTMLDivElement>
|
||||
const { layout } = useGridLayout(gridRef, tracks.length, { gridLayouts: STAGE_GRID_LAYOUTS })
|
||||
const pagination = usePagination(layout.maxTiles, tracks)
|
||||
|
||||
useSwipe(gridRef, {
|
||||
onLeftSwipe: pagination.nextPage,
|
||||
onRightSwipe: pagination.prevPage,
|
||||
})
|
||||
|
||||
const hasPages = pagination.totalPageCount > 1
|
||||
|
||||
return (
|
||||
<div ref={gridEl} className="lk-grid-layout stage-grid" data-lk-pagination={hasPages}>
|
||||
<TrackLoop tracks={pagination.tracks}>{children}</TrackLoop>
|
||||
{hasPages && (
|
||||
<div className="stage-grid-pages">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Предыдущая страница участников"
|
||||
disabled={pagination.currentPage <= 1}
|
||||
onClick={pagination.prevPage}
|
||||
>
|
||||
<ChevronLeft className="lucide" aria-hidden="true" />
|
||||
</button>
|
||||
<span aria-live="polite">
|
||||
{pagination.currentPage} / {pagination.totalPageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Следующая страница участников"
|
||||
disabled={pagination.currentPage >= pagination.totalPageCount}
|
||||
onClick={pagination.nextPage}
|
||||
>
|
||||
<ChevronRight className="lucide" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
frontend/src/components/room/StageViewOptions.tsx
Normal file
130
frontend/src/components/room/StageViewOptions.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { LayoutGrid } from 'lucide-react'
|
||||
import { STAGE_LAYOUT_MODE_OPTIONS, type StageLayoutMode } from '@/lib/stageLayoutMode'
|
||||
|
||||
export interface StageViewProps {
|
||||
layoutMode: StageLayoutMode
|
||||
onLayoutModeChange: (mode: StageLayoutMode) => void
|
||||
/** Карусель остальных участников скрыта — на сцене только основная область. */
|
||||
hideOthers: boolean
|
||||
onHideOthersChange: (hide: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Блок выбора вида сцены: режим показа (три варианта) + скрытие остальных
|
||||
* участников. Один и тот же блок рендерится в двух местах — в поповере кнопки
|
||||
* «Вид» на десктопе (`StageViewMenu` ниже) и секцией внутри мобильной шторки
|
||||
* настроек (`DeviceSettingsDialog`), поэтому вынесен отдельно.
|
||||
*
|
||||
* «Скрыть остальных» в режиме «Плитки» заблокировано, а не спрятано: все
|
||||
* плитки там равноправны, скрывать нечего, но исчезающий на ровном месте
|
||||
* переключатель читался бы как баг — вместо этого он выключен с пояснением.
|
||||
*/
|
||||
export function StageViewOptions({
|
||||
layoutMode,
|
||||
onLayoutModeChange,
|
||||
hideOthers,
|
||||
onHideOthersChange,
|
||||
}: StageViewProps) {
|
||||
// Радиогруппе нужно имя, уникальное на документ: блок может оказаться на
|
||||
// странице дважды (десктопный поповер и шторка живут в разных ветках
|
||||
// рендера, но полагаться на это не стоит).
|
||||
const groupName = useId()
|
||||
const hideOthersDisabled = layoutMode === 'tiles'
|
||||
|
||||
return (
|
||||
<div className="stage-view-options">
|
||||
<fieldset className="stage-view-modes">
|
||||
<legend>Режим показа</legend>
|
||||
{STAGE_LAYOUT_MODE_OPTIONS.map((option) => (
|
||||
<label
|
||||
key={option.mode}
|
||||
className={`stage-view-mode${layoutMode === option.mode ? ' is-active' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={groupName}
|
||||
value={option.mode}
|
||||
checked={layoutMode === option.mode}
|
||||
onChange={() => onLayoutModeChange(option.mode)}
|
||||
/>
|
||||
<span className="stage-view-mode-text">
|
||||
<span className="stage-view-mode-title">{option.title}</span>
|
||||
<span className="stage-view-mode-hint">{option.hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<label className={`stage-view-toggle${hideOthersDisabled ? ' is-disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideOthers}
|
||||
disabled={hideOthersDisabled}
|
||||
onChange={(e) => onHideOthersChange(e.target.checked)}
|
||||
/>
|
||||
<span>Скрыть остальных участников</span>
|
||||
</label>
|
||||
{hideOthersDisabled && (
|
||||
<p className="stage-view-note">В режиме «Плитки» все участники равноправны — скрывать нечего.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Кнопка «Вид» в тулбаре с поповером над ней — десктопный вход в
|
||||
* `StageViewOptions`. На мобильном не рендерится вовсе (см. `RoomToolbar`):
|
||||
* там тулбар и так ужат до пяти кнопок, а те же настройки доступны секцией
|
||||
* «Вид» в шторке настроек.
|
||||
*
|
||||
* Закрытие по клику вне и Escape — тем же паттерном, что меню пользователя в
|
||||
* `ShellTopbar` (слушатели на document, пока меню открыто).
|
||||
*/
|
||||
export function StageViewMenu(props: StageViewProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="tb-menu-wrap" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`tb-btn${open ? ' is-panel-open' : ''}`}
|
||||
aria-label="Вид сцены"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span className="icon-shell">
|
||||
<LayoutGrid className="lucide" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="label">Вид</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="tb-menu" role="dialog" aria-label="Вид сцены">
|
||||
<StageViewOptions {...props} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user