Первоначальная версия 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,177 @@
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* Сообщение чата в проводном формате сервера — 1:1 с pydantic-схемой
* `ChatMessageOut` backend (зафиксированный протокол WS).
* `created_at` — UTC ISO-8601 (в БД и API только UTC), конвертация в
* локальное время — на стороне ChatPanel (`lib/localTime.ts`).
*/
export interface ChatMessageOut {
id: number
author_id: string | null
author_name: string
is_guest: boolean
text: string
created_at: string
}
/**
* Статус WS-соединения чата: `connecting` — сокет открывается,
* `open` — транспорт открыт (auth уже отправлен, история может ещё
* загружаться), `closed` — соединение штатно закрыто/не запускалось,
* `error` — обрыв по невалидному токену или отказу в доступе
* (см. `statusMessage` для пояснения пользователю).
*/
export type ChatConnectionStatus = 'connecting' | 'open' | 'closed' | 'error'
type IncomingFrame =
| { type: 'history'; messages: ChatMessageOut[] }
| { type: 'message'; message: ChatMessageOut }
| { type: 'error'; code: string }
interface UseChatOptions {
/** id конференции — пока не известен (страница ещё не подключилась к LiveKit), WS не открываем. */
conferenceId: string | undefined
/** LiveKit-токен из JoinOut — им же авторизуем WS-сессию чата (см. протокол). */
token: string | undefined
/** `JoinOut.chat_enabled` — при `false` хук ничего не подключает. */
enabled: boolean
}
interface UseChatResult {
messages: ChatMessageOut[]
status: ChatConnectionStatus
/** Пояснение для баннера при обрыве/ошибке — `null`, если показывать нечего. */
statusMessage: string | null
/** `true` после close-кода 4404 (чат выключен на сервере/конференция не найдена) — панель нужно скрыть. */
unavailable: boolean
/** Отправить сообщение (1..2000 символов после strip, пустое/слишком длинное — игнорируется). */
sendMessage: (text: string) => void
}
/** Close-коды сервера — см. зафиксированный протокол WS. */
const CLOSE_TOKEN_INVALID = 4401
const CLOSE_FORBIDDEN = 4403
const CLOSE_UNAVAILABLE = 4404
/** Собрать WS-адрес чата от текущего `window.location` (ws:// на http, wss:// на https). */
function buildChatWsUrl(conferenceId: string): string {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${wsProtocol}//${window.location.host}/api/v1/conferences/${conferenceId}/chat`
}
/**
* WS-клиент чата комнаты конференции. Реализует зафиксированный протокол:
* connect → `{type:"auth"}` → `{type:"history"}` → далее входящие
* `{type:"message"}`/`{type:"error"}`.
*
* Optimistic-append собственных сообщений НЕ делается: сервер всегда
* присылает наше же сообщение обратно echo-фреймом `message` — если
* добавлять его на клиенте сразу при отправке, оно задублируется в списке.
*/
export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseChatResult {
const [messages, setMessages] = useState<ChatMessageOut[]>([])
// `wsStatus` меняется ТОЛЬКО из колбэков реального WS-соединения (см. ниже) —
// никогда синхронно в теле эффекта, иначе react-hooks/set-state-in-effect
// (эффект без активной подписки, только синхронизирующий производное
// значение, — по факту тот самый анти-паттерн, который правило и ловит).
// Начальное значение — `connecting`: типичный случай — подключение начинается
// сразу при монтировании хука (RoomPage монтируется один раз на комнату);
// при повторном подключении (смена conferenceId/token) статус до первого
// события нового сокета может на короткое время показывать значение от
// предыдущего соединения — не считается веб-сокет-ready до первого
// onopen/onerror/onclose. Когда чат выключен/данных для подключения ещё
// нет, наружу отдаём производный статус `closed` без всякого state — см.
// `status` ниже.
const [wsStatus, setWsStatus] = useState<ChatConnectionStatus>('connecting')
const [statusMessage, setStatusMessage] = useState<string | null>(null)
const [unavailable, setUnavailable] = useState(false)
const wsRef = useRef<WebSocket | null>(null)
const canConnect = enabled && Boolean(conferenceId) && Boolean(token)
useEffect(() => {
if (!canConnect || !conferenceId || !token) {
return
}
// Флаг «эффект пересоздан/компонент размонтирован» — без него обработчики
// СТАРОГО сокета (onclose и т.п.), сработавшие асинхронно уже после того,
// как эффект пересоздался для нового conferenceId/token, могли бы
// перетереть состояние уже актуального соединения.
let stale = false
const ws = new WebSocket(buildChatWsUrl(conferenceId))
wsRef.current = ws
ws.onopen = () => {
if (stale) return
setMessages([])
setStatusMessage(null)
setUnavailable(false)
setWsStatus('open')
ws.send(JSON.stringify({ type: 'auth', token }))
}
ws.onmessage = (event) => {
if (stale) return
let frame: IncomingFrame
try {
frame = JSON.parse(event.data as string) as IncomingFrame
} catch {
return
}
if (frame.type === 'history') {
setMessages(frame.messages)
} else if (frame.type === 'message') {
setMessages((prev) => [...prev, frame.message])
} else if (frame.type === 'error') {
// Ошибка отдельной операции (например, отклонённое сообщение) — соединение
// не рвётся, просто короткое пояснение пользователю.
setStatusMessage(`Ошибка чата: ${frame.code}`)
}
}
ws.onerror = () => {
if (stale) return
setWsStatus('error')
}
ws.onclose = (event) => {
wsRef.current = null
if (stale) return
if (event.code === CLOSE_TOKEN_INVALID) {
setWsStatus('error')
setStatusMessage('Сессия чата истекла — обновите страницу, чтобы переподключиться')
} else if (event.code === CLOSE_FORBIDDEN) {
setWsStatus('error')
setStatusMessage('Нет доступа к чату этой конференции')
} else if (event.code === CLOSE_UNAVAILABLE) {
setWsStatus('closed')
setUnavailable(true)
} else {
setWsStatus('closed')
}
}
return () => {
stale = true
wsRef.current = null
ws.close()
}
}, [canConnect, conferenceId, token])
const sendMessage = useCallback((text: string) => {
const trimmed = text.trim()
if (!trimmed || trimmed.length > 2000) return
const ws = wsRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'message', text: trimmed }))
}, [])
// Наружу — производный статус: пока подключаться нечем (выключено/нет
// conferenceId/token), всегда `closed`, даже если внутренний `wsStatus`
// ещё хранит значение от предыдущего подключения.
const status: ChatConnectionStatus = canConnect ? wsStatus : 'closed'
return { messages, status, statusMessage, unavailable, sendMessage }
}

