В Chrome мини-плеер показывал самого пользователя вместо того, что он видел крупно. Сцена в мини-окне — отдельный экземпляр RoomStage (портал в PiP-окно), и состояние фокуса он начинал с нуля: демонстрации нет, никто прямо сейчас не говорит — pickStageFocus доходил до последнего фолбэка localKey, то есть до «показать себя». В Safari бага не было видно: там Document PiP не используется, а video-фолбэк берёт <video> прямо из фокус-плитки основного окна. Ключ фокуса теперь передаётся наружу (onFocusKeyChange), живёт в состоянии RoomPage и достаётся следующему экземпляру сцены (initialFocusKey). Работает в обе стороны — возврат из мини-плеера тоже не сбрасывает фокус. Правила выбора фокуса (pickStageFocus) не тронуты.
307 lines
16 KiB
TypeScript
307 lines
16 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { createPortal } from 'react-dom'
|
||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||
import { PictureInPicture2 } from 'lucide-react'
|
||
import { LiveKitRoom, usePersistentUserChoices } from '@livekit/components-react'
|
||
import type { RoomOptions } from 'livekit-client'
|
||
import '@livekit/components-styles'
|
||
import '@/styles/room.css'
|
||
import { joinConference, resolveConference } from '@/api/conferences'
|
||
import { ApiError, errorDetail } from '@/api/client'
|
||
import { useAuth } from '@/auth/useAuth'
|
||
import { useChat } from '@/hooks/useChat'
|
||
import { useFullscreen } from '@/hooks/useFullscreen'
|
||
import { useRoomPiP } from '@/hooks/useRoomPiP'
|
||
import { RoomTopbar } from '@/components/room/RoomTopbar'
|
||
import { RoomStage } from '@/components/room/RoomStage'
|
||
import { RoomToolbar } from '@/components/room/RoomToolbar'
|
||
import { ChatPanel } from '@/components/room/ChatPanel'
|
||
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
|
||
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
||
import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode'
|
||
|
||
interface RoomJoinState {
|
||
livekitUrl: string
|
||
token: string
|
||
roomName: string
|
||
conferenceId?: string
|
||
/** Номер конференции — для инвайт-чипа в топбаре (см. RoomTopbar). Есть только сразу после создания. */
|
||
number?: string
|
||
/** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */
|
||
chatEnabled?: boolean
|
||
}
|
||
|
||
/**
|
||
* Экран комнаты конференции (тёмная тема, `data-theme="room"`).
|
||
*
|
||
* Публичный маршрут: гость входит без JWT-аутентификации приложения — его
|
||
* LiveKit-токен получен через `POST /conferences/{id}/guest-join` (см.
|
||
* JoinPage) и передан сюда через navigation state, ровно как и токен
|
||
* авторизованного пользователя. RoomPage сам никогда не проверяет сессию —
|
||
* подключение к LiveKit целиком определяется переданными `token`/`livekitUrl`.
|
||
*
|
||
* `:slug` в адресе — тот же идентификатор (slug или номер), что пользователь
|
||
* вводил на JoinPage; используется только для повторного резолва при прямой
|
||
* перезагрузке страницы (join-state из state теряется при F5). Если состояния
|
||
* нет — не пытаемся угадать креды сами: гостю это в принципе невозможно
|
||
* (нет сохранённых имени/пароля), поэтому просто уводим на `/join?q=:slug`,
|
||
* где резолв и вход повторяются штатным флоу.
|
||
*/
|
||
export function RoomPage() {
|
||
const { slug } = useParams<{ slug: string }>()
|
||
const location = useLocation()
|
||
const navigate = useNavigate()
|
||
const { status } = useAuth()
|
||
|
||
const initialState = (location.state as RoomJoinState | null) ?? null
|
||
const [joinState, setJoinState] = useState<RoomJoinState | null>(initialState)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
// useCallback: стабильная ссылка на обработчик — иначе на каждом рендере
|
||
// RoomPage пересобирался бы объект-пропс `onDisconnected`, а с ним и
|
||
// внутренний useEffect @livekit/components-react (он в списке зависимостей
|
||
// хука useLiveKitRoom), лишний раз пересоздавая подписки на события Room.
|
||
const handleDisconnected = useCallback(() => {
|
||
navigate(status === 'authenticated' ? '/lobby' : '/join')
|
||
}, [navigate, status])
|
||
|
||
useEffect(() => {
|
||
if (joinState || !slug) return
|
||
// Сессия ещё восстанавливается (silent-refresh) — подождём, чтобы не
|
||
// отправить авторизованного пользователя в гостевой флоу по ошибке.
|
||
if (status === 'loading') return
|
||
|
||
if (status !== 'authenticated') {
|
||
navigate(`/join?q=${encodeURIComponent(slug)}`, { replace: true })
|
||
return
|
||
}
|
||
|
||
// Авторизованный пользователь перезагрузил страницу комнаты — пробуем
|
||
// молча переподключиться тем же slug/номером, что был в адресе.
|
||
let cancelled = false
|
||
|
||
async function reconnect() {
|
||
try {
|
||
const info = await resolveConference(slug!)
|
||
if (info.requires_password) {
|
||
if (!cancelled) navigate(`/join?q=${encodeURIComponent(slug!)}`, { replace: true })
|
||
return
|
||
}
|
||
const result = await joinConference(info.id)
|
||
if (!cancelled) {
|
||
setJoinState({
|
||
livekitUrl: result.livekit_url,
|
||
token: result.token,
|
||
roomName: result.room_name,
|
||
conferenceId: result.conference_id,
|
||
chatEnabled: result.chat_enabled,
|
||
})
|
||
}
|
||
} catch (err) {
|
||
if (cancelled) return
|
||
if (err instanceof ApiError && (err.status === 404 || err.status === 410)) {
|
||
setError('Конференция недоступна или уже завершилась')
|
||
} else if (err instanceof ApiError && err.status === 403 && errorDetail(err) === 'password_required') {
|
||
navigate(`/join?q=${encodeURIComponent(slug!)}`, { replace: true })
|
||
} else {
|
||
setError('Не удалось подключиться к конференции. Попробуйте войти ещё раз')
|
||
}
|
||
}
|
||
}
|
||
|
||
void reconnect()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [joinState, slug, status, navigate])
|
||
|
||
// WS-соединение чата живёт независимо от того, открыта ли панель, — иначе
|
||
// при сворачивании панели терялась бы история и обрывался счётчик
|
||
// непрочитанных. Хук вызывается безусловно (до ранних return ниже) —
|
||
// conferenceId/token до входа в комнату ещё не известны, enabled это учитывает.
|
||
const chat = useChat({
|
||
conferenceId: joinState?.conferenceId,
|
||
token: joinState?.token,
|
||
enabled: Boolean(joinState?.chatEnabled),
|
||
})
|
||
const [chatOpen, setChatOpen] = useState(false)
|
||
// Сколько сообщений уже «видели» (на момент последнего открытия панели) —
|
||
// считается прямо в теле рендера, под охраной сравнения («adjusting state
|
||
// during rendering», тот же санкционированный паттерн, что и focus-логика
|
||
// в RoomStage.tsx); useEffect тут не подходит — react-hooks/set-state-in-effect,
|
||
// а ref нельзя ни читать, ни писать во время рендера — react-hooks/refs.
|
||
const [chatSeenCount, setChatSeenCount] = useState(0)
|
||
if (chatOpen && chatSeenCount !== chat.messages.length) {
|
||
setChatSeenCount(chat.messages.length)
|
||
}
|
||
// Счётчик непрочитанных — сообщения, накопленные с момента последнего
|
||
// открытия панели (включая историю при первом подключении, если панель
|
||
// открывается впервые уже после её загрузки); при закрытой панели счётчик
|
||
// сохраняется (см. design/mockups/room.html, .collapsed-demo).
|
||
const chatUnreadCount = Math.max(0, chat.messages.length - chatSeenCount)
|
||
|
||
// Чат выключен на сервере (close-код 4404) — панель нужно скрыть, даже если
|
||
// изначально JoinOut.chat_enabled был true (рассинхрон с админкой в моменте).
|
||
const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable
|
||
|
||
// Корневой контейнер комнаты — цель для fullscreen и источник video-элемента
|
||
// для video-PiP-фолбэка.
|
||
const roomRootRef = useRef<HTMLDivElement>(null)
|
||
const fullscreen = useFullscreen(roomRootRef)
|
||
const pip = useRoomPiP(roomRootRef)
|
||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||
|
||
// Вид сцены живёт здесь, а не в `RoomStage`: переключатели — в тулбаре и в
|
||
// шторке настроек, а сцена их только читает (общий предок).
|
||
//
|
||
// Режим показа персистится между заходами в комнату (`lib/stageLayoutMode.ts`,
|
||
// localStorage) — это устойчивое предпочтение: кто любит «плитки», хочет их
|
||
// и завтра. А вот «скрыть остальных» намеренно НЕ сохраняется: это разовое
|
||
// действие по ходу разговора, и войти в новую конференцию сразу без половины
|
||
// участников — сюрприз, а не удобство. Сбрасывается вместе с уходом со
|
||
// страницы комнаты.
|
||
const [layoutMode, setLayoutMode] = useState<StageLayoutMode>(loadStageLayoutMode)
|
||
const [hideOthers, setHideOthers] = useState(false)
|
||
|
||
const handleLayoutModeChange = useCallback((mode: StageLayoutMode) => {
|
||
setLayoutMode(mode)
|
||
saveStageLayoutMode(mode)
|
||
}, [])
|
||
|
||
// Ключ трека, который сцена показывает крупно. Живёт ЗДЕСЬ, а не только
|
||
// внутри `RoomStage`, потому что при открытии мини-плеера сцена
|
||
// размонтируется в основном окне и монтируется заново в PiP-окне (портал
|
||
// ниже) — без этого мостика новый экземпляр начинал бы выбор фокуса с нуля и
|
||
// открывал мини-окно на самом пользователе. Подробнее — докстринг `RoomStage`.
|
||
const [stageFocusKey, setStageFocusKey] = useState<string | null>(null)
|
||
|
||
// Сохранённый выбор устройств — читаем через собственный вызов
|
||
// usePersistentUserChoices (независимый от того, что использует
|
||
// DeviceSettingsDialog: там свой вызов хука со своим состоянием). ВАЖНО:
|
||
// ссылка на `roomOptions` должна быть стабильной между рендерами — иначе
|
||
// LiveKitRoom получает «новый» объект `options` и переподключается
|
||
// Это безопасно мемоизировать по `userChoices`,
|
||
// потому что `userChoices` ЭТОГО вызова хука меняется, только если МЫ САМИ
|
||
// вызовем saveAudioInputDeviceId/saveVideoInputDeviceId НА НЁМ — а мы этого
|
||
// не делаем (сохранение — только в DeviceSettingsDialog).
|
||
const { userChoices } = usePersistentUserChoices()
|
||
const roomOptions = useMemo<RoomOptions>(
|
||
() => ({
|
||
audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined },
|
||
videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined },
|
||
// Аудиовыход (колонки/наушники/bluetooth) — отдельный персист, не через
|
||
// usePersistentUserChoices: LocalUserChoices LiveKit про него не знает
|
||
// (см. lib/audioOutputDevice.ts). Читаем один раз при монтировании —
|
||
// как и с audio/videoDeviceId, смена применяется через DeviceSettingsDialog
|
||
// (setActiveMediaDevice), а не пересозданием roomOptions.
|
||
audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined },
|
||
}),
|
||
[userChoices],
|
||
)
|
||
|
||
if (error) {
|
||
return (
|
||
<div data-theme="room" className="room-error">
|
||
<p>{error}</p>
|
||
<button type="button" className="btn btn-primary" onClick={() => navigate('/lobby')}>
|
||
Вернуться в лобби
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!joinState) {
|
||
return (
|
||
<div data-theme="room" className="room-loader" role="status" aria-live="polite">
|
||
Подключаемся к конференции…
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div data-theme="room" ref={roomRootRef}>
|
||
<LiveKitRoom
|
||
serverUrl={joinState.livekitUrl}
|
||
token={joinState.token}
|
||
connect
|
||
audio
|
||
video
|
||
options={roomOptions}
|
||
onDisconnected={handleDisconnected}
|
||
>
|
||
<div data-lk-theme="default" className="room-shell">
|
||
<RoomTopbar roomName={joinState.roomName} slug={slug} number={joinState.number} />
|
||
<div className="room-main">
|
||
{pip.mode === 'document' ? (
|
||
// Сцена сейчас рисуется в PiP-окне (через createPortal ниже) —
|
||
// основное окно вместо неё показывает заглушку с возвратом.
|
||
<div className="room-pip-placeholder">
|
||
<PictureInPicture2 aria-hidden="true" />
|
||
<p>Конференция открыта в отдельном мини-окне</p>
|
||
<button type="button" className="btn btn-secondary" onClick={pip.toggle}>
|
||
Вернуть в это окно
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<RoomStage
|
||
layoutMode={layoutMode}
|
||
hideOthers={hideOthers}
|
||
onShowOthers={() => setHideOthers(false)}
|
||
onHideOthers={() => setHideOthers(true)}
|
||
initialFocusKey={stageFocusKey}
|
||
onFocusKeyChange={setStageFocusKey}
|
||
/>
|
||
)}
|
||
{chatVisible && chatOpen && (
|
||
<ChatPanel
|
||
messages={chat.messages}
|
||
status={chat.status}
|
||
statusMessage={chat.statusMessage}
|
||
onSend={chat.sendMessage}
|
||
onClose={() => setChatOpen(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
<RoomToolbar
|
||
chatVisible={chatVisible}
|
||
chatOpen={chatOpen}
|
||
chatUnreadCount={chatUnreadCount}
|
||
onToggleChat={() => setChatOpen((open) => !open)}
|
||
onToggleSettings={() => setSettingsOpen(true)}
|
||
fullscreenSupported={fullscreen.supported}
|
||
fullscreenActive={fullscreen.active}
|
||
onToggleFullscreen={fullscreen.toggle}
|
||
pipSupported={pip.supported}
|
||
pipActive={pip.active}
|
||
onTogglePiP={pip.toggle}
|
||
layoutMode={layoutMode}
|
||
onLayoutModeChange={handleLayoutModeChange}
|
||
hideOthers={hideOthers}
|
||
onHideOthersChange={setHideOthers}
|
||
/>
|
||
</div>
|
||
{settingsOpen && (
|
||
<DeviceSettingsDialog
|
||
onClose={() => setSettingsOpen(false)}
|
||
layoutMode={layoutMode}
|
||
onLayoutModeChange={handleLayoutModeChange}
|
||
hideOthers={hideOthers}
|
||
onHideOthersChange={setHideOthers}
|
||
/>
|
||
)}
|
||
{/* Портал внутрь LiveKitRoom (не как сосед снаружи!) — RoomStage,
|
||
рендерящийся в PiP-окне, продолжает читать RoomContext/треки.
|
||
`variant="pip"` — мини-плеер
|
||
показывает только активное окно (одну плитку), без карусели/грида
|
||
основного окна. `initialFocusKey` — то, что было крупно в основном
|
||
окне: без него мини-окно открывалось на самом пользователе. */}
|
||
{pip.pipWindow &&
|
||
createPortal(
|
||
<RoomStage variant="pip" initialFocusKey={stageFocusKey} onFocusKeyChange={setStageFocusKey} />,
|
||
pip.pipWindow.document.body,
|
||
)}
|
||
</LiveKitRoom>
|
||
</div>
|
||
)
|
||
}
|