feat(ui): 直播预览阶段三——录制中房间实时码率折线图 (#1714)

* feat(api): GET /v1/live-rates 录制中房间的每秒写盘速率瘦端点

/v1/streamers 每次查全表、拼齐全部字段,10 s 一次的节奏不适合调快;码率曲线需要 1 s 粒度,
新加一个只读内存的瘦端点:扫一遍 worker,对每个 Working 的房间读一次阶段一 RateMeter 的窗口均值,
返回 [{id, bytes_per_sec, ts}]。与 /v1/streamers 的 live_bytes_per_sec 同源同值;不新增采样任务、
服务端不存历史、Cache-Control: no-store;注册在 router() 内,--auth 时同一道登录校验。

Part of #1708

* feat(ui): 实时码率数据层与 uPlot 折线组件

- app/lib/use-live-rates.ts:页面内唯一的一份采样源。有订阅者时每秒拉一次 /v1/live-rates,
  全部卸载 / 标签页隐藏即停;历史放模块级环形缓冲(每房间最近 3 分钟),组件卸载不清,
  切走再切回曲线还在;帧里没有的房间补 null 成断口。useSyncExternalStore 订阅,无新帧不重渲染。
- app/ui/RateChart.tsx:uPlot 折线,full(带时间轴 / Y 轴,单位按窗口最大值自动选 KB/s / MB/s,
  Y 从 0 起)与 sparkline(无坐标轴,hover 读数)两种形态;实例在 effect 里建 / 销毁,渲染期不碰 ref,
  StrictMode 双次 effect 不留多余 canvas;配色每次绘制时读 Semi CSS 变量,深浅色切换不重建。
  无采样 / 未录制只显示「—」占位,不画空图。
- app/ui/LiveRateChart.tsx:next/dynamic 懒加载入口 + 当前 / 峰值 / 均值统计条;
  uPlot 与其 CSS 打进独立 chunk(约 23 KB gzip),首屏不含。
- 新依赖 uplot ^1.6.32(本 PR 唯一新依赖)。

Part of #1708

* feat(ui): 三处接入码率折线——弹层可折叠折线、卡片与监视器 sparkline

- 卡片弹层底部新增「写盘速率 · 最近 3 分钟」可折叠区(默认展开,折叠态记 localStorage
  biliup.preview.rateChart):标题行带当前 / 峰值 / 均值,展开时才轮询与加载 uPlot。
- 控制台 / 直播管理页录制中卡片:名称行下一条 60 s sparkline(28 px,hover 显示时刻与数值)。
- 历史记录页「实时监视」:工具栏新增「码率图」开关(默认关,记 biliup.monitor.rateChart),
  开启后每路小窗下方一条 60 s sparkline;所有小窗共用同一次每秒请求。

Part of #1708

* style(api): live_rates 测试按 rustfmt 换行

* fix(ui): sparkline 的 hover 读数浮在折线上方,不再被容器裁掉

* docs(readme): 功能列表补充直播预览(封面 / 播放器 / 弹幕 / 码率曲线)

#1708 三个阶段至此完整,在 README 的功能列表里加一条说明。

Closes #1708

* feat(api): GET /v1/ws/live-rates 用 WebSocket 每秒推送码率帧

review 1(ForgQi):复用现有的 WebSocket 机制推送而不是每秒轮询。WebSocket 不占浏览器对同一主机
HTTP/1.1 的六个并发连接(监视器 4 路视频 + 1 条弹幕 SSE 已用掉五个,这正是当初不用 SSE 的原因),
与日志页 /v1/ws/logs 同款:Origin 与 Host 同源校验、进程内连接数上限(16)、Ping 回 Pong、Close 即止。
连上立刻发一帧,之后每秒一帧,形状与 GET /v1/live-rates 相同;HTTP 版保留给 WebSocket 连不上时回退与 curl 核对。
两个路由都在 router() 内,--auth 下同一道登录校验。单测用手写的最小 WebSocket 客户端走完握手 + 两帧,不引新依赖。

* feat(ui): 码率数据改走 WebSocket;卡片 sparkline 加开关并叠在封面上

review 1(ForgQi):
- use-live-rates.ts 首选一条 WebSocket /v1/ws/live-rates(服务端每秒推一帧,所有图表共用),
  一帧都没收到就断开(反代没放行 upgrade、401)时回退到每秒 GET /v1/live-rates 轮询;中途断开退避重连,
  标签页隐藏时断开、可见时重连;快照新增 transport: 'ws' | 'poll'。
- 控制台「进行中」区块头与直播管理页工具栏新增「码率图」开关(默认关,记 localStorage biliup.cards.rateChart,
  两页共用);开启后录制中卡片的 sparkline 叠在封面底部(深色渐变垫底、读数条在图区内;点击仍冒泡到封面打开预览),
  封面没有 / 加载失败时退到名称行下方。

* test(api): 码率 WebSocket 连接上限测试改用独立信号量

CI 上与同一二进制里并发跑的 WebSocket 推送测试争用进程级信号量(后者占着一个许可),
上限测试拿不满 16 个而失败;改为像 ws.rs 的日志连接测试那样传入独立的 Semaphore。
This commit is contained in:
Noah authored and GitHub committed 2026-09-23 03:44:43 +00:00
1 parent ffa847e1c8
commit 486676db29
24 files changed
+1462 -10

No files matched your search

+6
View File
@@ -136,6 +136,12 @@
margin-left: 6px;
color: var(--semi-color-text-1);
}
/* 区块头右侧:码率图开关 + 「直播管理 →」 */
.secTools {
display: inline-flex;
align-items: center;
gap: 14px;
}
.secLink {
font-size: 13px;
font-weight: 600;
+7 -3
View File
@@ -8,6 +8,7 @@ import {
} from '@/app/lib/use-dashboard'
import { formatVersion } from '@/app/lib/status'
import StreamerCard from '../ui/StreamerCard'
import { CardRateSwitch } from '../ui/LiveRateChart'
import EventTimeline from '../ui/EventTimeline'
import BackgroundSetter from './components/BackgroundSetter'
import styles from './page.module.scss'
@@ -123,9 +124,12 @@ export default function Home() {
<span className={styles.secLabel}>
进行中<span className={styles.secCount}>{live.length}</span>
</span>
<Link href="/streamers" prefetch={false} className={styles.secLink}>
直播管理 →
</Link>
<span className={styles.secTools}>
{live.length > 0 ? <CardRateSwitch /> : null}
<Link href="/streamers" prefetch={false} className={styles.secLink}>
直播管理 →
</Link>
</span>
</div>
{d.streamersFailed ? (
<Text type="tertiary">主播列表加载失败,无法显示卡片。</Text>
+5
View File
@@ -68,6 +68,11 @@
color: var(--semi-color-primary);
font-weight: 600;
}
/* 工具栏「码率图」开关(网格视图) */
.rateSwitch {
margin-right: 4px;
}
.toolbarSpacer {
flex: 1;
}
+2
View File
@@ -41,6 +41,7 @@ import {
SLOW_REFRESH_MS,
} from '@/app/lib/use-dashboard'
import StreamerCard, { LiveAvatar } from '@/app/ui/StreamerCard'
import { CardRateSwitch } from '@/app/ui/LiveRateChart'
import PageHeader from '../components/PageHeader'
import styles from './page.module.scss'
@@ -309,6 +310,7 @@ export default function StreamersPage() {
))}
</div>
<span className={styles.toolbarSpacer} />
{layout === 'grid' ? <CardRateSwitch className={styles.rateSwitch} /> : null}
<div className={styles.seg}>
<button
className={layout === 'grid' ? styles.segActive : ''}
+334
View File
@@ -0,0 +1,334 @@
'use client'
import { useSyncExternalStore } from 'react'
import { API_BASE } from './api-streamer'
/**
* 录制中各房间的写盘速率采样:页面内唯一的一份数据源,供弹层折线图、卡片与监视器的 sparkline 共用。
*
* - 有订阅者时连一条 WebSocket `GET /v1/ws/live-rates`,服务端每秒推一帧(只含录制中房间的
* `{id, bytes_per_sec, ts}`,只读内存);WebSocket 连不上(反代没放行 upgrade)就回退到每秒
* `GET /v1/live-rates` 轮询。没有订阅者就断开,标签页切到后台也断,回前台立刻重连。**不调快 `/v1/streamers`**。
* 选 WebSocket 而不是 SSE:WebSocket 不占浏览器对同一主机 HTTP/1.1 的六个并发连接,
* 监视器 4 路视频 + 1 条弹幕 SSE 已经用掉五个。
* - 历史放在模块级的环形缓冲里(每房间最近 3 分钟),组件卸载不清:切走再切回来曲线还在,
* 中间没采样的那段是空档(null),画成断口。
* - 用 useSyncExternalStore 订阅:快照只在收到新一帧时换引用,无采样变化时组件不重渲染。
*/
/** 与后端 `LiveRateFrame` 一致 */
export interface LiveRateFrame {
id: number
/** 字节/秒;尚无采样 / 下载器不经本进程写盘时为 null */
bytes_per_sec: number | null
/** 服务端读数时刻,Unix 毫秒 */
ts: number
}
/** 回退轮询的间隔(毫秒);WebSocket 推送也是 1 s 一帧。后端 RateMeter 本身 1 s 采一次,再快没有新信息。 */
export const LIVE_RATES_POLL_MS = 1000
/** 浏览器里保留的历史长度(毫秒) */
export const LIVE_RATES_HISTORY_MS = 3 * 60 * 1000
/** 每房间环形缓冲容量:3 分钟 × 1 Hz,再留些余量给略快于 1 s 的轮询 */
const RING_CAPACITY = 240
/** 拉取失败 / WebSocket 断开后的退避基数 */
const RETRY_MS = 3000
/** 一段用于画图的序列:x 为 Unix 秒(uPlot 时间轴单位),y 为字节/秒,null 是断口 */
export interface RateSeries {
xs: number[]
ys: (number | null)[]
/** 窗口内最新一个非 null 的值;没有则 null */
latest: number | null
/** 窗口内的最大值(用于选单位 / Y 轴上限);没有则 0 */
max: number
}
/** 固定容量的环形缓冲,只追加、按时间窗口读出。 */
class RateRing {
private readonly ts = new Float64Array(RING_CAPACITY)
private readonly bps = new Float64Array(RING_CAPACITY)
private start = 0
private len = 0
push(ts: number, value: number | null) {
const i = (this.start + this.len) % RING_CAPACITY
this.ts[i] = ts
this.bps[i] = value === null ? NaN : value
if (this.len < RING_CAPACITY) this.len += 1
else this.start = (this.start + 1) % RING_CAPACITY
}
/** 最后一次写入的时刻(毫秒);空缓冲为 0 */
lastTs(): number {
if (this.len === 0) return 0
return this.ts[(this.start + this.len - 1) % RING_CAPACITY]
}
/** 读出 `[sinceMs, +∞)` 的样本 */
read(sinceMs: number): RateSeries {
const xs: number[] = []
const ys: (number | null)[] = []
let latest: number | null = null
let max = 0
for (let k = 0; k < this.len; k++) {
const i = (this.start + k) % RING_CAPACITY
const t = this.ts[i]
if (t < sinceMs) continue
const v = this.bps[i]
xs.push(t / 1000)
if (Number.isNaN(v)) {
ys.push(null)
} else {
ys.push(v)
latest = v
if (v > max) max = v
}
}
return { xs, ys, latest, max }
}
}
/** 最近一帧的摘要;引用只在收到新帧 / 出错 / 换传输方式时更换 */
export interface LiveRatesSnapshot {
/** 每收到一帧加一,组件用它决定何时重画 */
version: number
/** 最近一帧的服务端时刻(毫秒);尚未收到为 0 */
ts: number
/** 最近一帧到达浏览器的时刻(毫秒,浏览器时钟);窗口裁剪按它算。尚未收到为 0 */
receivedAt: number
/** 最近一帧里各录制中房间的速率 */
latest: ReadonlyMap<number, number | null>
/** 最近一次连接 / 拉取是否失败(网络 / 401 等);成功后清空 */
error: string | null
/** 当前的数据来源:WebSocket 推送(首选)或每秒 HTTP 轮询(WebSocket 连不上时的回退);尚未开始为 null */
transport: 'ws' | 'poll' | null
}
const rings = new Map<number, RateRing>()
const listeners = new Set<() => void>()
let snapshot: LiveRatesSnapshot = { version: 0, ts: 0, receivedAt: 0, latest: new Map(), error: null, transport: null }
const SERVER_SNAPSHOT: LiveRatesSnapshot = snapshot
let timer: ReturnType<typeof setTimeout> | null = null
let inflight: AbortController | null = null
let socket: WebSocket | null = null
/** WebSocket 从未成功建立过(反代没放行 upgrade、老代理)→ 本页面余下时间用轮询 */
let wsUnusable = false
/** WebSocket 连续失败次数,决定重连退避 */
let wsFailures = 0
let visibilityBound = false
function emit() {
listeners.forEach((l) => l())
}
/**
* 把一帧写进各房间的缓冲;帧里没有的房间(停录 / 下播)补一个 null,曲线就此断开。
* 横轴统一用浏览器时钟(收到的时刻):窗口裁剪也按同一时钟算,服务端与浏览器时钟有偏差时曲线不漂。
*/
export function ingestFrames(frames: LiveRateFrame[], receivedAt = Date.now()) {
const latest = new Map<number, number | null>()
for (const f of frames) {
let ring = rings.get(f.id)
if (!ring) {
ring = new RateRing()
rings.set(f.id, ring)
}
ring.push(receivedAt, f.bytes_per_sec)
latest.set(f.id, f.bytes_per_sec)
}
for (const [id, ring] of rings) {
if (latest.has(id)) continue
if (receivedAt - ring.lastTs() > LIVE_RATES_HISTORY_MS) {
rings.delete(id)
} else {
ring.push(receivedAt, null)
}
}
const ts = frames.length > 0 ? frames[0].ts : receivedAt
snapshot = { ...snapshot, version: snapshot.version + 1, ts, receivedAt, latest, error: null }
emit()
}
function markError(message: string) {
if (snapshot.error === message) return
snapshot = { ...snapshot, version: snapshot.version + 1, error: message }
emit()
}
function setTransport(transport: LiveRatesSnapshot['transport']) {
if (snapshot.transport === transport) return
snapshot = { ...snapshot, version: snapshot.version + 1, transport }
emit()
}
/** 与日志页同一套推导:生产同源,开发模式指向 NEXT_PUBLIC_API_SERVER */
function liveRatesSocketUrl(): string {
if (API_BASE) return `${API_BASE.replace(/^http/, 'ws')}/v1/ws/live-rates`
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${window.location.host}/v1/ws/live-rates`
}
function active(): boolean {
return listeners.size > 0 && !(typeof document !== 'undefined' && document.visibilityState === 'hidden')
}
/** 首选:一条 WebSocket,服务端每秒推一帧。 */
function connectSocket() {
timer = null
if (socket || !active()) return
let ws: WebSocket
try {
ws = new WebSocket(liveRatesSocketUrl())
} catch (e) {
wsUnusable = true
markError(e instanceof Error ? e.message : String(e))
schedule(0)
return
}
socket = ws
let gotFrame = false
ws.onmessage = (event: MessageEvent<string>) => {
let frames: LiveRateFrame[]
try {
frames = JSON.parse(event.data)
} catch {
return
}
if (!Array.isArray(frames)) return
gotFrame = true
wsFailures = 0
if (snapshot.transport !== 'ws') setTransport('ws')
ingestFrames(frames)
}
ws.onclose = () => {
if (socket !== ws) return
socket = null
if (!active()) return
if (!gotFrame) {
// 一帧都没收到就断了:反代不支持 upgrade、401、403……这个页面里不再试 WebSocket
wsUnusable = true
markError('WebSocket 不可用,改为每秒轮询')
schedule(0)
return
}
// 中途断开(服务重启 / 网络抖动):退避重连,期间没有新帧,曲线出现断口
wsFailures += 1
timer = setTimeout(connectSocket, Math.min(RETRY_MS * wsFailures, 10_000))
}
ws.onerror = () => {
/* 紧接着会触发 onclose,在那里统一处理 */
}
}
/** 回退:每秒 GET 一次。 */
async function pollOnce() {
inflight?.abort()
const controller = new AbortController()
inflight = controller
let ok = false
try {
const res = await fetch(`${API_BASE}/v1/live-rates`, { cache: 'no-store', signal: controller.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const frames = (await res.json()) as LiveRateFrame[]
if (controller.signal.aborted) return
if (snapshot.transport !== 'poll') setTransport('poll')
ingestFrames(Array.isArray(frames) ? frames : [])
ok = true
} catch (e) {
if (controller.signal.aborted) return
markError(e instanceof Error ? e.message : String(e))
} finally {
if (inflight === controller) inflight = null
}
schedule(ok ? LIVE_RATES_POLL_MS : RETRY_MS)
}
/** 有订阅者且页面可见时,按当前传输方式开始 / 继续取数。 */
function schedule(delay: number) {
if (timer) clearTimeout(timer)
timer = null
if (!active()) return
if (!wsUnusable && typeof WebSocket !== 'undefined') {
timer = setTimeout(connectSocket, delay)
} else {
timer = setTimeout(pollOnce, delay)
}
}
function stop() {
if (timer) clearTimeout(timer)
timer = null
inflight?.abort()
inflight = null
if (socket) {
const ws = socket
socket = null
ws.onclose = null
ws.close()
}
}
function onVisibilityChange() {
if (document.visibilityState === 'hidden') stop()
else if (listeners.size > 0 && !timer && !inflight && !socket) schedule(0)
}
function subscribe(listener: () => void) {
listeners.add(listener)
if (typeof document !== 'undefined' && !visibilityBound) {
document.addEventListener('visibilitychange', onVisibilityChange)
visibilityBound = true
}
// 首个订阅者:下一拍再连(StrictMode 的订阅 → 退订 → 再订阅只会连一次)
if (listeners.size === 1 && !timer && !inflight && !socket) schedule(0)
return () => {
listeners.delete(listener)
if (listeners.size === 0) stop()
}
}
const getSnapshot = () => snapshot
const getServerSnapshot = () => SERVER_SNAPSHOT
/**
* 订阅实时速率。挂载即开始(或加入)取数——首选一条 WebSocket(服务端每秒推一帧),
* 连不上时回退到每秒 HTTP 轮询;全部卸载即断开 / 停止。返回最近一帧的摘要,
* 序列本身用 {@link readRateSeries} 按窗口读取。
*/
export function useLiveRates(): LiveRatesSnapshot {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
/**
* 读某房间最近 `windowMs` 的序列(含组件卸载期间留下的历史)。
* `now` 传快照的 `receivedAt`:同一版本的快照读出的序列相同,渲染期调用也是纯的。
*/
export function readRateSeries(id: number, windowMs: number, now: number): RateSeries {
const ring = rings.get(id)
if (!ring) return { xs: [], ys: [], latest: null, max: 0 }
return ring.read(now - windowMs)
}
/** 测试时清空全部历史(生产代码不调用)。 */
export function resetLiveRates() {
stop()
rings.clear()
wsUnusable = false
wsFailures = 0
snapshot = { version: 0, ts: 0, receivedAt: 0, latest: new Map(), error: null, transport: null }
}
/**
* 按窗口内的最大值选单位:≥ 1 MiB/s 用 MB/s,否则 KB/s。
* 返回除数与标签,供 Y 轴刻度与 hover 数值共用,同一张图里单位不跳。
*/
export function rateUnit(maxBytesPerSec: number): { div: number; label: string } {
if (maxBytesPerSec >= 1 << 20) return { div: 1 << 20, label: 'MB/s' }
return { div: 1 << 10, label: 'KB/s' }
}
/** 用给定单位格式化一个值(刻度 / hover 用),小于 10 保留一位小数。 */
export function formatInUnit(bytesPerSec: number, unit: { div: number; label: string }): string {
const v = bytesPerSec / unit.div
return `${v >= 10 || v === 0 ? v.toFixed(0) : v.toFixed(1)} ${unit.label}`
}
+22
View File
@@ -17,6 +17,7 @@ import {
import { useBoolPref, useChoicePref } from '@/app/lib/use-local-pref'
import { useDanmakuFeed } from '@/app/lib/danmaku-feed'
import { LivePreviewPlayer } from './LivePreview'
import { LiveRateChart, SPARK_RATE_WINDOW_MS } from './LiveRateChart'
import styles from './live-monitor.module.scss'
/**
@@ -41,6 +42,8 @@ function tileOptions(): number[] {
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 {
@@ -88,6 +91,7 @@ export default function LiveMonitor() {
const options = tileOptions()
const [maxTiles, setMaxTiles] = useChoicePref(TILES_KEY, options, DEFAULT_MONITOR_TILES)
const [danmaku, setDanmaku] = useBoolPref(DANMAKU_KEY, false)
const [rateChart, setRateChart] = useBoolPref(RATE_CHART_KEY, false)
const [selection, setSelection] = useState<Selection>({ order: [], stopped: [] })
const live = (streamers ?? []).filter((s) => s.status === LIVE_STATUS)
@@ -162,6 +166,14 @@ export default function LiveMonitor() {
<Switch size="small" checked={danmaku} onChange={(v) => setDanmaku(!!v)} aria-label="弹幕" />
</span>
</Tooltip>
<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>
@@ -258,6 +270,16 @@ export default function LiveMonitor() {
</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>
+24 -1
View File
@@ -2,7 +2,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import dynamic from 'next/dynamic'
import { Button, Modal, Switch, Tag, Toast, Tooltip, Typography } from '@douyinfe/semi-ui'
import { IconPlay, IconRefresh } from '@douyinfe/semi-icons'
import { IconChevronDown, IconPlay, IconRefresh } from '@douyinfe/semi-icons'
import type { ButtonProps } from '@douyinfe/semi-ui/lib/es/button'
import { LiveStreamerEntity } from '@/app/lib/api-streamer'
import { platformName } from '@/app/lib/status'
@@ -19,6 +19,7 @@ import {
} from '@/app/lib/use-dashboard'
import { useBoolPref } from '@/app/lib/use-local-pref'
import { type DanmakuFeed, useDanmakuFeed } from '@/app/lib/danmaku-feed'
import { LiveRateChart, LiveRateSummary, MODAL_RATE_WINDOW_MS } from './LiveRateChart'
import styles from './live-preview.module.scss'
const Players = dynamic(() => import('@/app/ui/Player'), { ssr: false })
@@ -28,6 +29,8 @@ const MAX_AUTO_RECONNECT = 3
const RECONNECT_DELAY_MS = 2000
/** 弹层弹幕开关记在本地,默认开;监视器另有自己的开关(默认关) */
const MODAL_DANMAKU_KEY = 'biliup.preview.danmaku'
/** 弹层底部的码率折线默认展开,折叠状态记在本地 */
const MODAL_RATE_CHART_KEY = 'biliup.preview.rateChart'
type Phase = 'connecting' | 'playing' | 'reconnecting' | 'ended' | 'error'
@@ -330,6 +333,7 @@ export function LivePreviewModal({
const [danmakuPref, setDanmakuPref] = useBoolPref(MODAL_DANMAKU_KEY, true)
const danmakuOn = visible && danmakuAvailable && danmakuPref
const danmakuFeed = useDanmakuFeed([streamer.id], danmakuOn)
const [chartOpen, setChartOpen] = useBoolPref(MODAL_RATE_CHART_KEY, true)
const transport = usePreviewTransport()
const notifiedRef = useRef(false)
useEffect(() => {
@@ -396,6 +400,25 @@ export function LivePreviewModal({
{visible ? (
<LivePreviewPlayer streamer={streamer} danmakuFeed={danmakuOn ? danmakuFeed : null} onFatal={handleFatal} />
) : null}
{/* 写盘速率折线:每秒轮询瘦端点 /v1/live-rates,最近 3 分钟;折叠时不轮询、不加载 uPlot */}
<section className={styles.rateSection} data-open={chartOpen ? 'true' : 'false'}>
<button
type="button"
className={styles.rateHead}
onClick={() => setChartOpen(!chartOpen)}
aria-expanded={chartOpen}
aria-controls={`live-rate-chart-${streamer.id}`}
>
<IconChevronDown className={styles.rateChevron} aria-hidden="true" />
<span className={styles.rateTitle}>写盘速率 · 最近 3 分钟</span>
{visible && chartOpen ? <LiveRateSummary id={streamer.id} windowMs={MODAL_RATE_WINDOW_MS} /> : null}
</button>
{visible && chartOpen ? (
<div id={`live-rate-chart-${streamer.id}`} className={styles.rateBody}>
<LiveRateChart id={streamer.id} windowMs={MODAL_RATE_WINDOW_MS} variant="full" height={150} label={name} />
</div>
) : null}
</section>
<div className={styles.modalFoot}>
<Text type="tertiary" size="small">
{transport === 'direct'
+111
View File
@@ -0,0 +1,111 @@
'use client'
import React, { useMemo } from 'react'
import dynamic from 'next/dynamic'
import { Switch, Tooltip, Typography } from '@douyinfe/semi-ui'
import type { RateChartVariant } from './RateChart'
import { formatInUnit, rateUnit, readRateSeries, useLiveRates } from '@/app/lib/use-live-rates'
import { useBoolPref } from '@/app/lib/use-local-pref'
import styles from './live-rate-chart.module.scss'
/**
* 码率折线的懒加载入口。uPlot(约 22 KB gzip)与 `RateChart` 打在独立 chunk 里,
* 只有页面上真的出现了一张图才下载;首屏包不含它。
*/
const RateChart = dynamic(() => import('./RateChart'), { ssr: false, loading: () => null })
/** 弹层折线的窗口:与浏览器内保留的历史一致 */
export const MODAL_RATE_WINDOW_MS = 3 * 60 * 1000
/** 卡片 / 监视器 sparkline 的窗口 */
export const SPARK_RATE_WINDOW_MS = 60 * 1000
/** 控制台 / 直播管理页卡片 sparkline 的开关(默认关),两页共用一个偏好 */
export const CARD_RATE_CHART_KEY = 'biliup.cards.rateChart'
export function useCardRateChart(): [boolean, (v: boolean) => void] {
return useBoolPref(CARD_RATE_CHART_KEY, false)
}
/** 工具栏里的「码率图」开关:开启后录制中卡片的封面上叠一条最近 60 秒的写盘速率折线。 */
export function CardRateSwitch({ className }: { className?: string }) {
const [on, setOn] = useCardRateChart()
const { Text } = Typography
return (
<Tooltip content="开启后,录制中卡片的封面上叠加最近 60 秒的写盘速率折线(一条 WebSocket 每秒推一帧,所有卡片共用)">
<span className={[styles.switchRow, className].filter(Boolean).join(' ')}>
<Text type="tertiary" size="small">
码率图
</Text>
<Switch size="small" checked={on} onChange={(v) => setOn(!!v)} aria-label="码率图" />
</span>
</Tooltip>
)
}
export function LiveRateChart({
id,
windowMs,
variant = 'full',
height,
label,
className,
overlay = false,
}: {
id: number
windowMs: number
variant?: RateChartVariant
height?: number
label?: string
className?: string
/** 叠在封面 / 画面上:读数条放在图区内部而不是上方 */
overlay?: boolean
}) {
const h = height ?? (variant === 'sparkline' ? 36 : 160)
// 外层定高:chunk 到达前后布局不跳
return (
<div
className={[styles.slot, className].filter(Boolean).join(' ')}
style={{ height: h }}
data-variant={variant}
data-overlay={overlay || undefined}
>
<RateChart id={id} windowMs={windowMs} variant={variant} height={h} label={label} overlay={overlay} />
</div>
)
}
/** 弹层折线标题里的「当前 / 峰值 / 均值」,与图共用同一份采样与同一个单位。 */
export function LiveRateSummary({ id, windowMs }: { id: number; windowMs: number }) {
const snap = useLiveRates()
const series = useMemo(() => readRateSeries(id, windowMs, snap.receivedAt), [id, windowMs, snap])
const current = snap.latest.get(id) ?? null
if (!snap.latest.has(id) || series.latest === null) {
return (
<span className={styles.summary} data-state="empty">
—
</span>
)
}
const unit = rateUnit(series.max)
let sum = 0
let n = 0
for (const v of series.ys) {
if (v !== null) {
sum += v
n += 1
}
}
const avg = n > 0 ? sum / n : 0
return (
<span className={styles.summary} data-state="ready">
<span>
当前 <b>{current === null ? '—' : formatInUnit(current, unit)}</b>
</span>
<span>
峰值 <b>{formatInUnit(series.max, unit)}</b>
</span>
<span>
均值 <b>{formatInUnit(avg, unit)}</b>
</span>
</span>
)
}
+264
View File
@@ -0,0 +1,264 @@
'use client'
import React, { useEffect, useMemo, useRef } from 'react'
import uPlot from 'uplot'
import 'uplot/dist/uPlot.min.css'
import { formatInUnit, rateUnit, readRateSeries, useLiveRates, type RateSeries } from '@/app/lib/use-live-rates'
import styles from './rate-chart.module.scss'
/**
* 写盘速率折线(uPlot)。本模块整体懒加载(消费方用 `next/dynamic` 引入),uPlot 与它的 CSS
* 只在第一次真正要画图时才下载,不进首屏。
*
* 两种形态:
* - `full`:弹层底部的折线,带时间轴与 Y 轴(单位按窗口最大值自动选 KB/s / MB/s),hover 读数;
* - `sparkline`:卡片 / 监视器小窗里的一条线,不画坐标轴,hover 显示数值与时刻。
*
* 数据来自 `useLiveRates()` 共享的采样历史;uPlot 实例在 effect 里创建 / 销毁,渲染期不碰 ref。
* 配色在每次绘制时从 Semi 的 CSS 变量读取,切换深浅色不用重建实例。uPlot 本身没有动画,
* 因此 `prefers-reduced-motion` 下没有需要关的东西;样式里也不加过渡。
* 没有任何采样(未录制 / 断流 / 下载器不计字节)时只显示「—」占位,不画空图。
*/
export type RateChartVariant = 'full' | 'sparkline'
interface Props {
/** 直播间 id */
id: number
/** 展示的时间窗口(毫秒) */
windowMs: number
variant?: RateChartVariant
/** 图高(CSS px);宽随容器 */
height?: number
className?: string
/** 可访问名称前缀 */
label?: string
/** 叠在封面 / 画面上:读数条放图区内部,占位「—」不画虚线框 */
overlay?: boolean
}
const FONT = '11px system-ui, -apple-system, "Segoe UI", sans-serif'
/** 读 Semi 主题变量。`--semi-green-5` 是 `r,g,b` 三元组,方便拼半透明填充。 */
function themeColor(name: string, fallback: string): string {
if (typeof document === 'undefined') return fallback
const v = getComputedStyle(document.body).getPropertyValue(name).trim()
return v || fallback
}
const lineColor = () => `rgba(${themeColor('--semi-green-5', '59,179,70')}, 1)`
const fillColor = () => `rgba(${themeColor('--semi-green-5', '59,179,70')}, 0.16)`
const gridColor = () => themeColor('--semi-color-border', 'rgba(28,31,35,0.08)')
const axisColor = () => themeColor('--semi-color-text-2', 'rgba(28,31,35,0.62)')
function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n)
}
function hhmmss(sec: number): string {
const d = new Date(sec * 1000)
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
}
function seriesMax(u: uPlot): number {
let max = 0
for (const v of u.data[1] ?? []) if (v !== null && v !== undefined && v > max) max = v
return max
}
/** Y 轴从 0 起,顶上留 15% 余量;全 0 时给 1 免得刻度退化 */
function yRange(u: uPlot): uPlot.Range.MinMax {
const max = seriesMax(u)
return [0, max > 0 ? max * 1.15 : 1]
}
/** 横轴始终是「最新一点往前 windowSec 秒」:只有几个点时曲线从右端往左长,不会先铺满再压缩 */
function xRange(windowSec: number) {
return (u: uPlot): uPlot.Range.MinMax => {
const xs = u.data[0]
const end = xs && xs.length > 0 ? xs[xs.length - 1] : Date.now() / 1000
return [end - windowSec, end]
}
}
function buildOptions(variant: RateChartVariant, width: number, height: number, windowMs: number): uPlot.Options {
const spark = variant === 'sparkline'
return {
width,
height,
// 图例用自己的读数条替代;uPlot 的表格图例会撑开高度
legend: { show: false },
// 只允许 hover 读数,不做框选缩放(缩放对实时滚动窗口没有意义)
cursor: {
x: !spark,
y: false,
drag: { x: false, y: false, setScale: false },
points: { show: true, size: spark ? 6 : 8, fill: lineColor, stroke: lineColor },
lock: false,
},
select: { show: false, left: 0, top: 0, width: 0, height: 0 },
scales: {
x: { time: true, range: xRange(windowMs / 1000) },
y: { range: yRange },
},
series: [
{},
{
label: '写盘速率',
stroke: lineColor,
fill: spark ? fillColor : undefined,
width: spark ? 1.5 : 2,
// 断口(null)不连线:录制中断的那段就是空白
spanGaps: false,
points: { show: false },
},
],
axes: spark
? [{ show: false }, { show: false }]
: [
{
stroke: axisColor,
grid: { stroke: gridColor, width: 1 },
ticks: { stroke: gridColor, width: 1, size: 4 },
font: FONT,
size: 22,
gap: 4,
values: (_u, splits) => splits.map(hhmmss),
},
{
stroke: axisColor,
grid: { stroke: gridColor, width: 1 },
ticks: { show: false },
font: FONT,
size: 58,
gap: 6,
// 四格等分、0 在底
splits: (_u, _axisIdx, _scaleMin, scaleMax) => [0, 1, 2, 3, 4].map((k) => (scaleMax / 4) * k),
values: (u, splits) => {
const unit = rateUnit(seriesMax(u))
return splits.map((s) => (s === 0 ? '0' : formatInUnit(s, unit)))
},
},
],
}
}
/**
* 真正持有 uPlot 实例的部分;外层已确认窗口内有采样才挂它。
* 创建 / 销毁与数据更新分成两个 effect:StrictMode 双次执行时第一份实例在清理里销毁,
* 不会留下多余的 canvas。
*/
function Plot({
id,
windowMs,
variant,
height,
series,
overlay,
}: {
id: number
windowMs: number
variant: RateChartVariant
height: number
series: RateSeries
overlay: boolean
}) {
const hostRef = useRef<HTMLDivElement>(null)
const readoutRef = useRef<HTMLSpanElement>(null)
const plotRef = useRef<uPlot | null>(null)
useEffect(() => {
const host = hostRef.current
const readout = readoutRef.current
if (!host) return
const width = Math.max(1, Math.floor(host.clientWidth))
const opts = buildOptions(variant, width, height, windowMs)
// hover 读数:直接写 DOM,不走 React 状态(mousemove 频率高,不值得重渲染)
opts.hooks = {
setCursor: [
(u) => {
if (!readout) return
const idx = u.cursor.idx
if (idx === null || idx === undefined) {
readout.hidden = true
return
}
const x = u.data[0][idx]
const y = u.data[1][idx]
const text = y === null || y === undefined ? '—' : formatInUnit(y, rateUnit(seriesMax(u)))
readout.textContent = `${hhmmss(x)} · ${text}`
readout.hidden = false
// 跟着光标左右走,不出图区
const plotLeft = u.bbox.left / (window.devicePixelRatio || 1)
const left = plotLeft + Math.min(Math.max(0, u.cursor.left ?? 0), Math.max(0, u.bbox.width / (window.devicePixelRatio || 1) - readout.offsetWidth))
readout.style.transform = `translateX(${Math.round(left)}px)`
},
],
}
// 初始数据直接从共享历史读(import 异步、props 可能已经过时),随后的更新走下面那个 effect
const initial = readRateSeries(id, windowMs, Date.now())
const plot = new uPlot(opts, [initial.xs, initial.ys], host)
plotRef.current = plot
const ro = new ResizeObserver(() => {
const w = Math.floor(host.clientWidth)
if (w > 0 && w !== plot.width) plot.setSize({ width: w, height })
})
ro.observe(host)
return () => {
ro.disconnect()
plot.destroy()
plotRef.current = null
if (readout) readout.hidden = true
}
}, [id, windowMs, variant, height])
// 新一帧到达:只换数据,不重建实例
useEffect(() => {
plotRef.current?.setData([series.xs, series.ys])
}, [series])
return (
<div className={styles.plotHost} ref={hostRef} data-variant={variant} data-overlay={overlay || undefined} style={{ height }}>
<span className={styles.readout} ref={readoutRef} hidden aria-hidden="true" />
</div>
)
}
export default function RateChart({ id, windowMs, variant = 'full', height, className, label, overlay = false }: Props) {
const snap = useLiveRates()
// 环形缓冲只随快照版本变化,快照引用就是重新读序列的触发键;`receivedAt` 让渲染期的读取保持纯粹
const series = useMemo(() => readRateSeries(id, windowMs, snap.receivedAt), [id, windowMs, snap])
const h = height ?? (variant === 'sparkline' ? 36 : 160)
const unit = rateUnit(series.max)
const wrapCls = [styles.chart, className].filter(Boolean).join(' ')
// 房间不在最近一帧里(停录 / 下播 / 接口不可用)或窗口内从没有过采样(yt-dlp 等不计字节)→ 只给「—」
const recording = snap.latest.has(id)
if (!recording || series.latest === null) {
return (
<div
className={wrapCls}
data-variant={variant}
data-overlay={overlay || undefined}
data-state={snap.error ? 'error' : 'empty'}
style={{ height: h }}
role="img"
aria-label={label ? `${label}:暂无采样` : '写盘速率暂无采样'}
title={snap.error ? `速率接口不可用:${snap.error}` : recording ? '尚无写盘速率采样' : '未在录制'}
>
<span className={styles.placeholder}>—</span>
</div>
)
}
const current = formatInUnit(series.latest, unit)
return (
<div
className={wrapCls}
data-variant={variant}
data-overlay={overlay || undefined}
data-state="ready"
role="img"
aria-label={label ? `${label}:当前 ${current}` : `写盘速率 ${current}`}
>
<Plot id={id} windowMs={windowMs} variant={variant} height={h} series={series} overlay={overlay} />
</div>
)
}
+38 -2
View File
@@ -12,6 +12,7 @@ import {
canPreview,
} from '@/app/lib/use-dashboard'
import { LivePreviewButton, LivePreviewModal } from './LivePreview'
import { LiveRateChart, SPARK_RATE_WINDOW_MS, useCardRateChart } from './LiveRateChart'
import styles from './streamer-card.module.scss'
export interface StreamerCardProps {
@@ -54,10 +55,16 @@ export function LiveCover({
src,
alt,
onPreview,
overlay,
onStateChange,
}: {
src: string
alt: string
onPreview?: () => void
/** 叠在封面底部的一层(码率 sparkline);点击会冒泡到封面本身的 onClick */
overlay?: React.ReactNode
/** 加载结果回调:父组件据此决定 overlay 放不放得下(加载失败整块消失) */
onStateChange?: (state: 'ok' | 'error') => void
}) {
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
if (state === 'error') return null
@@ -87,8 +94,14 @@ export function LiveCover({
fill
sizes="(max-width: 600px) 100vw, 400px"
loading="lazy"
onLoad={() => setState('ok')}
onError={() => setState('error')}
onLoad={() => {
setState('ok')
onStateChange?.('ok')
}}
onError={() => {
setState('error')
onStateChange?.('error')
}}
/>
{clickable ? (
<span className={styles.coverPlay} aria-hidden="true">
@@ -97,6 +110,7 @@ export function LiveCover({
</svg>
</span>
) : null}
{overlay ? <div className={styles.coverOverlay}>{overlay}</div> : null}
</figure>
)
}
@@ -156,6 +170,23 @@ export default function StreamerCard({
const avatarSrc = live ? liveImageUrl(streamer.id, 'avatar', streamer.live_avatar_url) : null
const rate = live ? formatRate(streamer.live_bytes_per_sec) : null
// 码率 sparkline(页面工具栏「码率图」开关,默认关):有封面就叠在封面底部,封面没有 / 加载失败就放名称行下
const [rateChartOn] = useCardRateChart()
const [coverFailed, setCoverFailed] = useState(false)
const coverShown = !!coverSrc && !coverFailed
const sparkline =
live && rateChartOn ? (
<LiveRateChart
id={streamer.id}
windowMs={SPARK_RATE_WINDOW_MS}
variant="sparkline"
height={coverShown ? 34 : 28}
label={`${name} 写盘速率`}
className={coverShown ? undefined : styles.spark}
overlay={coverShown}
/>
) : null
const plat = platformName(streamer.url)
const platColor = PLAT_COLORS[plat] ?? DEFAULT_PLAT_COLOR
@@ -203,6 +234,8 @@ export default function StreamerCard({
src={coverSrc}
alt={`${name} 的直播间封面`}
onPreview={previewable ? () => setPreviewOpen(true) : undefined}
overlay={coverShown ? sparkline : null}
onStateChange={(s) => setCoverFailed(s === 'error')}
/>
) : null}
@@ -231,6 +264,9 @@ export default function StreamerCard({
) : null}
</div>
{/* 开了码率图但没有封面可叠:sparkline 放这一行 */}
{sparkline && !coverShown ? sparkline : null}
{/* 标题行:始终渲染(空字符串占位),保证所有卡片等高、标题基线对齐 */}
<div className={styles.title} title={info?.title || ''}>
{info?.title || ''}
+6
View File
@@ -155,6 +155,12 @@
color: rgba(255, 255, 255, 0.85);
}
/* 小窗下的 60 s 码率 sparkline(工具栏「码率图」开关,默认关) */
.tileSpark {
padding: 6px 12px 0;
box-sizing: content-box;
}
.tileFoot {
padding: 7px 12px 9px;
font-size: 12px;
+56
View File
@@ -126,6 +126,62 @@
padding: 10px 24px 14px;
}
/* ===== 弹层底部:写盘速率折线(可折叠) ===== */
.rateSection {
border-top: 1px solid var(--semi-color-border);
}
.rateHead {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 24px;
border: 0;
background: transparent;
color: var(--semi-color-text-1);
font: inherit;
font-size: 12.5px;
text-align: left;
cursor: pointer;
}
.rateHead:hover {
background: var(--semi-color-fill-0);
}
.rateHead:focus-visible {
outline: 2px solid var(--semi-color-primary);
outline-offset: -2px;
}
.rateTitle {
font-weight: 600;
white-space: nowrap;
margin-right: auto;
}
.rateChevron {
flex-shrink: 0;
color: var(--semi-color-text-2);
transition: transform 0.15s ease;
}
.rateSection[data-open='false'] .rateChevron {
transform: rotate(-90deg);
}
.rateBody {
padding: 4px 24px 8px;
}
@media (max-width: 600px) {
.rateHead {
flex-wrap: wrap;
padding: 8px 16px;
}
.rateBody {
padding: 4px 12px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rateChevron {
transition: none;
}
}
/* disabled 的按钮不响应鼠标,包一层让 Tooltip 仍能弹出 */
.disabledWrap {
display: inline-flex;
+31
View File
@@ -0,0 +1,31 @@
/* 码率折线的懒加载壳与统计条 */
.slot {
position: relative;
width: 100%;
min-width: 0;
}
.switchRow {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.summary {
display: inline-flex;
align-items: baseline;
gap: 12px;
font-size: 12px;
color: var(--semi-color-text-2);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.summary b {
font-weight: 600;
color: var(--semi-color-text-0);
}
.summary[data-state='empty'] {
color: var(--semi-color-text-3);
}
+74
View File
@@ -0,0 +1,74 @@
/* 写盘速率折线(uPlot):弹层折线 + 卡片 / 监视器 sparkline 共用。不加过渡动画。 */
.chart {
position: relative;
width: 100%;
min-width: 0;
}
/* uPlot 挂载点:宽随容器、高由组件内联指定。不裁剪,读数条要能浮在 sparkline 上方 */
.plotHost {
position: relative;
width: 100%;
}
.plotHost :global(.uplot) {
font-family: inherit;
}
/* 光标线与点用主题色 */
.plotHost :global(.u-cursor-x) {
border-right: 1px dashed var(--semi-color-text-3);
}
/* hover 读数:贴在图区顶部,随光标横向移动 */
.readout {
position: absolute;
top: 2px;
left: 0;
z-index: 2;
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
line-height: 16px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
color: var(--semi-color-text-0);
background: var(--semi-color-bg-3);
border: 1px solid var(--semi-color-border);
box-shadow: 0 1px 4px rgba(var(--semi-grey-9), 0.12);
pointer-events: none;
}
.plotHost[data-variant='sparkline'] .readout {
top: -20px;
}
/* 叠在封面上时读数条放图区内部(封面容器会裁掉外面的部分) */
.plotHost[data-overlay] .readout {
top: 2px;
}
/* 无采样:只显示「—」 */
.chart[data-state='empty'],
.chart[data-state='error'] {
display: flex;
align-items: center;
justify-content: center;
color: var(--semi-color-text-3);
font-variant-numeric: tabular-nums;
}
.chart[data-variant='full'][data-state='empty'],
.chart[data-variant='full'][data-state='error'] {
border: 1px dashed var(--semi-color-border);
border-radius: 8px;
font-size: 13px;
}
.chart[data-variant='sparkline'][data-state='empty'],
.chart[data-variant='sparkline'][data-state='error'] {
font-size: 12px;
}
/* 叠在封面上的占位:白字、不加边框 */
.chart[data-overlay][data-state='empty'],
.chart[data-overlay][data-state='error'] {
color: rgba(255, 255, 255, 0.75);
}
.placeholder {
user-select: none;
}
+19
View File
@@ -258,6 +258,25 @@
align-self: center;
}
/* 直播中:最近 60 秒写盘速率 sparkline(无坐标轴,hover 显示数值);没有封面可叠时放名称行下 */
.spark {
margin: 2px 0 6px;
}
/* 叠在封面底部的 sparkline:深色渐变垫底让绿线在任何封面上都可读;点击仍冒泡到封面打开预览 */
.coverOverlay {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 10px 6px 2px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.35) 60%, rgba(0, 0, 0, 0));
pointer-events: none;
}
.coverOverlay > * {
pointer-events: auto;
}
/* 未开播:最近录制时间 */
.recInfo {
font-size: 12px;