View File

@@ -0,0 +1,48 @@
import { useCallback, useEffect, useState, type RefObject } from 'react'
import { useToast } from '@/components/ui/ToastProvider'
interface UseFullscreenResult {
/** `document.fullscreenEnabled` — если `false`, кнопку в тулбаре нужно скрыть (программный фуллскрин недоступен). */
supported: boolean
/** Активен ли полноэкранный режим сейчас. */
active: boolean
/** Войти/выйти из полноэкранного режима для контейнера `ref`. */
toggle: () => void
}
/**
* Полноэкранный режим комнаты конференции (Fullscreen API, MDN).
* Цель — корневой контейнер комнаты
* (`div[data-theme="room"]` в `RoomPage.tsx`), чтобы тулбар и чат оставались
* видны внутри фуллскрина.
*
* Единственный источник истины о состоянии — событие `fullscreenchange`
* документа, а не промис `requestFullscreen()`: выход по Esc браузер
* выполняет сам, без обратного вызова нашего кода, и событие — единственный
* способ об этом узнать.
*/
export function useFullscreen(ref: RefObject<HTMLElement | null>): UseFullscreenResult {
const toast = useToast()
const supported = typeof document !== 'undefined' && document.fullscreenEnabled
const [active, setActive] = useState(() => typeof document !== 'undefined' && document.fullscreenElement !== null)
useEffect(() => {
function handleChange() {
setActive(document.fullscreenElement !== null)
}
document.addEventListener('fullscreenchange', handleChange)
return () => document.removeEventListener('fullscreenchange', handleChange)
}, [])
const toggle = useCallback(() => {
if (document.fullscreenElement) {
void document.exitFullscreen()
return
}
ref.current?.requestFullscreen().catch(() => {
toast.show('Не удалось включить полноэкранный режим', 'error')
})
}, [ref, toast])
return { supported, active, toggle }
}

