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:
24 files changed
+1462
-10
No files matched your search
@@ -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
@@ -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>
|
||||
|
||||
@@ -68,6 +68,11 @@
|
||||
color: var(--semi-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 工具栏「码率图」开关(网格视图) */
|
||||
.rateSwitch {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.toolbarSpacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -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 : ''}
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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 || ''}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user