instance_settings.media_limits (publish_quality_cap: off/720p/360p/180p, stage_max_tiles: 4/9/16/25) — новая вкладка «Нагрузка» в админке, дефолты (off/25) сохраняют текущее поведение существующих инсталляций. Настройка отдаётся не только GET /admin/settings, но и в join-ответе (JoinOut) — участнику нужно иметь её на руках ДО публикации трека, а /admin/settings доступен только администратору. Потолок качества применяется через RoomOptions.publishDefaults (videoEncoding + videoSimulcastLayers на пресетах VideoPresets LiveKit) — режет битрейт верхнего слоя симулкаста, реальное разрешение WebRTC подстраивает сам. Лимит плиток — фильтрация STAGE_GRID_LAYOUTS по columns*rows в StageGrid, лишние участники уходят на страницу пагинации вместо подписки. Значение приезжает в joinState вместе с токеном ДО первого рендера LiveKitRoom (RoomPage не рендерит его, пока joinState не заполнен целиком), поэтому смена настройки не переподключает уже вошедшего участника — roomOptions пересчитывается по стабильной ссылке на joinState, которая после подключения не меняется. Проверено вживую на локальном стенде (docker compose --profile media): сохранение/персист настроек, join отдаёт актуальные значения, уже подключённый участник не разрывается при смене настройки в другом окне.
123 lines
5.5 KiB
TypeScript
123 lines
5.5 KiB
TypeScript
import { useState } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { Calendar, Link2, User, Video } from 'lucide-react'
|
||
import { ShellTopbar } from '@/components/layout/ShellTopbar'
|
||
import { AppFooter } from '@/components/layout/AppFooter'
|
||
import { LogoMedallion } from '@/components/ui/LogoMark'
|
||
import { createConference } from '@/api/conferences'
|
||
import { useToast } from '@/components/ui/ToastProvider'
|
||
import '@/styles/lobby.css'
|
||
|
||
/**
|
||
* Лобби — «хаб действий» (см. design/mockups/lobby.html).
|
||
*
|
||
* Предустановленных переговорных комнат больше нет: конференции создаются
|
||
* динамически. Мозаика из 4 плиток — единственный вход в остальные сценарии:
|
||
* мгновенное создание, календарь плановых конференций, вход по ссылке/номеру,
|
||
* «Мои конференции».
|
||
*
|
||
* Отклонение от макета: топбар здесь — переиспользуемый `ShellTopbar` (прямые
|
||
* ссылки Лобби/Календарь/Админка + пилюля пользователя), а не бургер-меню из
|
||
* lobby.html. `ShellTopbar` — общий компонент светлой оболочки (используется
|
||
* также в календаре/админке), поэтому бургер-меню из макета здесь
|
||
* сознательно не воспроизводится.
|
||
*/
|
||
export function LobbyPage() {
|
||
const navigate = useNavigate()
|
||
const toast = useToast()
|
||
const [creating, setCreating] = useState(false)
|
||
|
||
async function handleCreate() {
|
||
if (creating) return
|
||
setCreating(true)
|
||
try {
|
||
const conference = await createConference({})
|
||
if (!conference.join) {
|
||
toast.show('Не удалось создать конференцию. Попробуйте ещё раз', 'error')
|
||
return
|
||
}
|
||
navigate(`/room/${conference.slug}`, {
|
||
state: {
|
||
livekitUrl: conference.join.livekit_url,
|
||
token: conference.join.token,
|
||
title: conference.title,
|
||
conferenceId: conference.join.conference_id,
|
||
chatEnabled: conference.join.chat_enabled,
|
||
publishQualityCap: conference.join.publish_quality_cap,
|
||
stageMaxTiles: conference.join.stage_max_tiles,
|
||
number: conference.number,
|
||
slug: conference.slug,
|
||
},
|
||
})
|
||
} catch {
|
||
toast.show('Не удалось создать конференцию. Попробуйте ещё раз', 'error')
|
||
} finally {
|
||
setCreating(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="lobby-shell">
|
||
<ShellTopbar />
|
||
<main className="hub-main">
|
||
<div className="hub-wrap">
|
||
<div className="hub-intro">
|
||
<p className="eyebrow">[ ДОБРО ПОЖАЛОВАТЬ В VIDCONF ]</p>
|
||
<h1>Что делаем сегодня?</h1>
|
||
</div>
|
||
|
||
<div className="hub">
|
||
<button
|
||
type="button"
|
||
className="action action--create"
|
||
onClick={handleCreate}
|
||
disabled={creating}
|
||
aria-busy={creating}
|
||
>
|
||
<span className="action-icon" aria-hidden="true">
|
||
<Video className="icon" aria-hidden="true" />
|
||
</span>
|
||
<h2 className="action-title">Создать</h2>
|
||
<p className="action-object">конференцию</p>
|
||
<p className="action-note">{creating ? 'Создаём…' : 'начнётся сразу — вы первый участник'}</p>
|
||
</button>
|
||
|
||
<button type="button" className="action action--calendar" onClick={() => navigate('/calendar')}>
|
||
<span className="action-icon" aria-hidden="true">
|
||
<Calendar className="icon" aria-hidden="true" />
|
||
</span>
|
||
<h2 className="action-title">Календарь</h2>
|
||
<p className="action-object">конференций</p>
|
||
<p className="action-note">запланировать · закрепить · повторять</p>
|
||
</button>
|
||
|
||
<button type="button" className="action action--join" onClick={() => navigate('/join')}>
|
||
<span className="action-icon" aria-hidden="true">
|
||
<Link2 className="icon" aria-hidden="true" />
|
||
</span>
|
||
<h2 className="action-title">Подключиться</h2>
|
||
<p className="action-object">к конференции</p>
|
||
<p className="action-note">по ссылке или номеру</p>
|
||
</button>
|
||
|
||
<button type="button" className="action action--mine" onClick={() => navigate('/my-conferences')}>
|
||
<span className="action-icon" aria-hidden="true">
|
||
<User className="icon" aria-hidden="true" />
|
||
</span>
|
||
<h2 className="action-title">Мои</h2>
|
||
<p className="action-object">конференции</p>
|
||
<p className="action-note">закреплённые и предстоящие</p>
|
||
</button>
|
||
|
||
<div className="hub-logo" aria-hidden="true">
|
||
<LogoMedallion className="logo-mark" />
|
||
<span className="logo-word">VidConf</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
<AppFooter />
|
||
</div>
|
||
)
|
||
}
|