Files
vidconf/frontend/src/pages/RoomPage.tsx
Max Ronzhin f627d827af feat(room): выключенные микрофон и камера при входе в конференцию
Раньше LiveKitRoom публиковал оба трека сразу при подключении (audio/video
без значения — булев true). Теперь участник заходит с выключенными
микрофоном и камерой и включает их сам уже в комнате; кнопки тулбара
(useTrackToggle) отражают фактическое состояние и работают штатно.
2026-07-30 00:22:44 +03:00

323 lines
18 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 { 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>(
() => ({
// Оба флага в LiveKit по умолчанию выключены, и без них каждый клиент
// подписан на полное качество всех чужих треков независимо от размера
// плитки, а каждый паблишер шлёт все слои симулкаста, даже если их никто
// не смотрит. На тесте 28.07.2026 (19 участников, ~8 камер) это дало
// устойчивые 140169 Мбит/с исходящего трафика при пике 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 (
<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={false}
video={false}
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>
)
}