feat(admin): рычаги нагрузки медиа — потолок качества публикации и лимит плиток
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 отдаёт актуальные значения, уже подключённый участник не разрывается при смене настройки в другом окне.
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
||||
type SettingsUpdateIn,
|
||||
type TestEmailOut,
|
||||
} from '@/api/admin'
|
||||
import type { SummaryRecipientsMode } from '@/api/conferences'
|
||||
import type { PublishQualityCap, SummaryRecipientsMode } from '@/api/conferences'
|
||||
import { ApiError, errorDetail } from '@/api/client'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
@@ -21,6 +21,20 @@ const SUMMARY_RECIPIENTS_OPTIONS = [
|
||||
{ value: 'owner', label: 'Только организатору' },
|
||||
]
|
||||
|
||||
const PUBLISH_QUALITY_CAP_OPTIONS = [
|
||||
{ value: 'off', label: 'Без ограничения' },
|
||||
{ value: '720p', label: 'Не выше 720p' },
|
||||
{ value: '360p', label: 'Не выше 360p' },
|
||||
{ value: '180p', label: 'Не выше 180p' },
|
||||
]
|
||||
|
||||
const STAGE_MAX_TILES_OPTIONS = [
|
||||
{ value: '25', label: '25 (5×5, без ограничения)' },
|
||||
{ value: '16', label: '16 (4×4)' },
|
||||
{ value: '9', label: '9 (3×3)' },
|
||||
{ value: '4', label: '4 (2×2)' },
|
||||
]
|
||||
|
||||
const AI_LEVEL_LABEL: Record<AiLevel, string> = {
|
||||
min: 'Минимальный (CPU, faster-whisper small + Qwen2.5-3B)',
|
||||
medium: 'Средний',
|
||||
@@ -64,6 +78,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
||||
const [newDomainInput, setNewDomainInput] = useState('')
|
||||
const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled)
|
||||
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '')
|
||||
const [publishQualityCap, setPublishQualityCap] = useState<PublishQualityCap>(data.publish_quality_cap)
|
||||
const [stageMaxTiles, setStageMaxTiles] = useState(data.stage_max_tiles)
|
||||
const [testEmailTo, setTestEmailTo] = useState('')
|
||||
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
|
||||
|
||||
@@ -136,6 +152,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
||||
if (trimmedContactEmail !== (data.contact_email ?? null)) {
|
||||
payload.contact_email = trimmedContactEmail
|
||||
}
|
||||
if (publishQualityCap !== data.publish_quality_cap) payload.publish_quality_cap = publishQualityCap
|
||||
if (stageMaxTiles !== data.stage_max_tiles) payload.stage_max_tiles = stageMaxTiles
|
||||
mutation.mutate(payload)
|
||||
}
|
||||
|
||||
@@ -361,6 +379,51 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-card">
|
||||
<h2>Нагрузка</h2>
|
||||
<p className="desc">
|
||||
Рычаги для инстансов на слабом канале/железе — режут исходящий трафик и нагрузку на
|
||||
устройство участника. Дефолты сохраняют прежнее поведение (без ограничений).
|
||||
</p>
|
||||
|
||||
<div className="settings-card-body settings-card-body--spread">
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label id="settings-quality-cap-label" htmlFor="settings-quality-cap">
|
||||
Потолок качества публикации видео
|
||||
</label>
|
||||
<Select
|
||||
id="settings-quality-cap"
|
||||
aria-labelledby="settings-quality-cap-label"
|
||||
value={publishQualityCap}
|
||||
onChange={(v) => setPublishQualityCap(v as PublishQualityCap)}
|
||||
options={PUBLISH_QUALITY_CAP_OPTIONS}
|
||||
/>
|
||||
<p className="field-hint">
|
||||
Ограничивает битрейт исходящего видео публикующего — снижает нагрузку на его канал и
|
||||
устройство, независимо от размера плитки у смотрящих (adaptiveStream режет с их
|
||||
стороны отдельно)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label id="settings-max-tiles-label" htmlFor="settings-max-tiles">
|
||||
Максимум плиток на экране
|
||||
</label>
|
||||
<Select
|
||||
id="settings-max-tiles"
|
||||
aria-labelledby="settings-max-tiles-label"
|
||||
value={String(stageMaxTiles)}
|
||||
onChange={(v) => setStageMaxTiles(Number(v))}
|
||||
options={STAGE_MAX_TILES_OPTIONS}
|
||||
/>
|
||||
<p className="field-hint">
|
||||
Сверх лимита участники уходят на следующую страницу сетки вместо подписки — меньше
|
||||
одновременных видеопотоков на канал и экран участника
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-card">
|
||||
<h2>Контактный адрес</h2>
|
||||
<p className="desc">
|
||||
|
||||
@@ -206,6 +206,7 @@ export function RoomStage({
|
||||
onPinFocus,
|
||||
raisedHandIdentities,
|
||||
conferenceId,
|
||||
stageMaxTiles,
|
||||
}: {
|
||||
variant?: 'full' | 'pip'
|
||||
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
|
||||
@@ -232,6 +233,8 @@ export function RoomStage({
|
||||
raisedHandIdentities?: Set<string>
|
||||
/** Id конференции (задача B2) — кнопки принудительного мьюта на чужих плитках; игнорируется при `variant="pip"`. */
|
||||
conferenceId?: string
|
||||
/** Потолок админки на число плиток `StageGrid` (`instance_settings.media_limits`); игнорируется при `variant="pip"`. */
|
||||
stageMaxTiles?: number
|
||||
}) {
|
||||
const room = useRoomContext()
|
||||
const isCompact = useIsCompactViewport()
|
||||
@@ -429,14 +432,14 @@ export function RoomStage({
|
||||
function renderMain(): ReactNode {
|
||||
if (effectiveMode === 'tiles') {
|
||||
return (
|
||||
<StageGrid tracks={tracks}>
|
||||
<StageGrid tracks={tracks} maxTiles={stageMaxTiles}>
|
||||
<RoomParticipantTile {...tileProps} />
|
||||
</StageGrid>
|
||||
)
|
||||
}
|
||||
if (effectiveMode === 'live-tiles') {
|
||||
return (
|
||||
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks}>
|
||||
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks} maxTiles={stageMaxTiles}>
|
||||
<RoomParticipantTile {...tileProps} />
|
||||
</StageGrid>
|
||||
)
|
||||
@@ -445,7 +448,7 @@ export function RoomStage({
|
||||
// прежнее поведение: равномерная сетка на всю сцену, а не фокус-плитка.
|
||||
if (sideTracks.length === 0 && !hideOthers) {
|
||||
return (
|
||||
<StageGrid tracks={tracks}>
|
||||
<StageGrid tracks={tracks} maxTiles={stageMaxTiles}>
|
||||
<RoomParticipantTile {...tileProps} />
|
||||
</StageGrid>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, type ReactNode, type RefObject } from 'react'
|
||||
import { useMemo, useRef, type ReactNode, type RefObject } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
TrackLoop,
|
||||
@@ -47,6 +47,17 @@ interface StageGridProps {
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
/** Шаблон плитки — рендерится для каждого трека страницы (как у `GridLayout`, через `TrackLoop`). */
|
||||
children: ReactNode
|
||||
/**
|
||||
* Потолок админки на число одновременно видимых плиток (`instance_settings.media_limits.stage_max_tiles`,
|
||||
* см. `RoomPage`). Не задан — все раскладки `STAGE_GRID_LAYOUTS` доступны как
|
||||
* раньше (текущий максимум сетки — 25, 5×5). Реализовано отсечением раскладок
|
||||
* КРУПНЕЕ потолка из набора, который видит `useGridLayout`: она сама выбирает
|
||||
* бОльшую свободную раскладку, укладывающую всех участников без пагинации,
|
||||
* поэтому урезанный набор просто не даёт ей раздуть сетку сверх лимита —
|
||||
* лишние участники уходят на следующую страницу пагинации (`usePagination`),
|
||||
* то есть перестают быть подписанными треками, а не просто визуально мельче.
|
||||
*/
|
||||
maxTiles?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,7 +75,7 @@ interface StageGridProps {
|
||||
* `.stage-grid-pages` (кнопки со стрелками + счётчик, доступен и мышью, и с
|
||||
* клавиатуры; на тач-экране страницы листаются ещё и свайпом).
|
||||
*/
|
||||
export function StageGrid({ tracks, children }: StageGridProps) {
|
||||
export function StageGrid({ tracks, children, maxTiles }: StageGridProps) {
|
||||
const gridEl = useRef<HTMLDivElement | null>(null)
|
||||
// Хуки библиотеки объявлены с `RefObject<HTMLDivElement>` (типы React 18, где
|
||||
// `current` был readonly и тип вёл себя ковариантно). В типах React 19
|
||||
@@ -72,7 +83,11 @@ export function StageGrid({ tracks, children }: StageGridProps) {
|
||||
// параметр уже не присваивается — приведение безопасно: оба хука только
|
||||
// читают `.current` (ResizeObserver и слушатели touch-событий).
|
||||
const gridRef = gridEl as RefObject<HTMLDivElement>
|
||||
const { layout } = useGridLayout(gridRef, tracks.length, { gridLayouts: STAGE_GRID_LAYOUTS })
|
||||
const gridLayouts = useMemo(
|
||||
() => (maxTiles == null ? STAGE_GRID_LAYOUTS : STAGE_GRID_LAYOUTS.filter((l) => l.columns * l.rows <= maxTiles)),
|
||||
[maxTiles],
|
||||
)
|
||||
const { layout } = useGridLayout(gridRef, tracks.length, { gridLayouts })
|
||||
const pagination = usePagination(layout.maxTiles, tracks)
|
||||
|
||||
useSwipe(gridRef, {
|
||||
|
||||
Reference in New Issue
Block a user