Files
vidconf/frontend/src/components/room/RoomToolbar.tsx
Max Ronzhin 3fb3a5d42c feat(room): выбор режима показа участников и скрытие остальных
Раскладка сцены больше не выбирается автоматически — пользователь выбирает
один из трёх режимов:
  - «Стандарт» — как раньше: крупная плитка плюс карусель остальных сбоку,
    кого показать крупно, по-прежнему решает pickStageFocus;
  - «Плитки» — все участники равными плитками, без выделенного крупного;
  - «Живые плитки» — сетка только из тех, у кого включена камера, остальные
    в карусели сбоку; если камеру не включил никто, сетка была бы пустой —
    режим вырождается в «Плитки».

Демонстрация экрана перебивает выбранный режим: пока в комнате есть активный
шэр, сцена ведёт себя как «Стандарт» (смысл плиточных режимов — равноправие
участников, а демонстрация неравноправна по определению). Режим при этом
живёт в состоянии RoomPage, поэтому по завершении шэра вид сам возвращается
к выбранному.

Скрытие остальных убирает карусель, основная область занимает сцену целиком.
Скрыть можно из меню «Вид», из шторки настроек на мобильном и кнопкой прямо
над колонкой миниатюр; вернуть — кнопкой «Показать остальных (N)» на сцене,
которая видна всё время, пока кто-то скрыт. В режиме «Плитки» скрывать
нечего, переключатель там заблокирован с пояснением.

Переключатель на широком экране — кнопка «Вид» с поповером; на мобильном её
нет (тулбар там и так ужат до пяти кнопок), те же настройки идут секцией в
шторке. Видимость решается условным рендерингом в React, а не новым
CSS-правилом поверх медиазапроса — в room.css за это уже была битва
специфичности.

Режим сохраняется между заходами в комнату (localStorage, своим модулем —
LocalUserChoices у LiveKit фиксированной структуры, поля под раскладку там
нет). Скрытие не сохраняется: разовое действие по ходу разговора, войти в
новую конференцию без половины участников — сюрприз, а не удобство.

Сетка собрана своим StageGrid поверх тех же публичных хуков, что использует
GridLayout: библиотечный компонент не пробрасывает gridLayouts, а её набор
раскладок требует 560px уже для 2x2 — на телефоне это две плитки на страницу.
Свой набор даёт 2x2 от 360px и портретную 2x3. Индикатор страниц тоже свой:
PaginationControl/PaginationIndicator из пакета не экспортируются.
2026-07-28 04:24:49 +03:00

