Первоначальная версия VidConf
This commit is contained in:
250
frontend/src/pages/RoomPage.tsx
Normal file
250
frontend/src/pages/RoomPage.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
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'
|
||||
|
||||
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)
|
||||
|
||||
// Сохранённый выбор устройств — читаем через собственный вызов
|
||||
// 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 },
|
||||
}),
|
||||
[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 />
|
||||
)}
|
||||
{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}
|
||||
/>
|
||||
</div>
|
||||
{settingsOpen && <DeviceSettingsDialog onClose={() => setSettingsOpen(false)} />}
|
||||
{/* Портал внутрь LiveKitRoom (не как сосед снаружи!) — RoomStage,
|
||||
рендерящийся в PiP-окне, продолжает читать RoomContext/треки.
|
||||
`variant="pip"` — мини-плеер
|
||||
показывает только активное окно (одну плитку), без карусели/грида
|
||||
основного окна. */}
|
||||
{pip.pipWindow && createPortal(<RoomStage variant="pip" />, pip.pipWindow.document.body)}
|
||||
</LiveKitRoom>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user