Files
vidconf/frontend/src/pages/RoomPage.tsx
Max Ronzhin 8e5eda88a2 feat(room): поднятие руки и очередь для организатора
Транспорт — существующий аутентифицированный WS чата (api/chat.py), а не
отдельный эндпоинт: сервер уже держит это соединение на каждого участника
(обоснование — докстринг chat_websocket и useChat.ts). Состояние очереди —
Redis (services/hand_queue.py), не Postgres: это эфемерное состояние звонка,
а не история, и два процесса uvicorn делают наивную память одного процесса
недостаточной. HSETNX даёт идемпотентное «поднять» (повторный клик не
переставляет в конец очереди), снапшот шлётся всем участникам при любом
изменении — организатор, зашедший позже, сразу видит актуальную картину.

Опустить чужую руку может организатор (решение оператора) — проверка через
conference.owner_id, не через identity клиента. Участник, вышедший из
комнаты LiveKit (webhook participant_left), теряет место в очереди
автоматически; переподключение WS чата место не сбрасывает (Redis не привязан
к жизни соединения). room_finished чистит очередь целиком — она не должна
пережить завершение звонка.

Побочный эффект транспортного решения: поднять руку нельзя, если чат выключен
настройкой инстанса (WS вообще не открывается) — принятый компромисс ради
переиспользования уже готового канала.

UI: кнопка «Рука» в тулбаре (у всех, бейдж — общий счётчик), бейдж на плитке
говорящего (видно всем), панель «Очередь» организатору (HandQueuePanel).
Кнопка «Рука» и панель «Очередь» намеренно НЕ прячутся в мобильную шторку
настроек, в отличие от «Вида», — поднятие руки посреди разговора требует
кнопки под рукой, а не в два клика вглубь настроек.

Этим же коммитом (файлы разделяемые с задачей B2, RoomParticipantTile.tsx/
useChat.ts/RoomStage.tsx/RoomPage.tsx/room.css) — проброс conferenceId и
каркас forced_mute-обработки, без которых кнопки принудительного мьюта не
скомпилировались бы; сама реализация мьюта — следующим коммитом.
2026-08-01 22:06:43 +03:00

363 lines
21 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 { HandQueuePanel } from '@/components/room/HandQueuePanel'
import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher'
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
/** Название конференции (`ConferenceOut.title`/`ResolveOut.title`) — для топбара, см. `RoomTopbar` и задачу A6. */
title?: string | null
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,
title: info.title,
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
// Панель очереди поднятых рук — открыта/закрыта организатором (задача B1).
// Саму видимость кнопки/панели решает `useIsOrganizer()` внутри
// `RoomToolbar`/`HandQueuePanel` (эти компоненты — дети `LiveKitRoom`, а
// `RoomPage` — нет, `useLocalParticipant` здесь не вызвать).
const [handQueueOpen, setHandQueueOpen] = useState(false)
// Identity участников с поднятой рукой — множеством, для дешёвого `.has()`
// на каждой плитке сцены (см. `RoomParticipantTile`).
const raisedHandIdentities = useMemo(
() => new Set(chat.handQueue.map((entry) => entry.identity)),
[chat.handQueue],
)
// Корневой контейнер комнаты — цель для 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`, `onPinFocus`) — но это НЕ то же самое, что
// явный выбор пользователя: переключение не сохраняется в localStorage
// (в отличие от `handleLayoutModeChange`), иначе разовое закрепление в этой
// встрече поменяло бы устойчивое предпочтение режима для всех следующих.
const handlePinFocus = useCallback(() => {
setLayoutMode('standard')
}, [])
// Ключ трека, который сцена показывает крупно. Живёт ЗДЕСЬ, а не только
// внутри `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 title={joinState.title ?? null} 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}
onPinFocus={handlePinFocus}
raisedHandIdentities={raisedHandIdentities}
conferenceId={joinState.conferenceId}
/>
)}
{chatVisible && chatOpen && (
<ChatPanel
messages={chat.messages}
status={chat.status}
statusMessage={chat.statusMessage}
onSend={chat.sendMessage}
onClose={() => setChatOpen(false)}
/>
)}
{handQueueOpen && (
<HandQueuePanel
queue={chat.handQueue}
onLower={(identity) => chat.lowerHand(identity)}
onClose={() => setHandQueueOpen(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}
handQueue={chat.handQueue}
onRaiseHand={chat.raiseHand}
onLowerHand={() => chat.lowerHand()}
handQueueOpen={handQueueOpen}
onToggleHandQueue={() => setHandQueueOpen((open) => !open)}
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,
)}
<ForcedMuteWatcher event={chat.lastForcedMute} />
</LiveKitRoom>
</div>
)
}