* fix(preview): stream-gears FLV 在解析点旁路,不等写盘循环整 GOP 落盘 httpflv 的写盘循环把 tag 攒在 flv_tags_cache 里,等下一个关键帧到来才把整个 GOP 一起写出——预览的旁路点原来就挂在这个写出循环里,于是预览端每个 GOP 间隔(B 站 常见 2–4 s)收到一次整 GOP 的突发、其余时间一个字节都不来,且整体落后一个 GOP。 播放器拿到的缓冲恰好在下一个突发到达时耗尽,零余量:同机 0 ms 下勉强连上,链路 上稍有 RTT / 抖动就 waiting;GOP 长于 mpegts.js 的追帧阈值时更是每个 GOP 都跳一次 再停一次(实测 GOP 4.2 s 的房间同机也有 85% 时间处于 waiting)。 把旁路挪到 tag 解析完、进缓存之前:预览按 tag 实时收到,与 mesio 路径(在条目进 管线前 tee)一致。写盘循环一个字节不变;旁路仍只是 push,不 await、不返回错误。 实测(arrival-probe,40 s):同一斗鱼房间直连 CDN 到达连续(最大静默 0.24 s), 中转修前每 2.0 s 一个 ~940 KB 突发、20 个静默间隙全部 > 1 s。 Refs #1712 #1713 * feat(preview): 快照保留最近 6 s 的 GOP,新订阅者起播就有几秒缓冲 原来快照只含「当前 GOP」:新订阅者从零到一个 GOP 之间的缓冲起步,之后到达 = 消耗, 缓冲永远这么薄,链路上几十到几百毫秒的抖动就 waiting。浏览器直连 CDN 时 CDN 会先 给几秒的 GOP 缓存,中转得给同样的深度。 PreviewSink 里多一个已完成 GOP 的队列(按关键帧到达时刻计,保留 SNAPSHOT_WINDOW = 6 s 以内的),快照 = 文件头 + 序列头 + 队列里的 GOP + 当前 GOP,实际深度在 6 s 到 6 s + 一个 GOP 之间。字节上限 MAX_SNAPSHOT_BYTES = 16 MB(6 s × 20 Mbps),超过先丢最旧的 GOP;单个 GOP 超过 MAX_GOP_BYTES 作废时历史一并放掉(不再连续);序列头内容变了或 fMP4 换了 init 时历史也放掉(旧 GOP 与新参数不配),内容相同的重发(stream-gears 分段时重放 onMetaData / 序列头)不影响。热路径只多几次 usize 比较与一次 VecDeque push,仍不 await、不返回错误;无订阅者时同样保留(第一个订阅者也要立刻有缓冲), 每路多占约 6 s 的码率(1080p 2–5 MB)。 `with_snapshot_window(Duration::ZERO)` 给只关心分块判定的测试用。 Refs #1712 #1713 * fix(preview): 客户端掉队时从最近关键帧原地续播,不再断开重连 广播缓冲被覆盖(Lagged)时原来直接结束响应,浏览器看到「连接已断开,2 秒后重连」 的加载态,再花一次 HTTP 往返 + 等下一个关键帧才恢复;带宽长期偏紧的链路上就是 反复「触发加载」。 现在 hub 多一个 `resubscribe(previous, timeout)`:复用原订阅的连接许可(不占第二 个名额),向写入端再要一份从最近关键帧起的快照与新接收端;响应在同一条连接里续 发这份快照——FLV 去掉文件头(`Subscription::snapshot_after_header`,解码器不能中途 再收到文件头;序列头照给,掉队期间可能换过),fMP4 的 init segment 照发(MSE 接受 中途再来一份)。mpegts.js 的 remuxer 会把批次之间的时间戳跳变收平,画面表现为跳 过一段而不是转圈;一次响应最多 MAX_RESYNCS_PER_RESPONSE = 20 次,再多说明客户端 长期跟不上,结束响应交给播放器。每次重对齐 warn 日志带 skipped 与累计次数。 Refs #1712 #1713 * perf(preview): 客户端追赶积压时把广播缓冲里攒着的分块合并写出 实时转发每收到一个分块就写一个 chunked 帧(FLV 一个 tag 几百字节到几十 KB)。 客户端跟得上时缓冲里本来就只有一个,无从合并;追赶积压(起播快照刚发完、链路 抖动之后)时缓冲里攒着几十上百个,逐个写出是几十上百次 socket 写与 chunked 帧。 `coalesce` 在 `recv()` 到手后用 `try_recv` 把已经在缓冲里的后续分块合并成一个 响应块(上限 COALESCE_BYTES = 64 KB),缓冲空着就原样透传不复制,不引入任何等待; 合并途中撞上 Lagged 时把跳过数交回主循环按掉队处理,不吞掉。 Refs #1712 #1713 * fix(ui): 中转模式的播放器维持约 5 s 缓冲,用倍速而不是跳变追帧 mpegts.js 原来对中转、直连一律 liveBufferLatencyMaxLatency 3 s / MinRemain 0.5 s: 缓冲一超过 3 s 就跳到末尾前 0.5 s,等于把服务端刚给的几秒快照直接扔掉,之后只剩 0.5 s 余量,链路一抖就 waiting;GOP 长于 3 s 的流更是每个 GOP 跳一次再停一次。 按取流方式分两套参数(`Players` 新 prop `transport`,LivePreviewPlayer 按实际 source 传): - relay:liveSync 倍速追帧——落后 > 9 s 用 1.1× 追到 5 s 附近,≤ 5 s 恢复 1×; 只在落后 > 20 s(长时间暂停后)才直接跳到末尾前 5 s。与服务端 6 s 的快照窗口配合, 播放器起播就有约 5 s 缓冲并一直保持。 - direct:沿用原参数(CDN 自带 GOP 缓存,ForgQi 实测异地直连不卡,不动)。 fMP4 的 MediaSource 播放器(只在中转路径上)同样改成:起播对齐到末尾前 5 s、 落后 > 9 s 用 1.1× 追、> 20 s 才跳;另加空洞跳过——服务端掉队重对齐后分片时间戳 前跳,缓冲区出现空洞,停在空洞前沿时跳到下一段起点,不再原地转圈。 Refs #1712 #1713 * style(preview): cargo fmt 与 clippy(len_zero) * feat(preview): /live 支持 snapshot_ms,起播快照按播放器要的缓冲深度回溯 新订阅者拿到多深的快照,播放器就有多深的起播缓冲,也就是多长的中转延迟。之前一律给整个 6 s 保留窗口,只适合「流畅」档;低延迟档要 2 s 就只该拿 2 s,而不是拿 6–10 s 再用 1.1 倍速追 40 s。 - PreviewHub::subscribe_with_depth(depth):从当前 GOP 往前取,取到第一个关键帧到达时刻早于 「现在 − depth」的 GOP 为止(含),起播缓冲落在 depth 到 depth + 一个 GOP 之间;Some(ZERO) 只给当前 GOP,None 给整个窗口。Subscription 记住 depth,Lagged 重新对齐沿用。 - GET /v1/streamers/{id}/live?snapshot_ms=2000;不带参数行为不变。「开始直播预览」日志多一个 snapshot_ms 字段。 - 保留窗口 SNAPSHOT_WINDOW 仍是 6 s,服务端内存不变;热路径只在有订阅请求时多一次 VecDeque::make_contiguous(历史只有几个 GOP)。 - 单测 +2:深度切分的边界(零 / 跨线 / 大于全部历史 / 空历史)、带深度订阅与重对齐沿用深度。 Refs #1713 #1716 * feat(ui): 中转预览分「低延迟 / 流畅」两档,默认低延迟 2 s,卡了自动升档 ForgQi 在 #1713 下问「中转有办法不延迟 5 s 也不卡顿吗」。主因(整 GOP 成坨下发)修掉后 5 s 是 过度配置:同一条模拟链路(60 ms RTT ± 16 ms、丢包重传停顿、20 Mbps)上四路真实房间扫 1 / 1.5 / 2 / 3 / 5 s 目标缓冲各播 180 s,三路 FLV 从 1 s 起就 0 次 waiting;只有 HLS 来的 fMP4 要盖住一个分片(B 站 1–3 s),3 s 起 0 次、4 s 余量最低约 1–2 s。 - app/lib/live-buffer.ts:缓冲控制统一到一处(原来 mpegts.js 的 liveSync 管 FLV、fMP4 播放器 自己一套 chase):起播跳到「末尾 − target」,快照落地那几秒里超了再对齐(慢链路上几 MB 要 几秒才到齐,每对齐一次多看 2 s、最多 15 s),稳态超 max 倍速 / 超 hard 跳 / 跳过重对齐留下的 空洞,稳态里缓冲耗尽一次报 onStall。档位:low = FLV 2 s / fMP4·TS 4 s,smooth = 5 s (目标与原 0d41140 相同,倍速阈值 9 → 7)。mpegts.js 在中转模式下关掉自己的追帧,直连一行未动。 - /live 请求带 snapshot_ms = 目标缓冲,起播就是目标深度,不再从 6–10 s 追回来。 - 低延迟档在稳态里卡一次 ≥ 1 s 或 60 s 内两次,本次播放升到流畅档重连(更深的快照起播), 角标「卡顿,已切到流畅档」;换房间或改偏好即复位。 - 弹层标题栏与监视器工具栏各一个「低延迟 / 流畅」开关,记 localStorage biliup.preview.latency, 默认低延迟;直连模式下不显示。 实测(同一模拟链路,180 s,hub 最新时间戳 → 已渲染):低延迟档三路 FLV 延迟中位 2.1–2.4 s、 fMP4 2.8–3.7 s,四路 0 次 waiting;流畅档 5.1 s。每 18 s 停 2.5 s 的链路上低延迟档卡两次后 自动升档,之后 130 s 0 次。 Refs #1713 #1716
319 lines
13 KiB
TypeScript
319 lines
13 KiB
TypeScript
'use client'
|
||
import React, { useState } from 'react'
|
||
import useSWR from 'swr'
|
||
import { Button, Empty, Select, Switch, Tag, Tooltip, Typography } from '@douyinfe/semi-ui'
|
||
import { IconPlay, IconStop } from '@douyinfe/semi-icons'
|
||
import { fetcher, LiveStreamerEntity, StreamerInfo } from '@/app/lib/api-streamer'
|
||
import { LIVE_STATUS, platformName } from '@/app/lib/status'
|
||
import {
|
||
canPreview,
|
||
formatRate,
|
||
liveImageUrl,
|
||
previewDisabledReason,
|
||
previewFormatLabel,
|
||
SLOW_REFRESH_MS,
|
||
STREAMERS_REFRESH_MS,
|
||
usePreviewTransport,
|
||
} from '@/app/lib/use-dashboard'
|
||
import { useBoolPref, useChoicePref } from '@/app/lib/use-local-pref'
|
||
import { useDanmakuFeed } from '@/app/lib/danmaku-feed'
|
||
import { LivePreviewPlayer, useLatencyProfile } from './LivePreview'
|
||
import { type LatencyProfile, RELAY_PROFILES, RELAY_PROFILES_SEGMENTED } from '@/app/lib/live-buffer'
|
||
import { LiveRateChart, SPARK_RATE_WINDOW_MS } from './LiveRateChart'
|
||
import styles from './live-monitor.module.scss'
|
||
|
||
/**
|
||
* 监视器默认同屏路数。每路小窗占用对应直播间的 1 个预览连接
|
||
* (服务端每间上限见 `PREVIEW_MAX_SUBSCRIBERS_PER_ROOM`,进程上限 16),改这里即可换默认值。
|
||
*/
|
||
export const DEFAULT_MONITOR_TILES = 4
|
||
/**
|
||
* 浏览器对同一主机的 HTTP/1.1 并发连接只有 6 个:每路小窗占 1 个长连接,弹幕复用 1 个,
|
||
* 列表轮询还要留 1 个,所以 HTTP/1.1 下最多 4 路;页面走 HTTP/2、HTTP/3(TLS 反代)时不受此限,
|
||
* 放开 6 / 9 路。
|
||
*/
|
||
export const MONITOR_TILE_OPTIONS_H1 = [1, 2, 4]
|
||
export const MONITOR_TILE_OPTIONS_H2 = [1, 2, 4, 6, 9]
|
||
|
||
function tileOptions(): number[] {
|
||
if (typeof window === 'undefined') return MONITOR_TILE_OPTIONS_H1
|
||
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined
|
||
const proto = nav?.nextHopProtocol ?? ''
|
||
return proto === 'h2' || proto === 'h3' ? MONITOR_TILE_OPTIONS_H2 : MONITOR_TILE_OPTIONS_H1
|
||
}
|
||
const TILES_KEY = 'biliup.monitor.maxTiles'
|
||
/** 多路同屏默认不开弹幕(每路一条 SSE、多路叠加太吵),开关记在本地 */
|
||
const DANMAKU_KEY = 'biliup.monitor.danmaku'
|
||
/** 每路小窗下的 60 s 码率 sparkline,默认关(多路同时画 + 每秒轮询),开关记在本地 */
|
||
const RATE_CHART_KEY = 'biliup.monitor.rateChart'
|
||
|
||
/** 用户的选择:手动激活的顺序(先进先出)与手动停掉的房间;其余由数据推导。 */
|
||
interface Selection {
|
||
order: number[]
|
||
stopped: number[]
|
||
}
|
||
|
||
/**
|
||
* 由「用户选择 + 当前可预览房间 + 路数上限」推导正在播放的房间列表(纯函数,无副作用):
|
||
* 先保留用户激活且仍可预览的,再按列表顺序自动补满,手动停掉的不自动补。
|
||
*/
|
||
export function resolveActive(
|
||
selection: Selection,
|
||
previewableIds: number[],
|
||
maxTiles: number
|
||
): number[] {
|
||
const previewable = new Set(previewableIds)
|
||
const stopped = new Set(selection.stopped)
|
||
const active: number[] = []
|
||
for (const id of selection.order) {
|
||
if (previewable.has(id) && !active.includes(id)) active.push(id)
|
||
}
|
||
for (const id of previewableIds) {
|
||
if (active.length >= maxTiles) break
|
||
if (!active.includes(id) && !stopped.has(id)) active.push(id)
|
||
}
|
||
return active.slice(Math.max(0, active.length - maxTiles))
|
||
}
|
||
|
||
/**
|
||
* 实时监视:所有正在录制的直播间的小窗网格。
|
||
* 可预览且在路数上限内的直接播放(默认静音);超出的显示封面,点击替换最早开始的一路;
|
||
* 下载器不能旁路的显示封面与原因。独立组件,可整体挪到别的页面。
|
||
*/
|
||
export default function LiveMonitor() {
|
||
const { Text } = Typography
|
||
const { data: streamers, error, isLoading } = useSWR<LiveStreamerEntity[]>(
|
||
'/v1/streamers',
|
||
fetcher,
|
||
{ refreshInterval: STREAMERS_REFRESH_MS }
|
||
)
|
||
const { data: infos } = useSWR<StreamerInfo[]>('/v1/streamer-info', fetcher, {
|
||
refreshInterval: SLOW_REFRESH_MS,
|
||
})
|
||
const options = tileOptions()
|
||
const [maxTiles, setMaxTiles] = useChoicePref(TILES_KEY, options, DEFAULT_MONITOR_TILES)
|
||
const [danmaku, setDanmaku] = useBoolPref(DANMAKU_KEY, false)
|
||
const [latency, setLatency] = useLatencyProfile()
|
||
const transport = usePreviewTransport()
|
||
const [rateChart, setRateChart] = useBoolPref(RATE_CHART_KEY, false)
|
||
const [selection, setSelection] = useState<Selection>({ order: [], stopped: [] })
|
||
|
||
const live = (streamers ?? []).filter((s) => s.status === LIVE_STATUS)
|
||
const previewable = live.filter(canPreview)
|
||
const previewableIds = previewable.map((s) => s.id)
|
||
const active = resolveActive(selection, previewableIds, maxTiles)
|
||
const activeSet = new Set(active)
|
||
// 正在播且平台有弹幕客户端的那几路共用一条弹幕连接
|
||
const danmakuIds = previewable.filter((s) => activeSet.has(s.id) && s.preview?.danmaku).map((s) => s.id)
|
||
const danmakuFeed = useDanmakuFeed(danmakuIds, danmaku)
|
||
|
||
const infoByUrl = new Map<string, StreamerInfo>()
|
||
for (const i of infos ?? []) {
|
||
const cur = infoByUrl.get(i.url)
|
||
if (!cur || i.date > cur.date) infoByUrl.set(i.url, i)
|
||
}
|
||
|
||
// 激活:满了就替换最早开始的一路(先进先出)
|
||
const activate = (id: number) => {
|
||
setSelection((prev) => {
|
||
const current = resolveActive(prev, previewableIds, maxTiles)
|
||
const kept = current.length >= maxTiles ? current.slice(1) : current
|
||
return {
|
||
order: [...kept.filter((x) => x !== id), id],
|
||
stopped: prev.stopped.filter((x) => x !== id),
|
||
}
|
||
})
|
||
}
|
||
const stop = (id: number) => {
|
||
setSelection((prev) => ({
|
||
order: prev.order.filter((x) => x !== id),
|
||
stopped: prev.stopped.includes(id) ? prev.stopped : [...prev.stopped, id],
|
||
}))
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className={styles.center}>
|
||
<Empty title="加载失败" description="无法获取主播列表,请检查后端连接" />
|
||
</div>
|
||
)
|
||
}
|
||
if (!isLoading && live.length === 0) {
|
||
return (
|
||
<div className={styles.center}>
|
||
<Empty
|
||
title="当前没有正在录制的直播间"
|
||
description="开播并开始录制后,这里会以小窗形式同屏显示各路画面(stream-gears / mesio 下载器)"
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const full = active.length >= maxTiles
|
||
return (
|
||
<section className={styles.monitor}>
|
||
<div className={styles.toolbar}>
|
||
<div className={styles.summary}>
|
||
<Text strong>
|
||
正在监视 {active.length} / {maxTiles} 路
|
||
</Text>
|
||
<Text type="tertiary" size="small">
|
||
录制中 {live.length} 间 · 可预览 {previewable.length} 间
|
||
</Text>
|
||
</div>
|
||
<div className={styles.controls}>
|
||
<Tooltip content="所有小窗一起开关;只有平台实现了弹幕客户端的房间会显示">
|
||
<span className={styles.switchRow}>
|
||
<Text type="tertiary" size="small">
|
||
弹幕
|
||
</Text>
|
||
<Switch size="small" checked={danmaku} onChange={(v) => setDanmaku(!!v)} aria-label="弹幕" />
|
||
</span>
|
||
</Tooltip>
|
||
{transport === 'relay' ? (
|
||
<>
|
||
<Tooltip
|
||
content={`中转缓冲深度,也就是画面延迟。低延迟:FLV 约 ${RELAY_PROFILES.low.target} s、HLS 分片流约 ${RELAY_PROFILES_SEGMENTED.low.target} s,卡了的小窗自动切到流畅;流畅:约 ${RELAY_PROFILES.smooth.target} s`}
|
||
>
|
||
<Text type="tertiary" size="small">
|
||
延迟
|
||
</Text>
|
||
</Tooltip>
|
||
<Select
|
||
size="small"
|
||
value={latency}
|
||
onChange={(v) => setLatency(v as LatencyProfile)}
|
||
optionList={[
|
||
{ value: 'low', label: '低延迟' },
|
||
{ value: 'smooth', label: '流畅' },
|
||
]}
|
||
style={{ width: 96 }}
|
||
aria-label="中转延迟"
|
||
/>
|
||
</>
|
||
) : null}
|
||
<Tooltip content="每路小窗下方显示最近 60 秒的写盘速率折线(每秒向后端拉一次速率,所有小窗共用一次请求)">
|
||
<span className={styles.switchRow}>
|
||
<Text type="tertiary" size="small">
|
||
码率图
|
||
</Text>
|
||
<Switch size="small" checked={rateChart} onChange={(v) => setRateChart(!!v)} aria-label="码率图" />
|
||
</span>
|
||
</Tooltip>
|
||
<Text type="tertiary" size="small">
|
||
同屏路数
|
||
</Text>
|
||
<Select
|
||
size="small"
|
||
value={maxTiles}
|
||
onChange={(v) => setMaxTiles(Number(v))}
|
||
optionList={options.map((n) => ({ value: n, label: `${n} 路` }))}
|
||
style={{ width: 88 }}
|
||
aria-label="同屏路数"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<Text type="tertiary" size="small" className={styles.hint}>
|
||
画面来自各直播间正在写盘的同一路流,默认静音;每路小窗占用该直播间的 1 个预览连接。
|
||
超出路数的直播间显示封面,点击可替换最早开始的一路。
|
||
</Text>
|
||
<div className={styles.grid}>
|
||
{live.map((s) => {
|
||
const info = infoByUrl.get(s.url)
|
||
const playing = activeSet.has(s.id)
|
||
const can = canPreview(s)
|
||
const reason = can ? null : previewDisabledReason(s)
|
||
const cover = liveImageUrl(s.id, 'cover', s.live_cover_url)
|
||
const rate = formatRate(s.live_bytes_per_sec)
|
||
const name = s.remark || s.url
|
||
return (
|
||
<article
|
||
key={s.id}
|
||
className={`${styles.tile} ${playing ? styles.tilePlaying : ''}`}
|
||
data-id={s.id}
|
||
data-state={playing ? 'playing' : can ? 'idle' : 'unavailable'}
|
||
>
|
||
<header className={styles.tileHead}>
|
||
<span className={styles.tileName} title={name}>
|
||
{name}
|
||
</span>
|
||
<Tag size="small" color="grey">
|
||
{platformName(s.url)}
|
||
</Tag>
|
||
{previewFormatLabel(s.preview?.format) ? (
|
||
<Tag size="small" color="green">
|
||
{previewFormatLabel(s.preview?.format)}
|
||
</Tag>
|
||
) : null}
|
||
<span className={styles.tileRate}>{rate ?? '—'}</span>
|
||
{playing ? (
|
||
<Tooltip content="停止这一路">
|
||
<Button
|
||
size="small"
|
||
theme="borderless"
|
||
type="tertiary"
|
||
icon={<IconStop />}
|
||
aria-label={`停止 ${name}`}
|
||
onClick={() => stop(s.id)}
|
||
/>
|
||
</Tooltip>
|
||
) : null}
|
||
</header>
|
||
{playing ? (
|
||
<LivePreviewPlayer streamer={s} muted compact danmakuFeed={danmaku ? danmakuFeed : null} />
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className={styles.idle}
|
||
disabled={!can}
|
||
onClick={() => activate(s.id)}
|
||
aria-label={
|
||
can ? (full ? `播放 ${name}(替换最早的一路)` : `播放 ${name}`) : `${name}:${reason}`
|
||
}
|
||
>
|
||
{cover ? (
|
||
// 封面走同源代理;失败时隐藏,只剩底色,说明文字仍在
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img
|
||
className={styles.idleCover}
|
||
src={cover}
|
||
alt=""
|
||
aria-hidden="true"
|
||
onError={(e) => {
|
||
e.currentTarget.style.display = 'none'
|
||
}}
|
||
/>
|
||
) : null}
|
||
<span className={styles.idleText}>
|
||
{can ? (
|
||
<>
|
||
<IconPlay />
|
||
{full ? '点击替换最早的一路' : '点击开始预览'}
|
||
</>
|
||
) : (
|
||
reason
|
||
)}
|
||
</span>
|
||
</button>
|
||
)}
|
||
{rateChart ? (
|
||
<LiveRateChart
|
||
id={s.id}
|
||
windowMs={SPARK_RATE_WINDOW_MS}
|
||
variant="sparkline"
|
||
height={32}
|
||
label={`${name} 写盘速率`}
|
||
className={styles.tileSpark}
|
||
/>
|
||
) : null}
|
||
<footer className={styles.tileFoot} title={info?.title || ''}>
|
||
{info?.title || '\u00a0'}
|
||
</footer>
|
||
</article>
|
||
)
|
||
})}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|