* 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。
295 lines
9.6 KiB
TypeScript
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>
|
|
)
|
|
}
|