View File

@@ -0,0 +1,183 @@
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'
import { useToast } from '@/components/ui/ToastProvider'
/** Какой вид мини-плеера сейчас активен — влияет на то, что рисовать в основном окне (см. `RoomPage.tsx`). */
type PiPMode = 'document' | 'video' | null
interface UseRoomPiPResult {
/** Доступен ли мини-плеер хоть в каком-то виде (Document PiP либо video-фолбэк) — `false` в Firefox, кнопку нужно скрыть. */
supported: boolean
/** Активен ли сейчас мини-плеер (в любом режиме). */
active: boolean
mode: PiPMode
/** Открытое PiP-окно — рендер сетки участников в него через `createPortal` (вызывающий код, `RoomPage.tsx`); `null` вне режима `'document'`. */
pipWindow: Window | null
/** Включить/выключить мини-плеер. ВАЖНО: вызывать СИНХРОННО из `onClick` — оба API требуют user gesture (transient activation), никакого `await` до первого вызова. */
toggle: () => void
}
/**
* Копирует таблицы стилей текущего документа в PiP-окно (рецепт MDN для
* Document Picture-in-Picture — окно открывается с пустым `<head>`, своих
* стилей не наследует). Внешние (cross-origin) таблицы, у которых `cssRules`
* недоступны из-за CORS, переносятся ссылкой (`<link>`), а не инлайном.
*/
function copyStyleSheets(target: Document) {
for (const sheet of Array.from(document.styleSheets)) {
try {
const cssText = Array.from(sheet.cssRules)
.map((rule) => rule.cssText)
.join('\n')
const style = target.createElement('style')
style.textContent = cssText
target.head.appendChild(style)
} catch {
if (sheet.href) {
const link = target.createElement('link')
link.rel = 'stylesheet'
link.href = sheet.href
target.head.appendChild(link)
}
}
}
}
/**
* Мини-плеер конференции («картинка в картинке»,
* Приоритет:
* 1. **Document Picture-in-Picture** (Chrome/Edge 116+) — активное окно (одна
* крупная плитка, без карусели/грида — см. `variant="pip"` у `RoomStage`)
* рисует вызывающий код через `createPortal(<RoomStage variant="pip"/>,
* pipWindow.document.body)`, React-контекст `LiveKitRoom` при этом
* остаётся в основном дереве (хуки треков продолжают работать). Основное
* окно вместо сцены показывает заглушку — см. `mode === 'document'` в
* `RoomPage.tsx`.
* 2. **Фолбэк video-PiP** (Safari, `document.pictureInPictureEnabled`) —
* классический `videoEl.requestPictureInPicture()` на видео активного
* спикера; сцена в основном окне остаётся как есть.
* 3. Ничего не поддерживается (Firefox) — `supported: false`, кнопку в
* тулбаре нужно СКРЫТЬ, а не задизейблить (ничего не обещаем).
*
* Переход в фуллскрин из PiP и обратно ничем дополнительно не
* синхронизируется: браузер сам выходит из фуллскрина при входе в PiP,
* `useFullscreen.active` обновится своим `fullscreenchange`.
*/
export function useRoomPiP(containerRef: RefObject<HTMLElement | null>): UseRoomPiPResult {
const toast = useToast()
const [pipWindow, setPipWindow] = useState<Window | null>(null)
const [videoPipActive, setVideoPipActive] = useState(false)
const hasDocumentPiP = typeof window !== 'undefined' && 'documentPictureInPicture' in window
const hasVideoPiP = typeof document !== 'undefined' && document.pictureInPictureEnabled
// Video-PiP фолбэк: состояние читаем из событий входа/выхода — они летят
// на document независимо от того, какой именно <video> сейчас в PiP.
useEffect(() => {
function handleEnter() {
setVideoPipActive(true)
}
function handleLeave() {
setVideoPipActive(false)
}
document.addEventListener('enterpictureinpicture', handleEnter, true)
document.addEventListener('leavepictureinpicture', handleLeave, true)
return () => {
document.removeEventListener('enterpictureinpicture', handleEnter, true)
document.removeEventListener('leavepictureinpicture', handleLeave, true)
}
}, [])
// Document PiP: пользователь может закрыть окно крестиком ОС, минуя нашу
// кнопку — единственный надёжный сигнал об этом, `pagehide` самого окна.
useEffect(() => {
if (!pipWindow) return
function handlePageHide() {
setPipWindow(null)
}
pipWindow.addEventListener('pagehide', handlePageHide)
return () => pipWindow.removeEventListener('pagehide', handlePageHide)
}, [pipWindow])
// Зеркало `pipWindow` в ref — для cleanup-эффекта ниже: при размонтировании
// самого хука (уход с RoomPage при активном PiP — навигация после
// onDisconnected, смена :slug и т.п.) эффект с пустыми зависимостями видит
// только значение на момент МОНТИРОВАНИЯ, если брать его из состояния
// напрямую; ref всегда содержит актуальное значение на момент unmount.
const pipWindowRef = useRef<Window | null>(null)
useEffect(() => {
pipWindowRef.current = pipWindow
}, [pipWindow])
// Закрыть осиротевшее PiP-окно при размонтировании хука — иначе при выходе
// из конференции (без явного «Вернуть в это окно») окно браузера остаётся
// висеть само по себе (WICG-рекомендация для Document PiP). Объявлен ПОСЛЕ
// эффекта с `pagehide`, поэтому при unmount его подписка уже снята до
// вызова `close()` — повторный `setPipWindow(null)` из `handlePageHide` не
// произойдёт (да и был бы безвреден, если бы порядок был другим: `close()`
// на уже закрытом окне — no-op, а обновление состояния уже
// размонтированного компонента React просто игнорирует).
useEffect(() => {
return () => {
pipWindowRef.current?.close()
}
}, [])
const toggle = useCallback(() => {
if (hasDocumentPiP) {
if (pipWindow) {
pipWindow.close()
setPipWindow(null)
return
}
// requestWindow вызывается СИНХРОННО прямо в обработчике клика — это и
// есть user gesture (transient activation); никакой асинхронной
// подготовки/await до этого вызова быть не должно.
window.documentPictureInPicture
?.requestWindow({ width: 480, height: 320 })
.then((win) => {
copyStyleSheets(win.document)
// Комната — всегда своя тёмная тема;
// `[data-theme="room"]` на <html> PiP-окна включает переменные
// --color-room-* из скопированного tokens.css.
win.document.documentElement.setAttribute('data-theme', 'room')
setPipWindow(win)
})
.catch(() => {
toast.show('Не удалось открыть мини-окно конференции', 'error')
})
return
}
if (hasVideoPiP) {
if (document.pictureInPictureElement) {
void document.exitPictureInPicture()
return
}
const container = containerRef.current
// `.lk-focus-layout video` без уточнения брал бы ПЕРВОЕ video в DOM —
// это миниатюра карусели (она в разметке идёт раньше фокус-плитки), а
// не крупный контент. Фокус-плитка — прямой ребёнок `.lk-focus-layout`
// (`> .lk-participant-tile`), карусельные — вложены внутрь `.lk-carousel`
// ещё на уровень глубже, поэтому прямой child-селектор берёт именно её —
// включая демонстрацию экрана, когда она в фокусе.
const video =
container?.querySelector<HTMLVideoElement>('.lk-focus-layout > .lk-participant-tile video') ??
container?.querySelector<HTMLVideoElement>('video')
if (!video) {
toast.show('Нет активного видео для мини-окна', 'error')
return
}
video.requestPictureInPicture().catch(() => {
toast.show('Не удалось открыть мини-окно видео', 'error')
})
}
}, [hasDocumentPiP, hasVideoPiP, pipWindow, containerRef, toast])
return {
supported: hasDocumentPiP || hasVideoPiP,
active: pipWindow !== null || videoPipActive,
mode: pipWindow ? 'document' : videoPipActive ? 'video' : null,
pipWindow,
toggle,
}
}

