Files
biliup/app/ui/StreamerCard.tsx
Noah 486676db29 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。
2026-09-23 03:44:43 +00:00

295 lines
9.6 KiB
TypeScript

'use client'
import React, { useState } from 'react'
import Image from 'next/image'
import { LiveStreamerEntity, StreamerInfo } from '@/app/lib/api-streamer'
import { streamerStatusMeta, uploadStatusTag, platformName } from '@/app/lib/status'
import {
timeAgo,
formatDuration,
formatRate,
liveImageUrl,
useNowSec,
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 {
streamer: LiveStreamerEntity
/** 匹配 url 的最新 streamer-info(标题 / 最近开始时间) */
info?: StreamerInfo
/** 直播管理页的批量选择 */
selectable?: boolean
selected?: boolean
onToggleSelect?: (id: number) => void
/** 卡片底部操作区(编辑 / 暂停 / 删除等) */
actions?: React.ReactNode
}
/** 平台品牌色:用于右上角平台 chip,打破卡片单调并提供平台识别 */
const PLAT_COLORS: Record<string, string> = {
哔哩哔哩: '#fb7299',
虎牙直播: '#ff6a00',
斗鱼直播: '#ff6b35',
抖音直播: '#22c1c3',
YouTube: '#ff0000',
Twitch: '#9146ff',
CC直播: '#3aac5f',
快手直播: '#ff4906',
}
const DEFAULT_PLAT_COLOR = '#6b6c75'
/** 平台色 → 10% 淡底(hex + alpha 后缀) */
function platTint(color: string): string {
return color + '1a'
}
/**
* 录制中的直播间封面缩略图(16:9,懒加载)。
* 加载失败就整块消失,卡片回到没有封面时的样式;加载中显示占位底色。
* 调用方用 key={src} 挂载,封面地址变化时状态自然重置。
* 传入 onPreview 时封面可点击,中央显示播放标记,点击打开直播预览。
*/
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
const clickable = !!onPreview && state === 'ok'
return (
<figure
className={`${styles.cover} ${clickable ? styles.coverClickable : ''}`}
data-state={state}
onClick={clickable ? onPreview : undefined}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
aria-label={clickable ? '预览直播' : undefined}
onKeyDown={
clickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onPreview?.()
}
}
: undefined
}
>
<Image
src={src}
alt={alt}
fill
sizes="(max-width: 600px) 100vw, 400px"
loading="lazy"
onLoad={() => {
setState('ok')
onStateChange?.('ok')
}}
onError={() => {
setState('error')
onStateChange?.('error')
}}
/>
{clickable ? (
<span className={styles.coverPlay} aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
<path d="M8 5.5v13l11-6.5z" />
</svg>
</span>
) : null}
{overlay ? <div className={styles.coverOverlay}>{overlay}</div> : null}
</figure>
)
}
/** 录制中的主播头像小圆图;加载失败则不占位。 */
export function LiveAvatar({ src }: { src: string }) {
const [failed, setFailed] = useState(false)
if (failed) return null
return (
<Image
className={styles.avatar}
src={src}
alt=""
aria-hidden="true"
width={22}
height={22}
loading="lazy"
onError={() => setFailed(true)}
/>
)
}
/**
* 直播中的实时时长:由"直播开始时间"和共享时钟算出。
* 只有录制中的卡片挂载它,所以只有这些卡片会随时钟每秒重渲染这一小块,其余卡片不受影响。
*/
function LiveDuration({ since }: { since: number }) {
const now = useNowSec()
return <span className={styles.liveInfo}>{formatDuration(now - since)}</span>
}
/**
* 全局统一的直播间卡片:
* 状态徽章 + 平台色 chip + [录制中:封面 / 头像 / 写盘速率] + 名称 + 实时时长(直播中)/ 最近录制(未开播)
* + 标题 + URL + 上传状态。主页与直播管理页共用。
*/
export default function StreamerCard({
streamer,
info,
selectable,
selected,
onToggleSelect,
actions,
}: StreamerCardProps) {
const meta = streamerStatusMeta(streamer.status)
const live = streamer.status === 'Working'
const paused = streamer.status === 'Pause'
const name = streamer.remark || streamer.url
// 封面点击打开的预览弹层;按钮自带一套,两处入口共用同一个弹层状态
const [previewOpen, setPreviewOpen] = useState(false)
const previewable = canPreview(streamer)
const lastRec = info?.date && !live ? timeAgo(info.date) : null
// 录制中才有封面 / 头像 / 速率;图片走同源代理,避开图片 CDN 的 Referer 校验
const coverSrc = live ? liveImageUrl(streamer.id, 'cover', streamer.live_cover_url) : null
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
const cardCls = [
styles.card,
live ? styles.rec : '',
paused ? styles.paused : '',
]
.filter(Boolean)
.join(' ')
return (
<article className={cardCls} data-id={streamer.id}>
{/* 头部:状态徽章(左)+ 平台 chip(右) */}
<div className={styles.topRow}>
<span className={styles.left}>
{selectable ? (
<input
type="checkbox"
className={styles.chk}
checked={!!selected}
onChange={() => onToggleSelect?.(streamer.id)}
aria-label={`选择 ${name}`}
/>
) : null}
<span className={`${styles.badge} ${styles[meta.cls]}`}>
<span className={styles.bdot} />
{meta.label}
</span>
</span>
<span
className={styles.platChip}
style={{ backgroundColor: platTint(platColor), color: platColor }}
title={streamer.url}
>
<span className={styles.platDot} style={{ backgroundColor: platColor }} />
{plat}
</span>
</div>
{/* 录制中:直播间封面(可预览时点击即打开播放器) */}
{coverSrc ? (
<LiveCover
key={coverSrc}
src={coverSrc}
alt={`${name} 的直播间封面`}
onPreview={previewable ? () => setPreviewOpen(true) : undefined}
overlay={coverShown ? sparkline : null}
onStateChange={(s) => setCoverFailed(s === 'error')}
/>
) : null}
{/* 名称(录制中带头像)+ 实时时长与写盘速率 / 最近录制 */}
<div className={styles.nameRow}>
<div className={styles.nameWrap}>
{avatarSrc ? <LiveAvatar key={avatarSrc} src={avatarSrc} /> : null}
<div className={styles.name} title={name}>
{name}
</div>
</div>
{live ? (
<span className={styles.liveStats}>
{info?.date ? <LiveDuration since={info.date} /> : null}
<span
className={styles.rate}
title={rate ? '写盘速率(最近 10 秒平均)' : '写盘速率:尚无采样'}
aria-label={rate ? `写盘速率 ${rate}` : '写盘速率尚无采样'}
>
{rate ?? '—'}
</span>
<LivePreviewButton streamer={streamer} size="small" iconOnly className={styles.previewBtn} />
</span>
) : lastRec ? (
<span className={styles.recInfo}>{lastRec}</span>
) : null}
</div>
{/* 开了码率图但没有封面可叠:sparkline 放这一行 */}
{sparkline && !coverShown ? sparkline : null}
{/* 标题行:始终渲染(空字符串占位),保证所有卡片等高、标题基线对齐 */}
<div className={styles.title} title={info?.title || ''}>
{info?.title || ''}
</div>
<a
className={styles.url}
href={streamer.url}
target="_blank"
rel="noreferrer"
title={streamer.url}
>
{streamer.url}
</a>
<div className={styles.meta}>{uploadStatusTag(streamer.upload_status)}</div>
{actions ? <div className={styles.actions}>{actions}</div> : null}
{previewOpen ? (
<LivePreviewModal
streamer={streamer}
visible={previewOpen}
onClose={() => setPreviewOpen(false)}
/>
) : null}
</article>
)
}