Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View 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>
)
}