View File

@@ -0,0 +1,77 @@
import { useCallback, useEffect, useState } from 'react'
/**
* Ключ localStorage для явного выбора темы пользователем. ДОЛЖЕН совпадать с
* инлайн-скриптом в `frontend/index.html` (защита от FOUC — атрибут
* `data-theme` проставляется на `<html>` ДО первого рендера React).
*/
const STORAGE_KEY = 'vidconf-theme'
type ExplicitTheme = 'light' | 'dark'
function readStoredTheme(): ExplicitTheme | null {
try {
const value = localStorage.getItem(STORAGE_KEY)
return value === 'light' || value === 'dark' ? value : null
} catch {
// localStorage недоступен (приватный режим/политики браузера) — считаем,
// что явного выбора нет, тема определится системной prefers-color-scheme.
return null
}
}
function applyThemeAttribute(theme: ExplicitTheme | null) {
if (theme) {
document.documentElement.setAttribute('data-theme', theme)
} else {
document.documentElement.removeAttribute('data-theme')
}
}
/**
* Переключатель темы оболочки (см. design/DESIGN_SYSTEM.md §0.1,
* design/mockups/dark/README.md «Переключатель темы»).
*
* Контракт: явный выбор пользователя (`data-theme="light"`/`"dark"` на
* `<html>`) сохраняется в localStorage и переживает перезагрузку; пока
* выбора нет — действует системная тема (`prefers-color-scheme`), атрибут не
* проставлен вообще. В макетах переключатель — сегментированный контрол из
* двух явных кнопок «Светлая»/«Тёмная» (не циклический тумблер и без
* третьего состояния «системная» в UI) — клик по любой из них фиксирует
* выбор навсегда (до следующего клика по другой кнопке); вернуться к
* автодетекту без явного выбора через этот контрол нельзя, ровно как в
* утверждённых макетах.
*/
export function useTheme() {
const [explicit, setExplicit] = useState<ExplicitTheme | null>(() => readStoredTheme())
const [systemPrefersDark, setSystemPrefersDark] = useState(
() => window.matchMedia('(prefers-color-scheme: dark)').matches,
)
// Живое отслеживание системной темы — чтобы подсветка активной кнопки
// оставалась верной, если пользователь ещё не делал явного выбора и сменил
// тему ОС, не покидая вкладку.
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (event: MediaQueryListEvent) => setSystemPrefersDark(event.matches)
media.addEventListener('change', handleChange)
return () => media.removeEventListener('change', handleChange)
}, [])
useEffect(() => {
applyThemeAttribute(explicit)
}, [explicit])
const setTheme = useCallback((theme: ExplicitTheme) => {
try {
localStorage.setItem(STORAGE_KEY, theme)
} catch {
// Сохранение недоступно — тема продержится до перезагрузки страницы.
}
setExplicit(theme)
}, [])
const resolved: ExplicitTheme = explicit ?? (systemPrefersDark ? 'dark' : 'light')
return { resolved, setTheme }
}