В Document PiP (Chrome/Edge) своего тулбара нет, и начать демонстрацию, не свернув мини-окно, было нельзя. Кнопка сделана по образцу кнопки микрофона, добавленной в 0.0.15: тот же `useTrackToggle` через `RoomContext`, поэтому она и кнопка основного тулбара — два вида одного состояния и рассинхрону взяться неоткуда. Главный вопрос задачи — пустит ли платформа `getDisplayMedia()`, вызванный из кода основного окна по клику в ДРУГОМ окне. Замер в Chrome 150: после клика в PiP `navigator.userActivation.isActive === true` в обоих окнах, активация доезжает до опенера, вызов проходит, системный пикер выбора экрана открывается отдельным окном поверх всего, а не прячется за заглушкой основного окна. Опции захвата (`SCREEN_SHARE_CAPTURE_OPTIONS`) вынесены из `RoomToolbar` в `lib/screenShareOptions.ts`: кнопок демонстрации теперь две, и разойдись они хотя бы в `audio`, демонстрация получалась бы разной в зависимости от того, откуда её запустили. Фокус в мини-окне не менялся: своя демонстрация показывается по тем же правилам `pickStageFocus`, что и в основном окне. В Safari мини-окно — нативный video-PiP без собственного DOM, кнопке там негде жить; в Firefox мини-окна нет вовсе. Это ограничение платформы.
315 lines
15 KiB
TypeScript
315 lines
15 KiB
TypeScript
import {
|
||
Hand,
|
||
LogOut,
|
||
Maximize,
|
||
MessageSquare,
|
||
Mic,
|
||
MicOff,
|
||
Minimize,
|
||
PictureInPicture2,
|
||
ScreenShare,
|
||
ScreenShareOff,
|
||
Settings,
|
||
Video,
|
||
VideoOff,
|
||
} from 'lucide-react'
|
||
import { Track } from 'livekit-client'
|
||
import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react'
|
||
import { useToast } from '@/components/ui/ToastProvider'
|
||
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
|
||
import type { HandQueueEntry } from '@/hooks/useChat'
|
||
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
|
||
import { HandQueueMenu } from '@/components/room/HandQueueMenu'
|
||
import { SCREEN_SHARE_CAPTURE_OPTIONS } from '@/lib/screenShareOptions'
|
||
|
||
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
|
||
/** `JoinOut.hand_queue_enabled` — при `false` кнопка «Рука» и очередь не рендерятся вовсе. */
|
||
handQueueEnabled: boolean
|
||
/**
|
||
* Очередь поднятых рук целиком (задача B1, `useChat().handQueue`) — сама
|
||
* решает, поднята ли СВОЯ рука (сравнивая с `localParticipant.identity`
|
||
* через `useLocalParticipant`), и показывает бейдж общего счётчика.
|
||
*/
|
||
handQueue: HandQueueEntry[]
|
||
onRaiseHand: () => void
|
||
onLowerHand: () => void
|
||
/** Опустить ЧУЖУЮ руку по identity — только организатору (панель очереди, `HandQueueMenu`). */
|
||
onLowerHandById: (identity: string) => void
|
||
/** Тулбар в оверлее полноэкранного режима — см. докстринг `RoomTopbar.overlayVisible`, тот же механизм. */
|
||
overlayVisible?: boolean
|
||
/**
|
||
* Пользователь нажал «Выйти» — вызывается ПЕРЕД тем, как `DisconnectButton`
|
||
* отключит комнату (обработчики в `mergeProps` вызываются цепочкой). Нужен
|
||
* `RoomPage`, чтобы отличить намеренный выход от разрыва: причина
|
||
* `CLIENT_INITIATED` приходит и от кнопки, и от livekit-client при заморозке
|
||
* вкладки — см. докстринг `handleDisconnected`.
|
||
*/
|
||
onLeave?: () => void
|
||
}
|
||
|
||
/**
|
||
* Нижний тулбар комнаты: микрофон/камера/демонстрация экрана/рука/очередь
|
||
* рук/вид сцены/настройки устройств/полноэкранный режим/мини-плеер/чат/выход —
|
||
* собственные кнопки на хуках LiveKit (useTrackToggle/DisconnectButton) и
|
||
* панели чата, стилизованные по design/mockups/room.html.
|
||
*
|
||
* Кнопка «Вид» (режимы показа и скрытие остальных) рендерится ТОЛЬКО на
|
||
* широком экране — условным рендерингом, а не скрытием через CSS: демонстрация
|
||
* и мини-плеер скрыты на узком экране через CSS (см. `styles/room.css`), а те
|
||
* же настройки показа сцены доступны секцией «Вид» в шторке настроек
|
||
* (`DeviceSettingsDialog`). «Рука» — сознательное исключение из этой
|
||
* экономии: поднять руку посреди разговора — действие со временем жизни в
|
||
* секунды, прятать его в шторку настроек означало бы делать его практически
|
||
* недоступным с телефона. «Очередь» видна ЛЮБОМУ участнику (сессия
|
||
* «28-hand-queue-for-all»: раньше только организатору), по той же причине
|
||
* оставлена в тулбаре, а не в шторке: любому участнику с телефона тоже нужно
|
||
* видеть очередь сразу. Обе кнопки целиком гасятся `handQueueEnabled`
|
||
* (`JoinOut.hand_queue_enabled`, отключаемый модуль в админке).
|
||
* Полноэкранный режим на мобильном ОСТАЁТСЯ в тулбаре (не спрятан в шторку,
|
||
* как демонстрация/мини-плеер) — по решению оператора вход в него должен
|
||
* быть по аналогии с десктопом; если из-за этого кнопки не помещаются в один
|
||
* ряд, `mobileButtonCount`/`.tb-wrap-grid` ниже раскладывают их равномерной
|
||
* сеткой, а не как получится через `flex-wrap`.
|
||
*/
|
||
export function RoomToolbar({
|
||
chatVisible,
|
||
chatOpen,
|
||
chatUnreadCount,
|
||
onToggleChat,
|
||
onToggleSettings,
|
||
fullscreenSupported,
|
||
fullscreenActive,
|
||
onToggleFullscreen,
|
||
pipSupported,
|
||
pipActive,
|
||
onTogglePiP,
|
||
handQueueEnabled,
|
||
handQueue,
|
||
onRaiseHand,
|
||
onLowerHand,
|
||
onLowerHandById,
|
||
layoutMode,
|
||
onLayoutModeChange,
|
||
hideOthers,
|
||
onHideOthersChange,
|
||
overlayVisible,
|
||
onLeave,
|
||
}: RoomToolbarProps) {
|
||
const toast = useToast()
|
||
const isCompact = useIsCompactViewport()
|
||
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). «Рука»/«Очередь» — обе теперь видны любому участнику
|
||
// (сессия «28-hand-queue-for-all»), их наличие зависит только от
|
||
// `handQueueEnabled`, не от роли.
|
||
const mobileButtonCount =
|
||
2 /* микрофон, камера */ +
|
||
(handQueueEnabled ? 2 : 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({
|
||
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${overlayVisible ? ' is-visible' : ''}${mobileButtonCount >= 7 ? ' tb-wrap-grid' : ''}`}
|
||
>
|
||
<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>
|
||
|
||
{handQueueEnabled && (
|
||
<button
|
||
type="button"
|
||
className={`tb-btn${handRaised ? ' is-hand-raised' : ''}`}
|
||
aria-pressed={handRaised}
|
||
aria-label={handRaised ? 'Опустить руку' : 'Поднять руку'}
|
||
onClick={() => (handRaised ? onLowerHand() : onRaiseHand())}
|
||
>
|
||
<span className="icon-shell">
|
||
<Hand className="lucide" aria-hidden="true" />
|
||
{handQueue.length > 0 && (
|
||
<span className="badge-count">{handQueue.length > 9 ? '9+' : handQueue.length}</span>
|
||
)}
|
||
</span>
|
||
<span className="label">Рука</span>
|
||
</button>
|
||
)}
|
||
|
||
{handQueueEnabled && <HandQueueMenu queue={handQueue} onLower={onLowerHandById} />}
|
||
|
||
{!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">{isCompact ? 'Настройки' : 'Устройства'}</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>
|
||
{/* На узком экране («Мини-окно» иначе переносится на 2 строки и
|
||
кнопка становится выше соседних, см. .label-full/.label-short
|
||
в room.css) — только «Мини». Текст, не структура: aria-label
|
||
выше уже несёт полный смысл независимо от видимой подписи. */}
|
||
<span className="label">
|
||
<span className="label-full">Мини-окно</span>
|
||
<span className="label-short">Мини</span>
|
||
</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="Выйти из конференции" onClick={onLeave}>
|
||
<span className="icon-shell">
|
||
<LogOut className="lucide" aria-hidden="true" />
|
||
</span>
|
||
<span className="label">Выйти</span>
|
||
</DisconnectButton>
|
||
</footer>
|
||
)
|
||
}
|