237 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
LogOut,
Maximize,
MessageSquare,
Mic,
MicOff,
Minimize,
PictureInPicture2,
ScreenShare,
ScreenShareOff,
Settings,
Video,
VideoOff,
} from 'lucide-react'
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` — звук
* вкладки/экрана там, где браузер его отдаёт (Chrome/Edge — вкладка почти
* всегда, целый экран — только Windows); `selfBrowserSurface: 'exclude'`
* — не предлагать в списке
* источников собственную вкладку (зеркальный туннель самой конференции);
* `surfaceSwitching: 'include'` — разрешить переключать источник прямо во
* время демонстрации, не останавливая её; `systemAudio: 'include'` — не
* запрещать захват системного звука при выборе «весь экран». Вынесено в
* модульную константу — `useTrackToggle` держит `JSON.stringify(captureOptions)`
* в зависимостях внутреннего `useMemo`, инлайновый литерал был бы безвреден,
* но константа явнее фиксирует неизменность опций.
*/
const SCREEN_SHARE_CAPTURE_OPTIONS: ScreenShareCaptureOptions = {
audio: true,
selfBrowserSurface: 'exclude',
surfaceSwitching: 'include',
systemAudio: 'include',
}
interface RoomToolbarProps extends StageViewProps {
/** Показывать ли кнопку чата — `JoinOut.chat_enabled` И чат не помечен недоступным (close-код 4404). */
chatVisible: boolean
chatOpen: boolean
/** Число непрочитанных сообщений, накопленных со времени последнего открытия панели. */
chatUnreadCount: number
onToggleChat: () => void
/** Открыть диалог настроек устройств. */
onToggleSettings: () => void
/** `document.fullscreenEnabled` — false скрывает кнопку. */
fullscreenSupported: boolean
fullscreenActive: boolean
onToggleFullscreen: () => void
/** Доступен ли мини-плеер хоть в каком-то виде (Document PiP либо video-фолбэк) — false в Firefox скрывает кнопку. */
pipSupported: boolean
pipActive: boolean
onTogglePiP: () => void
}
/**
* Нижний тулбар комнаты: микрофон/камера/демонстрация экрана/вид сцены/
* настройки устройств/полноэкранный режим/мини-плеер/чат/выход — собственные
* кнопки на хуках LiveKit (useTrackToggle/DisconnectButton) и панели чата,
* стилизованные по design/mockups/room.html.
*
* Кнопка «Вид» (режимы показа и скрытие остальных) рендерится ТОЛЬКО на
* широком экране — условным рендерингом, а не скрытием через CSS: тулбар на
* мобильном и так ужат до пяти кнопок, а те же настройки там доступны секцией
* «Вид» в шторке настроек (`DeviceSettingsDialog`).
*/
export function RoomToolbar({
chatVisible,
chatOpen,
chatUnreadCount,
onToggleChat,
onToggleSettings,
fullscreenSupported,
fullscreenActive,
onToggleFullscreen,
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({
source: Track.Source.ScreenShare,
captureOptions: SCREEN_SHARE_CAPTURE_OPTIONS,
// Отмена браузерного диалога выбора экрана/окна (пользователь нажал
// «Отмена») приходит сюда как NotAllowedError — молча игнорируем, это не
// ошибка приложения. Прочие сбои (например, `NotReadableError` при
// занятом источнике) показываем тостом.
onDeviceError: (error) => {
if (error.name === 'NotAllowedError') return
toast.show('Не удалось начать демонстрацию экрана', 'error')
},
})
return (
<footer className="room-toolbar">
<button
type="button"
{...mic.buttonProps}
className={`tb-btn${mic.enabled ? '' : ' is-off'}`}
aria-label={mic.enabled ? 'Выключить микрофон' : 'Включить микрофон'}
>
<span className="icon-shell">
{mic.enabled ? (
<Mic className="lucide" aria-hidden="true" />
) : (
<MicOff className="lucide" aria-hidden="true" />
)}
</span>
<span className="label">Микрофон</span>
</button>
<button
type="button"
{...camera.buttonProps}
className={`tb-btn${camera.enabled ? '' : ' is-off'}`}
aria-label={camera.enabled ? 'Выключить камеру' : 'Включить камеру'}
>
<span className="icon-shell">
{camera.enabled ? (
<Video className="lucide" aria-hidden="true" />
) : (
<VideoOff className="lucide" aria-hidden="true" />
)}
</span>
<span className="label">Камера</span>
</button>
<button
type="button"
{...screenShare.buttonProps}
className={`tb-btn tb-btn--screenshare${screenShare.enabled ? ' is-sharing' : ''}`}
aria-label={screenShare.enabled ? 'Остановить демонстрацию экрана' : 'Демонстрировать экран'}
>
<span className="icon-shell">
{screenShare.enabled ? (
<ScreenShareOff className="lucide" aria-hidden="true" />
) : (
<ScreenShare className="lucide" aria-hidden="true" />
)}
</span>
<span className="label">Демонстрация</span>
</button>
{!isCompact && (
<StageViewMenu
layoutMode={layoutMode}
onLayoutModeChange={onLayoutModeChange}
hideOthers={hideOthers}
onHideOthersChange={onHideOthersChange}
/>
)}
<button
type="button"
className="tb-btn"
aria-label="Настройки устройств"
onClick={onToggleSettings}
>
<span className="icon-shell">
<Settings className="lucide" aria-hidden="true" />
</span>
<span className="label">Устройства</span>
</button>
{fullscreenSupported && (
<button
type="button"
className={`tb-btn tb-btn--fullscreen${fullscreenActive ? ' is-panel-open' : ''}`}
aria-label={fullscreenActive ? 'Выйти из полноэкранного режима' : 'Развернуть на весь экран'}
aria-pressed={fullscreenActive}
onClick={onToggleFullscreen}
>
<span className="icon-shell">
{fullscreenActive ? (
<Minimize className="lucide" aria-hidden="true" />
) : (
<Maximize className="lucide" aria-hidden="true" />
)}
</span>
<span className="label">Экран</span>
</button>
)}
{pipSupported && (
<button
type="button"
className={`tb-btn tb-btn--pip${pipActive ? ' is-panel-open' : ''}`}
aria-label={pipActive ? 'Свернуть мини-окно' : 'Открыть мини-окно'}
aria-pressed={pipActive}
onClick={onTogglePiP}
>
<span className="icon-shell">
<PictureInPicture2 className="lucide" aria-hidden="true" />
</span>
<span className="label">Мини-окно</span>
</button>
)}
{chatVisible && (
<button
type="button"
className={`tb-btn${chatOpen ? ' is-panel-open' : ''}`}
aria-label={chatOpen ? 'Свернуть чат' : 'Открыть чат'}
aria-pressed={chatOpen}
onClick={onToggleChat}
>
<span className="icon-shell">
<MessageSquare className="lucide" aria-hidden="true" />
{chatUnreadCount > 0 && (
<span className="badge-count">{chatUnreadCount > 9 ? '9+' : chatUnreadCount}</span>
)}
</span>
<span className="label">Чат</span>
</button>
)}
<DisconnectButton className="tb-btn danger" aria-label="Выйти из конференции">
<span className="icon-shell">
<LogOut className="lucide" aria-hidden="true" />
</span>
<span className="label">Выйти</span>
</DisconnectButton>
</footer>
)
}