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(initialState) const [error, setError] = useState(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(null) const fullscreen = useFullscreen(roomRootRef) const pip = useRoomPiP(roomRootRef) const [settingsOpen, setSettingsOpen] = useState(false) // Вид сцены живёт здесь, а не в `RoomStage`: переключатели — в тулбаре и в // шторке настроек, а сцена их только читает (общий предок). // // Режим показа персистится между заходами в комнату (`lib/stageLayoutMode.ts`, // localStorage) — это устойчивое предпочтение: кто любит «плитки», хочет их // и завтра. А вот «скрыть остальных» намеренно НЕ сохраняется: это разовое // действие по ходу разговора, и войти в новую конференцию сразу без половины // участников — сюрприз, а не удобство. Сбрасывается вместе с уходом со // страницы комнаты. const [layoutMode, setLayoutMode] = useState(loadStageLayoutMode) const [hideOthers, setHideOthers] = useState(false) const handleLayoutModeChange = useCallback((mode: StageLayoutMode) => { setLayoutMode(mode) saveStageLayoutMode(mode) }, []) // Ключ трека, который сцена показывает крупно. Живёт ЗДЕСЬ, а не только // внутри `RoomStage`, потому что при открытии мини-плеера сцена // размонтируется в основном окне и монтируется заново в PiP-окне (портал // ниже) — без этого мостика новый экземпляр начинал бы выбор фокуса с нуля и // открывал мини-окно на самом пользователе. Подробнее — докстринг `RoomStage`. const [stageFocusKey, setStageFocusKey] = useState(null) // Сохранённый выбор устройств — читаем через собственный вызов // usePersistentUserChoices (независимый от того, что использует // DeviceSettingsDialog: там свой вызов хука со своим состоянием). ВАЖНО: // ссылка на `roomOptions` должна быть стабильной между рендерами — иначе // LiveKitRoom получает «новый» объект `options` и переподключается // Это безопасно мемоизировать по `userChoices`, // потому что `userChoices` ЭТОГО вызова хука меняется, только если МЫ САМИ // вызовем saveAudioInputDeviceId/saveVideoInputDeviceId НА НЁМ — а мы этого // не делаем (сохранение — только в DeviceSettingsDialog). const { userChoices } = usePersistentUserChoices() const roomOptions = useMemo( () => ({ // Оба флага в LiveKit по умолчанию выключены, и без них каждый клиент // подписан на полное качество всех чужих треков независимо от размера // плитки, а каждый паблишер шлёт все слои симулкаста, даже если их никто // не смотрит. На тесте 28.07.2026 (19 участников, ~8 камер) это дало // устойчивые 140–169 Мбит/с исходящего трафика при пике 240, 662 события // `remote bwe: channel congestion detected` и 146 переходов аллокатора // STABLE → DEFICIENT — то есть видимый участникам лаг. // // adaptiveStream: подписка на слой по фактическому размеру плитки на // экране + пауза треков, которые сейчас не отрисованы. Именно на нём // начинает экономить уже написанный код: «скрыть остальных» // (RoomStage) не рендерит карусель, а пагинация StageGrid рендерит // только текущую страницу — неприаттаченные треки считаются невидимыми. // dynacast: паблишер прекращает отдавать слои, на которые нет подписчиков. adaptiveStream: true, dynacast: true, 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 (

{error}

) } if (!joinState) { return (
Подключаемся к конференции…
) } return (
{pip.mode === 'document' ? ( // Сцена сейчас рисуется в PiP-окне (через createPortal ниже) — // основное окно вместо неё показывает заглушку с возвратом.
) : ( setHideOthers(false)} onHideOthers={() => setHideOthers(true)} initialFocusKey={stageFocusKey} onFocusKeyChange={setStageFocusKey} /> )} {chatVisible && chatOpen && ( setChatOpen(false)} /> )}
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} />
{settingsOpen && ( setSettingsOpen(false)} layoutMode={layoutMode} onLayoutModeChange={handleLayoutModeChange} hideOthers={hideOthers} onHideOthersChange={setHideOthers} /> )} {/* Портал внутрь LiveKitRoom (не как сосед снаружи!) — RoomStage, рендерящийся в PiP-окне, продолжает читать RoomContext/треки. `variant="pip"` — мини-плеер показывает только активное окно (одну плитку), без карусели/грида основного окна. `initialFocusKey` — то, что было крупно в основном окне: без него мини-окно открывалось на самом пользователе. */} {pip.pipWindow && createPortal( , pip.pipWindow.document.body, )}
) }