feat: 直播预览阶段二——内置播放器复用录制流(卡片弹层 + 历史页监视器) (#1712)

* feat(downloader): 直播预览 hub 与写盘旁路(httpflv / hls / mesio)

每路录制任务一个 PreviewHub(寿命 = 整场 execute(),跨分段与断流重试),下载器开始拉流时
attach 一个写入端,在写盘点旁边把同一份媒体字节 push 给它:httpflv 在关键帧写出循环、
stream-gears/hls 在分片 chunk 循环、mesio 在条目进入修复管线前。fMP4 分片流与 HEVC 序列头
标为不可预览;ffmpeg / streamlink / yt-dlp / 边录边传明确不可预览并给出原因。

写入端热路径只做三件事:GOP 缓冲追加、try_recv 订阅请求、broadcast::send。没有 .await、
没有锁等待、没有 ?;订阅请求由写入端在下一次推送时回应,快照(文件头 + 序列头 + 当前 GOP)
与随后的实时分块严格衔接。GOP 快照上限 8 MB,广播缓冲 256 槽 × 64 KB 且无订阅者时不占用,
每路订阅者数由信号量限制;慢订阅者收到 Lagged 后由端点断开让其重连。

Part of #1708

* feat(api): GET /v1/streamers/{id}/live 与列表的 preview 字段

新增 chunked 视频流端点:先发关键帧对齐的快照再接实时分块,Content-Type 按容器为
video/x-flv 或 video/mp2t,Cache-Control: no-store。房间不存在 / 未录制 404,下载器不能
旁路 415(带原因),连接数超限 429(每路 4、进程 16),尚未开始拉流或正在重连 503 +
Retry-After。慢客户端 Lagged 或写入端换代时结束响应,由播放器重连。注册在 router() 内,
--auth 时与 /v1/streamers 同一道登录校验。

GET /v1/streamers 每项新增 preview: { available, format: "flv" | "mpegts" | null, reason },
未录制为 null。

Part of #1708

* feat(preview): fMP4 分片流也能预览——init segment 快照、按分片广播、解出 codecs

mesio 的 HLS 会话拿到 fMP4 分片时不再标为不可预览:init segment(ftyp + moov)作为文件头
进快照,每个 moof + mdat 分片作为一个关键帧分块广播,新订阅者拿到 init + 最近一个完整分片,
端点 Content-Type 为 video/mp4,列表字段 preview.format 为 "fmp4"。

从 init segment 的 moov/trak/…/stsd 解出 RFC 6381 编码串(avc1.PPCCLL、hvc1.…、mp4a.40.N、
av01 / vp09 / ac-3 / ec-3 / opus / flac)放进 preview.codecs,供前端 MediaSource.isTypeSupported()
校验后 addSourceBuffer;解析只在 attach 后收到 init 时做一次,不在逐分块路径上。
stream-gears 的 HLS 路径没有下载 #EXT-X-MAP 初始化分片,遇到非 TS 分片时标为不可预览并说明原因。

按 ForgQi 在 #1708 的要求把 fMP4 纳入阶段二。

Part of #1708

* feat(ui): 直播间卡片的「预览」弹层——复用录制流的内置播放器

Player.tsx 加 isLive / muted / autoplay / type / codecs 与 onEnded / onError:flv 与 mpegts 走
mpegts.js(直播模式:不攒缓冲、追帧、自动清理 SourceBuffer),fmp4 走一个轻量 MediaSource
播放器(fetch ReadableStream → appendBuffer 串行队列,首个缓冲对齐播放位置,落后 > 3 s 追到
末尾,定期 remove 30 s 之前的缓冲),都以 Artplayer customType 挂载保持 UI 一致;
type 未知时用一次 GET 读 Content-Type 再起播。MediaSource.isTypeSupported 不通过时报错并显示
封面 + 原因,不转圈。

StreamerCard 录制中加「预览」图标按钮(不可预览时禁用,tooltip 显示后端给的原因),封面可点
即打开预览;弹层(LivePreview.tsx)关闭即卸载播放器、断开连接。服务端断开(掉队 / 换直链)
自动重连 3 次,之后交给用户手动重试;不可恢复错误 Toast 提示一次。零新依赖。

Part of #1708

* feat(ui): 历史记录页「实时监视」Tab——多路同屏监视器

<LiveMonitor /> 是独立组件:所有正在录制的直播间以小窗网格展示,可预览且在同屏路数内的
直接播放(默认静音),超出的显示封面、点击替换最早开始的一路(先进先出),下载器不能旁路
的显示封面与原因。同屏路数默认 DEFAULT_MONITOR_TILES = 4(可选 1/2/4/6/9,localStorage 记住),
每路小窗占对应直播间的一个预览连接。正在播放的列表由「用户选择 + 当前可预览房间 + 路数」
纯推导,不在 effect 里 setState。

历史记录页改为两个 Tab:「录制文件」(原表格)与「实时监视」,keepDOM=false 切走即卸载全部
播放器。要把监视器挪到别的页面只需换挂载点。

Part of #1708

* test(preview): 让旁路测试不依赖调度时机

rolling 测试先把订阅请求 poll 进写入端队列再开始写盘,源数据按 2 ms 一块限速送入而不是瞬间灌完
(瞬间灌完时订阅者必然掉队,那是慢订阅者测试覆盖的场景);慢订阅者测试只断言
「收到 + 被告知跳过 = 全部」与写入端不等待,不再对读取任务在负载下的吞吐做假设。

* fix(ui): mpegts.js 的 MSE 解码失败给出可读提示

典型来源是 TS 的 PES 没按访问单元对齐(如 Twitch 的 HLS),mpegts.js 拆出的帧不完整、浏览器拒绝
解码;原文 "Failed to execute 'appendBuffer'…" 对用户没有信息量,改为说明原因并强调录制不受影响。

* fix(preview): fMP4 与 TS 的快照从关键帧起,而不是从分段边界起

B 站 hls_fmp4 的分段(约 1 s)各含多个 moof/mdat 分片、且分段边界不对齐关键帧(实测 10 次冷启动
只有 1 次首帧是 I 帧):写入端现在把每个分段按 moof/mdat 切开,读 init 里的视频轨 ID 与
trex 默认 flags、trun 的 first_sample_flags / 首样本 flags / tfhd 默认 flags,视频首帧为同步样本的
分片才作为 GOP 起点。TS 分片起点则嗅探第一个视频 PES 的 NAL 类型(IDR/SPS → 起点,只有普通片
→ 追加到上一 GOP,说不准 → 按起点处理)。FLV 路径本来就按关键帧 tag 切,未变。

单测覆盖:分片切分(前导 styp、非同步 / 同步 / trex 默认 / 无视频轨)、跨分段边界的快照、
TS 嗅探(跨包的 PES、音频 PES 在前、非 TS 输入);另有 #[ignore] 的 B 站真实抓样核对。

* feat(danmaku): 预览播放器的实时弹幕——录制中的弹幕客户端旁路一份到 SSE

danmaku crate 的 RecorderConfig 新增可选的 live_tx(tokio broadcast):每解出一条事件,在写 XML
之前 send 一份(不等待,无订阅者即丢弃),XML 录制路径不变。DownloadTask 按 stream.danmaku
是否存在建一条 256 槽的广播,传给 danmaku_client;新增 GET /v1/streamers/{id}/danmaku(SSE,
每条 event 为 danmaku / gift / super_chat / guard_buy,data 为 {kind, text, name, color, ts},
进场消息不进;掉队跳过不断开;进程上限 32 路,超出 429;无弹幕客户端的平台 415)。
/v1/streamers 的 preview 字段新增 danmaku: bool,前端据此启用 / 禁用弹幕开关。

Part of #1708

* feat(ui): 预览弹层装载实时弹幕(开关控制),监视器默认关

Player.tsx 新增 danmaku: { url, enabled }:装 artplayer-plugin-danmuku(新依赖,Artplayer 官方弹幕
插件,5.3.0),开关打开时用 EventSource 连 /v1/streamers/{id}/danmaku,每条事件 emit 进弹幕层
(普通弹幕滚动、礼物 / 醒目留言 / 上舰置顶描边),关闭时断开 SSE 并隐藏弹幕层,不重建播放器。
弹层标题栏加「弹幕」Switch,默认开、记在 localStorage(biliup.preview.danmaku);平台没有弹幕
客户端时禁用并 tooltip 说明。监视器工具栏加同名开关,默认关(biliup.monitor.danmaku),
对全部小窗生效。localStorage 偏好统一走 useSyncExternalStore(app/lib/use-local-pref.ts),
同屏路数也改用它。

Part of #1708

* fix(preview): FLV 广播缓冲扩到 1024 槽,装得下 CDN 的整 GOP 突发

斗鱼 CDN 把整个 GOP(~4 s、~320 个 tag、~2 MB)一次突发送达,httpflv 解析器 1 ms 内推完;
刚建立的订阅者还没读到第一个实时分块,256 槽的缓冲就已被覆盖,收到 Lagged 被断开,重连后
又赶上下一个突发——冷启动 10 次只有 1 次播起来。FLV 分块是单个 tag,小而多,缓冲按格式分开:
FLV 1024 槽(实测均值 ~10 KB → ~10 MB),TS / fMP4 沿用 256 槽(分块大而少,16 MB 上限)。
回归测试:请求在关键帧回应后紧跟 600 个 tag 同步推完,订阅者仍逐个读到。

Part of #1708

* fix(ui): 弹幕开关禁用时说明是平台不支持还是未开对应 *_danmaku 配置

* feat(danmaku): 多个直播间的实时弹幕可复用一条 SSE(GET /v1/danmaku?ids=…)

浏览器对同一主机的 HTTP/1.1 并发连接只有 6 个:监视器同屏 N 路视频已占 N 个,弹幕若每路再开
一条 SSE,N=4 时就把列表轮询也饿死。新增 /v1/danmaku?ids=1,3,9 把若干路合成一条,事件 JSON
带 id 供前端分发;没有弹幕 / 未录制的 id 静默跳过,一个都没有时 404;一条最多 16 路,占 1 个
连接许可。原 /v1/streamers/{id}/danmaku 保留,走同一实现(事件同样带 id)。

Part of #1708

* feat(ui): 弹幕连接由页面持有并复用,监视器路数按 HTTP 协议限制

浏览器对同一主机 HTTP/1.1 只有 6 个并发连接,监视器 4 路视频 + 每路一条弹幕 SSE 就把列表轮询
饿死;9 路时也只有 6 路能播(实测)。改为 DanmakuFeed:页面(弹层 / 监视器)持有一条
/v1/danmaku?ids=… 连接,各播放器按 id 订阅;构造无副作用、open() 后有订阅者才连,StrictMode
下重复构造无害。同屏路数选项在 HTTP/1.1 下为 1 / 2 / 4,页面经 h2 / h3 加载时放开 6 / 9。
小窗弹幕字号 15px,弹层 22px。

Part of #1708

* fix(ui): 解码失败后关闭弹层必须断开拉流连接

<video>.error 非空时 mpegts.js destroy() 里清 SourceBuffer 的 remove() 抛 InvalidStateError,
后面的 transmuxer.close() 跑不到,连接留在服务端占着该路的预览许可直到刷新页面——真实 Twitch
TS 房间(mpegts.js 放不了)连开 4 次弹层第 4 次就 429。销毁前先把 MediaSource 从元素上摘下
(load() 使其 closed,mpegts.js 对 closed 的 MediaSource 跳过清理),destroy 仍抛则直接关
内部 transmuxer 兜底。

Part of #1708

* fix(ui): mpegts.js 换用可中止的自定义 loader,弹层关闭一定断开拉流

上一提交把 MediaSource 从元素上摘下只避免了 destroy() 抛异常,连接仍未断开:mpegts.js 自带的
FetchStreamLoader 在 Chrome 上处于 buffering 状态时 abort() 不调 AbortController.abort(),
而是等下一个 read() 回来再 reader.cancel()——解码出错后读取链已断,那一次永远等不到,连接留在
服务端占着该路许可(真实 Twitch TS 房间:关闭弹层后用 4 条 curl 探,1 条 429;再开一次 2 条 429,
直到浏览器退出才释放)。改为 customLoader:每次 open() 一个 AbortController,abort() 直接调它,
其余与原 loader 一致(直播不需要 Range)。

Part of #1708

* fix(ui): 忽略 Artplayer 在实例销毁后的自动重连,杜绝孤儿拉流连接

真正的泄漏来源:Artplayer 收到 video:error 后每隔 1 s 重设 art.url 最多 5 次(RECONNECT_TIME_MAX),
不检查自己是否已 destroy。弹层在解码失败时立刻卸载播放器,1 s 后 Artplayer 仍在已销毁的实例上
重设 url → customType 再建一个 mpegts.js 实例、再拉一路流,没有任何人持有它——每次打开泄漏
最多 5 条连接,直到刷新页面。mpegts / fMP4 两个 customType 入口对 art.isDestroy 直接返回;
fMP4 入口同时复用上一次的 dispose(同一实例重设 url 时先清掉旧的 MediaSource)。
mock 上用循环发送的 Twitch 坏样本复现:修前每开一次多 1 条连接(1→2→3),修后开关 4 次始终 0。

Part of #1708

* fix(ui): 自动播放重试前确认播放器还活着

* fix(preview): 最后一个订阅者走后释放广播缓冲

tokio broadcast 的缓冲只在被新数据覆盖时丢旧分块,而没有订阅者时写入端不再 send——最后一个
订阅者走后,缓冲里最后 N 个分块就一直挂着(FLV 1024 槽约 10 MB / 路;7 路真实短测中 RSS 在
一轮预览后从 95 MB 停在 117 MB)。写入端发现订阅者归零且缓冲里有数据时换一条新通道,旧缓冲
整个释放;之后的 subscribe() 拿新通道,对订阅者透明。

Part of #1708

* feat(preview): 直播预览取流方式配置(relay / direct)与浏览器直连的能力判定、直链端点

后端与浏览器不在一台机器时,浏览器直接向 CDN 拉一路能省服务器出口带宽(ForgQi,#1712 review 2)。
全局配置新增 preview_transport:relay(默认,复用录制流经 biliup 中转)/ direct(浏览器直连 CDN),
JSON 配置里可选字段,不需要迁移。/v1/streamers 的 preview 新增 direct: { capable, reason },按
review 1 实测的平台表判定:B 站 / 抖音 / 虎牙 FLV 能直连;斗鱼一 token 一连接、直连会挤掉录制;
Twitch CDN 无 ACAO;HLS(TS / fMP4)本版本回落——mpegts.js 放不了 m3u8,直连需 hls.js;其它平台
未验证按不能。新增 GET /v1/streamers/{id}/live-url:当前录制中那条流的直链、容器、平台与过期时间
估计(B 站 expires / 抖音 expire 十进制 Unix 秒,虎牙 wsTime 十六进制;斗鱼的相对秒数不算),
Cache-Control: no-store,与 /live 同在登录校验之内。DownloadTask 随每次 check_stream 刷新直链。

Part of #1708

* feat(ui): 直播预览取流方式——全局设置下拉,direct 模式下浏览器直连 CDN、失败自动回落中转

空间配置 → 全局设置新增「直播预览取流方式(preview_transport)」:经 biliup 中转(默认)/ 浏览器直连
CDN,文案说明两者区别与适用场景。LivePreviewPlayer 按(房间, 配置, 能力)派生取流方式:direct 且
平台能直连时先 GET /v1/streamers/{id}/live-url 拿直链(https 页面下 http 直链换 https),mpegts.js
以 cors / 不带凭据拉 CDN;出错或断开时重取一次直链再播,仍失败回落中转;平台不能直连的房间在
direct 模式下直接走中转。播放器左上角角标:「直连 CDN」或「已回落中转:<原因>」。监视器小窗共用
同一组件。异步结果与回落都带 sourceKey,配置切换时不在 effect 里同步 setState。

Part of #1708

* fix(ui): 直连模式下弹层脚注说明取流方式

* feat(preview): 直连模式向平台另取一条直链,不复用录制那条;斗鱼由此也能直连

ForgQi(#1712):走直连就不要复用链接,直接获取新链接。/v1/streamers/{id}/live-url 改为对该房间的
平台插件再调一次 check_stream,把新拿到的 raw_stream_url(新 token)给浏览器;平台返回未开播或
出错时 503,前端回落中转。实测新 token 的斗鱼 / 虎牙直链各读 25 s 稳定,同时录制那条连接无重试
——review 1 观察到的「斗鱼一 token 一连接」「虎牙第二条连接只给 GOP 缓存即断」都是同一直链两条
连接的问题,另取一条就绕开了。能力表:B 站 / 抖音 / 斗鱼 / 虎牙 FLV 能直连;Twitch 无 ACAO、HLS 需
hls.js 仍回落。Monitor 新增 PluginFor 消息按房间 URL 取插件。

Part of #1708

* fix(ui): 直连播够 60 s 后再断按直链到期处理,重取直链继续直连

斗鱼 CDN 的 token 每 5 分钟切一次连接(录制那条同样如此,日志里每 5:01 一次 StreamEnded → 重连),
B 站直链 1 h 过期。之前直连只允许重取一次,第二次到期就回落中转;现在区分「刚起播就失败」
(重取一次仍失败 → 回落)与「播了一阵才断」(视为到期,照常再取)。

Part of #1708

* feat(preview): HLS 直连——直链是 m3u8 时走 hls.js(按需加载),B 站 hls_fmp4 等也能直连

ForgQi(#1712):HLS 直连也支持一下。新依赖 hls.js ^1.7(动态 import,只在直连模式打开 HLS 房间时
才下载那一个 chunk,首屏包不变)。live-url 的 format 按新直链后缀给 flv / hls;direct_capability
只看平台的跨域放行,不再因容器排除——B 站 .m4s 分片的 CDN 同样 ACAO: *。Player 新增 hls 分支:
跟到最后 3 个分片、后向缓冲 30 s、分片请求不带凭据;清单 / 分片 404、CORS、解码等致命错误报给
LivePreviewPlayer 走重取直链 / 回落中转,清单变为非直播视为断开。Twitch 仍因无 ACAO 回落。

Part of #1708

* feat(preview): live-url 同一房间 5 秒去抖,播放失败后的重取带 fresh=1 绕过

每次打开预览都向平台 API 取一条新直链(带 cookie 时消耗登录态配额,B 站接口有频控)。同一房间
5 s 内的重复请求(StrictMode 双调用、连点、同一房间多个小窗)复用上一次结果并标 cached: true,
不再打平台;前端播放失败后的重取传 ?fresh=1 一定拿新 token(抖音某节点 404、直链到期都靠它)。
缓存插入时顺手清掉一分钟前的条目,表不会长。

Part of #1708
This commit is contained in:
Noah authored and GitHub committed 2026-09-22 16:25:00 +00:00
1 parent 20fe058b15
commit 072604eed5
39 files changed
+5684 -92

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
/* 历史记录页:Tab 面板不裁剪内容(Semi Tabs 面板默认 overflow hidden 会吃掉小窗阴影与浮层) */
.tabs :global(.semi-tabs-content),
.tabs :global(.semi-tabs-pane) {
overflow: visible;
}
.tabs :global(.semi-tabs-bar) {
margin-bottom: 16px;
}
+35 -11
View File
@@ -1,5 +1,5 @@
'use client'
import { Modal, Table, Typography } from '@douyinfe/semi-ui'
import { Modal, Table, Tabs, TabPane, Typography } from '@douyinfe/semi-ui'
import { IconVideoListStroked } from '@douyinfe/semi-icons'
import { SortOrder } from '@douyinfe/semi-ui/lib/es/table'
import useSWR from 'swr'
@@ -9,14 +9,23 @@ import dynamic from 'next/dynamic'
import { humDate } from '@/app/lib/utils'
import { formatSize } from '@/app/lib/use-dashboard'
import PageHeader from '../components/PageHeader'
import LiveMonitor from '@/app/ui/LiveMonitor'
import dc from '@/app/ui/data-card.module.scss'
import styles from './page.module.scss'
const Players = dynamic(() => import('@/app/ui/Player'), {
ssr: false,
})
type HistoryTab = 'files' | 'monitor'
/**
* 历史记录:「录制文件」(已录完的文件回放)与「实时监视」(正在录制的直播间多路同屏)两个 Tab。
* 监视器是独立组件 <LiveMonitor />,要挪到别的页面只需换个挂载点。
*/
export default function History() {
const { Text } = Typography
const [tab, setTab] = useState<HistoryTab>('files')
const { data: data, error, isLoading } = useSWR<FileList[]>('/v1/videos', fetcher)
const [fileName, setFileName] = useState<string>()
const [visible, setVisible] = useState(false)
@@ -60,18 +69,33 @@ export default function History() {
<PageHeader
icon={<IconVideoListStroked size="large" />}
title="历史记录"
description="已录制的视频文件,可在线回放"
description="已录制的视频文件可在线回放;「实时监视」同屏查看正在录制的直播间"
/>
<div className={dc.content}>
<div className={dc.card}>
<Table
size="small"
scroll={{ x: 'max-content' }}
columns={columns}
dataSource={data}
loading={isLoading}
/>
</div>
<Tabs
type="line"
activeKey={tab}
onChange={(key) => setTab(key as HistoryTab)}
className={styles.tabs}
keepDOM={false}
>
<TabPane tab="录制文件" itemKey="files">
<div className={dc.card}>
<Table
size="small"
scroll={{ x: 'max-content' }}
columns={columns}
dataSource={data}
loading={isLoading}
empty={error ? '加载失败,请检查后端连接' : '暂无数据'}
/>
</div>
</TabPane>
<TabPane tab="实时监视" itemKey="monitor">
{/* keepDOM=false:切走即卸载播放器、断开全部预览连接 */}
<LiveMonitor />
</TabPane>
</Tabs>
<Modal
visible={visible}
onCancel={() => setVisible(false)}
+38
View File
@@ -93,6 +93,42 @@ export interface StudioEntity {
extra_fields?: string;
}
/** 正在录制的直播间能否在页面内预览(复用正在录制的那一路流,见 GET /v1/streamers/{id}/live) */
export interface LivePreviewInfo {
/** 当前下载器能否提供预览;为 true 时 format 仍可能是 null(刚开始拉流、容器未定) */
available: boolean;
/** 视频流的容器,与 /live 响应的 Content-Type 一致:flv / mpegts 走 mpegts.js,fmp4 直接 MediaSource */
format: 'flv' | 'mpegts' | 'fmp4' | null;
/** fmp4 时从 init segment 解出的 RFC 6381 编码串(如 avc1.64001f,mp4a.40.2),其它容器为 null */
codecs: string | null;
/** 不可预览的原因(ffmpeg / streamlink 子进程落盘、HEVC FLV 等) */
reason: string | null;
/** 这一路有没有实时弹幕(平台实现了弹幕客户端),有则 /v1/streamers/{id}/danmaku(SSE)可用 */
danmaku: boolean;
/** 浏览器能否直连 CDN 拉这一路(全局配置 preview_transport = direct 时用;不能则回落中转并显示原因) */
direct: DirectCapability;
}
export interface DirectCapability {
capable: boolean;
/** 不能直连的原因;capable 为 true 时为 null */
reason: string | null;
}
/** GET /v1/streamers/{id}/live-url:正在录制的那条流的 CDN 直链 */
export interface LiveUrlInfo {
url: string;
/** 浏览器该用哪个播放器:flv → mpegts.js,hls → hls.js(TS / fMP4 分片都行) */
format: 'flv' | 'hls' | null;
platform: string;
/** 过期时间估计(Unix 秒),直链里没有可识别的过期参数时为 null */
expires_at: number | null;
direct: DirectCapability;
}
/** 直播预览的取流方式(全局配置 preview_transport),空值视同 relay */
export type PreviewTransport = 'relay' | 'direct';
export interface LiveStreamerEntity {
id: number;
url: string;
@@ -111,6 +147,8 @@ export interface LiveStreamerEntity {
live_cover_url?: string | null;
/** 录制中的主播头像地址;图片请走 /v1/streamers/{id}/avatar 代理 */
live_avatar_url?: string | null;
/** 录制中的预览能力;未录制为 null */
preview?: LivePreviewInfo | null;
statusTag?: React.ReactNode;
format?: string;
time_range?: string | Date[];
+99
View File
@@ -0,0 +1,99 @@
'use client'
import { useEffect, useMemo } from 'react'
import { API_BASE } from './api-streamer'
/** SSE 里一条弹幕的形状,与后端 `DanmakuFrame` 一致 */
export interface DanmakuFrame {
/** 直播间 id:一条 SSE 可能复用给多个直播间 */
id: number
kind: 'danmaku' | 'gift' | 'super_chat' | 'guard_buy'
text: string
name: string | null
/** RGB 整数(白 16777215) */
color: number
/** Unix 毫秒 */
ts: number
}
const KINDS: DanmakuFrame['kind'][] = ['danmaku', 'gift', 'super_chat', 'guard_buy']
/**
* 若干直播间共用的一条实时弹幕连接。
*
* 浏览器对同一主机的 HTTP/1.1 并发连接只有 6 个,监视器同屏 N 路视频已经占了 N 个,
* 弹幕不能再每路一条——所以由页面持有一个 feed(`/v1/danmaku?ids=…`,一条 SSE),
* 各播放器按 id 订阅自己那一路。单路弹层同样走这里(ids 只有一个)。
*/
export class DanmakuFeed {
private source: EventSource | null = null
private active = false
private listeners = new Map<number, Set<(frame: DanmakuFrame) => void>>()
/** 构造不产生副作用;`open()` 之后、有订阅者时才真正连接(StrictMode 下重复构造无害) */
constructor(readonly ids: number[]) {}
/** 允许连接;已有订阅者就立刻连。 */
open() {
this.active = true
this.connectIfNeeded()
}
/** 断开并禁止再连;订阅者保留,`open()` 后可恢复。 */
close() {
this.active = false
this.source?.close()
this.source = null
}
/** 订阅某个直播间的弹幕;返回退订函数。 */
subscribe(id: number, listener: (frame: DanmakuFrame) => void): () => void {
let set = this.listeners.get(id)
if (!set) {
set = new Set()
this.listeners.set(id, set)
}
set.add(listener)
this.connectIfNeeded()
return () => {
set?.delete(listener)
}
}
private connectIfNeeded() {
if (!this.active || this.source || this.ids.length === 0 || typeof window === 'undefined') return
if (![...this.listeners.values()].some((set) => set.size > 0)) return
const source = new EventSource(`${API_BASE}/v1/danmaku?ids=${this.ids.join(',')}`)
const handle = (e: MessageEvent<string>) => {
let frame: DanmakuFrame
try {
frame = JSON.parse(e.data)
} catch {
return
}
if (!frame.text) return
this.listeners.get(frame.id)?.forEach((l) => l(frame))
}
for (const kind of KINDS) source.addEventListener(kind, handle as EventListener)
this.source = source
}
}
/** 同一组 id 的稳定键,供 effect 依赖:顺序无关。 */
export function feedKey(ids: number[]): string {
return [...ids].sort((a, b) => a - b).join(',')
}
/**
* 按需持有一个 feed:`enabled` 且 ids 非空时连接,ids 变化(监视器换路)时换一条,
* 关闭 / 卸载时断开。返回值给各播放器的 `danmaku.feed`。
*/
export function useDanmakuFeed(ids: number[], enabled: boolean): DanmakuFeed | null {
const key = enabled ? feedKey(ids) : ''
// 构造无副作用,连接在 effect 里 open() 之后才发生
const feed = useMemo(() => (key ? new DanmakuFeed(key.split(',').map(Number)) : null), [key])
useEffect(() => {
feed?.open()
return () => feed?.close()
}, [feed])
return feed
}
+66 -1
View File
@@ -1,7 +1,7 @@
'use client'
import { useSyncExternalStore } from 'react'
import useSWR from 'swr'
import { API_BASE, fetcher, LiveStreamerEntity, StreamerInfo, FileList } from './api-streamer'
import { API_BASE, fetcher, LiveStreamerEntity, StreamerInfo, FileList, LiveUrlInfo, PreviewTransport } from './api-streamer'
import { platformName } from './status'
/**
@@ -112,6 +112,71 @@ export function liveImageUrl(
return `${API_BASE}/v1/streamers/${id}/${kind}?v=${(h >>> 0).toString(36)}`
}
/** 正在录制的那一路流的同源地址(chunked FLV / MPEG-TS / fMP4),供页面内播放器直接拉取。 */
export function livePreviewUrl(id: number): string {
return `${API_BASE}/v1/streamers/${id}/live`
}
/** 浏览器直连模式:向后端要当前录制中那条流的 CDN 直链。 */
export function fetchLiveUrl(id: number): Promise<LiveUrlInfo> {
return fetcher(`/v1/streamers/${id}/live-url`, { cache: 'no-store' })
}
/**
* CDN 直链在页面里能不能直接用:https 页面拉 http 直链会被当作混合内容拦掉(抖音的直链就是 http),
* 实测这些 CDN 都支持 https,直接换协议。
*/
export function directPlayableUrl(url: string): string {
if (typeof window !== 'undefined' && window.location.protocol === 'https:' && url.startsWith('http://')) {
return 'https://' + url.slice('http://'.length)
}
return url
}
/**
* 全局配置里的预览取流方式。与空间配置页共用同一个 SWR key,改完保存这里立刻拿到;
* 配置还没加载到时按 relay 处理(默认值)。
*/
export function usePreviewTransport(): PreviewTransport {
const { data } = useSWR<{ preview_transport?: PreviewTransport | null }>('/v1/configuration', fetcher, {
refreshInterval: SLOW_REFRESH_MS,
})
return data?.preview_transport === 'direct' ? 'direct' : 'relay'
}
/**
* 卡片 / 监视器能否起播:正在录制、下载器能旁路、容器已确定。
* 容器未定(刚开始拉流的前几秒)时按钮先禁用,下一次轮询拿到 format 再放开。
*/
export function canPreview(streamer: LiveStreamerEntity): streamer is LiveStreamerEntity & {
preview: { available: true; format: 'flv' | 'mpegts' | 'fmp4' }
} {
const format = streamer.preview?.format
return (
streamer.status === LIVE_STATUS &&
!!streamer.preview?.available &&
(format === 'flv' || format === 'mpegts' || format === 'fmp4')
)
}
/** 容器短名 → 界面标签 */
export function previewFormatLabel(format: 'flv' | 'mpegts' | 'fmp4' | null | undefined): string | null {
if (format === 'flv') return 'FLV'
if (format === 'mpegts') return 'TS'
if (format === 'fmp4') return 'fMP4'
return null
}
/** 预览按钮禁用时的提示文案;能预览时返回 null。 */
export function previewDisabledReason(streamer: LiveStreamerEntity): string | null {
if (streamer.status !== LIVE_STATUS) return '未在录制'
const preview = streamer.preview
if (!preview) return '预览信息尚未就绪'
if (!preview.available) return preview.reason || '当前下载器不支持预览'
if (!preview.format) return '正在建立预览,请稍候'
return null
}
const DAY = 86400
const EVENT_WINDOW = DAY // 事件流只展示最近 24h
+66
View File
@@ -0,0 +1,66 @@
'use client'
import { useSyncExternalStore } from 'react'
/**
* 记在 localStorage 里的界面偏好(同屏路数、弹幕开关等)。
* 用 useSyncExternalStore 而不是「useState + effect 里 setState」:服务端与首屏统一给默认值,
* 挂载后同步读到真实值,没有水合不一致,也不触发 react-hooks/set-state-in-effect。
*/
const listeners = new Set<() => void>()
function notify() {
listeners.forEach((l) => l())
}
function subscribe(listener: () => void) {
listeners.add(listener)
window.addEventListener('storage', listener)
return () => {
listeners.delete(listener)
window.removeEventListener('storage', listener)
}
}
export function readPref(key: string): string | null {
if (typeof window === 'undefined') return null
try {
return window.localStorage.getItem(key)
} catch {
return null
}
}
export function writePref(key: string, value: string) {
try {
window.localStorage.setItem(key, value)
} catch {
/* 隐私模式等写不进去时就只在本次会话里生效 */
}
notify()
}
/** 布尔偏好。 */
export function useBoolPref(key: string, fallback: boolean): [boolean, (v: boolean) => void] {
const value = useSyncExternalStore(
subscribe,
() => {
const raw = readPref(key)
return raw === null ? fallback : raw === '1'
},
() => fallback
)
return [value, (v: boolean) => writePref(key, v ? '1' : '0')]
}
/** 只接受给定候选值的数字偏好。 */
export function useChoicePref(key: string, options: number[], fallback: number): [number, (v: number) => void] {
const value = useSyncExternalStore(
subscribe,
() => {
const n = Number(readPref(key))
return options.includes(n) ? n : fallback
},
() => fallback
)
return [value, (v: number) => writePref(key, String(v))]
}
+270
View File
@@ -0,0 +1,270 @@
'use client'
import React, { useState } from 'react'
import useSWR from 'swr'
import { Button, Empty, Select, Switch, Tag, Tooltip, Typography } from '@douyinfe/semi-ui'
import { IconPlay, IconStop } from '@douyinfe/semi-icons'
import { fetcher, LiveStreamerEntity, StreamerInfo } from '@/app/lib/api-streamer'
import { LIVE_STATUS, platformName } from '@/app/lib/status'
import {
canPreview,
formatRate,
liveImageUrl,
previewDisabledReason,
previewFormatLabel,
SLOW_REFRESH_MS,
STREAMERS_REFRESH_MS,
} from '@/app/lib/use-dashboard'
import { useBoolPref, useChoicePref } from '@/app/lib/use-local-pref'
import { useDanmakuFeed } from '@/app/lib/danmaku-feed'
import { LivePreviewPlayer } from './LivePreview'
import styles from './live-monitor.module.scss'
/**
* 监视器默认同屏路数。每路小窗占用对应直播间的 1 个预览连接
* (服务端每间上限见 `PREVIEW_MAX_SUBSCRIBERS_PER_ROOM`,进程上限 16),改这里即可换默认值。
*/
export const DEFAULT_MONITOR_TILES = 4
/**
* 浏览器对同一主机的 HTTP/1.1 并发连接只有 6 个:每路小窗占 1 个长连接,弹幕复用 1 个,
* 列表轮询还要留 1 个,所以 HTTP/1.1 下最多 4 路;页面走 HTTP/2、HTTP/3(TLS 反代)时不受此限,
* 放开 6 / 9 路。
*/
export const MONITOR_TILE_OPTIONS_H1 = [1, 2, 4]
export const MONITOR_TILE_OPTIONS_H2 = [1, 2, 4, 6, 9]
function tileOptions(): number[] {
if (typeof window === 'undefined') return MONITOR_TILE_OPTIONS_H1
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined
const proto = nav?.nextHopProtocol ?? ''
return proto === 'h2' || proto === 'h3' ? MONITOR_TILE_OPTIONS_H2 : MONITOR_TILE_OPTIONS_H1
}
const TILES_KEY = 'biliup.monitor.maxTiles'
/** 多路同屏默认不开弹幕(每路一条 SSE、多路叠加太吵),开关记在本地 */
const DANMAKU_KEY = 'biliup.monitor.danmaku'
/** 用户的选择:手动激活的顺序(先进先出)与手动停掉的房间;其余由数据推导。 */
interface Selection {
order: number[]
stopped: number[]
}
/**
* 由「用户选择 + 当前可预览房间 + 路数上限」推导正在播放的房间列表(纯函数,无副作用):
* 先保留用户激活且仍可预览的,再按列表顺序自动补满,手动停掉的不自动补。
*/
export function resolveActive(
selection: Selection,
previewableIds: number[],
maxTiles: number
): number[] {
const previewable = new Set(previewableIds)
const stopped = new Set(selection.stopped)
const active: number[] = []
for (const id of selection.order) {
if (previewable.has(id) && !active.includes(id)) active.push(id)
}
for (const id of previewableIds) {
if (active.length >= maxTiles) break
if (!active.includes(id) && !stopped.has(id)) active.push(id)
}
return active.slice(Math.max(0, active.length - maxTiles))
}
/**
* 实时监视:所有正在录制的直播间的小窗网格。
* 可预览且在路数上限内的直接播放(默认静音);超出的显示封面,点击替换最早开始的一路;
* 下载器不能旁路的显示封面与原因。独立组件,可整体挪到别的页面。
*/
export default function LiveMonitor() {
const { Text } = Typography
const { data: streamers, error, isLoading } = useSWR<LiveStreamerEntity[]>(
'/v1/streamers',
fetcher,
{ refreshInterval: STREAMERS_REFRESH_MS }
)
const { data: infos } = useSWR<StreamerInfo[]>('/v1/streamer-info', fetcher, {
refreshInterval: SLOW_REFRESH_MS,
})
const options = tileOptions()
const [maxTiles, setMaxTiles] = useChoicePref(TILES_KEY, options, DEFAULT_MONITOR_TILES)
const [danmaku, setDanmaku] = useBoolPref(DANMAKU_KEY, false)
const [selection, setSelection] = useState<Selection>({ order: [], stopped: [] })
const live = (streamers ?? []).filter((s) => s.status === LIVE_STATUS)
const previewable = live.filter(canPreview)
const previewableIds = previewable.map((s) => s.id)
const active = resolveActive(selection, previewableIds, maxTiles)
const activeSet = new Set(active)
// 正在播且平台有弹幕客户端的那几路共用一条弹幕连接
const danmakuIds = previewable.filter((s) => activeSet.has(s.id) && s.preview?.danmaku).map((s) => s.id)
const danmakuFeed = useDanmakuFeed(danmakuIds, danmaku)
const infoByUrl = new Map<string, StreamerInfo>()
for (const i of infos ?? []) {
const cur = infoByUrl.get(i.url)
if (!cur || i.date > cur.date) infoByUrl.set(i.url, i)
}
// 激活:满了就替换最早开始的一路(先进先出)
const activate = (id: number) => {
setSelection((prev) => {
const current = resolveActive(prev, previewableIds, maxTiles)
const kept = current.length >= maxTiles ? current.slice(1) : current
return {
order: [...kept.filter((x) => x !== id), id],
stopped: prev.stopped.filter((x) => x !== id),
}
})
}
const stop = (id: number) => {
setSelection((prev) => ({
order: prev.order.filter((x) => x !== id),
stopped: prev.stopped.includes(id) ? prev.stopped : [...prev.stopped, id],
}))
}
if (error) {
return (
<div className={styles.center}>
<Empty title="加载失败" description="无法获取主播列表,请检查后端连接" />
</div>
)
}
if (!isLoading && live.length === 0) {
return (
<div className={styles.center}>
<Empty
title="当前没有正在录制的直播间"
description="开播并开始录制后,这里会以小窗形式同屏显示各路画面(stream-gears / mesio 下载器)"
/>
</div>
)
}
const full = active.length >= maxTiles
return (
<section className={styles.monitor}>
<div className={styles.toolbar}>
<div className={styles.summary}>
<Text strong>
正在监视 {active.length} / {maxTiles} 路
</Text>
<Text type="tertiary" size="small">
录制中 {live.length} 间 · 可预览 {previewable.length} 间
</Text>
</div>
<div className={styles.controls}>
<Tooltip content="所有小窗一起开关;只有平台实现了弹幕客户端的房间会显示">
<span className={styles.switchRow}>
<Text type="tertiary" size="small">
弹幕
</Text>
<Switch size="small" checked={danmaku} onChange={(v) => setDanmaku(!!v)} aria-label="弹幕" />
</span>
</Tooltip>
<Text type="tertiary" size="small">
同屏路数
</Text>
<Select
size="small"
value={maxTiles}
onChange={(v) => setMaxTiles(Number(v))}
optionList={options.map((n) => ({ value: n, label: `${n} 路` }))}
style={{ width: 88 }}
aria-label="同屏路数"
/>
</div>
</div>
<Text type="tertiary" size="small" className={styles.hint}>
画面来自各直播间正在写盘的同一路流,默认静音;每路小窗占用该直播间的 1 个预览连接。
超出路数的直播间显示封面,点击可替换最早开始的一路。
</Text>
<div className={styles.grid}>
{live.map((s) => {
const info = infoByUrl.get(s.url)
const playing = activeSet.has(s.id)
const can = canPreview(s)
const reason = can ? null : previewDisabledReason(s)
const cover = liveImageUrl(s.id, 'cover', s.live_cover_url)
const rate = formatRate(s.live_bytes_per_sec)
const name = s.remark || s.url
return (
<article
key={s.id}
className={`${styles.tile} ${playing ? styles.tilePlaying : ''}`}
data-id={s.id}
data-state={playing ? 'playing' : can ? 'idle' : 'unavailable'}
>
<header className={styles.tileHead}>
<span className={styles.tileName} title={name}>
{name}
</span>
<Tag size="small" color="grey">
{platformName(s.url)}
</Tag>
{previewFormatLabel(s.preview?.format) ? (
<Tag size="small" color="green">
{previewFormatLabel(s.preview?.format)}
</Tag>
) : null}
<span className={styles.tileRate}>{rate ?? '—'}</span>
{playing ? (
<Tooltip content="停止这一路">
<Button
size="small"
theme="borderless"
type="tertiary"
icon={<IconStop />}
aria-label={`停止 ${name}`}
onClick={() => stop(s.id)}
/>
</Tooltip>
) : null}
</header>
{playing ? (
<LivePreviewPlayer streamer={s} muted compact danmakuFeed={danmaku ? danmakuFeed : null} />
) : (
<button
type="button"
className={styles.idle}
disabled={!can}
onClick={() => activate(s.id)}
aria-label={
can ? (full ? `播放 ${name}(替换最早的一路)` : `播放 ${name}`) : `${name}:${reason}`
}
>
{cover ? (
// 封面走同源代理;失败时隐藏,只剩底色,说明文字仍在
// eslint-disable-next-line @next/next/no-img-element
<img
className={styles.idleCover}
src={cover}
alt=""
aria-hidden="true"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
) : null}
<span className={styles.idleText}>
{can ? (
<>
<IconPlay />
{full ? '点击替换最早的一路' : '点击开始预览'}
</>
) : (
reason
)}
</span>
</button>
)}
<footer className={styles.tileFoot} title={info?.title || ''}>
{info?.title || '\u00a0'}
</footer>
</article>
)
})}
</div>
</section>
)
}
+462
View File
@@ -0,0 +1,462 @@
'use client'
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 type { ButtonProps } from '@douyinfe/semi-ui/lib/es/button'
import { LiveStreamerEntity } from '@/app/lib/api-streamer'
import { platformName } from '@/app/lib/status'
import {
canPreview,
directPlayableUrl,
fetchLiveUrl,
formatRate,
liveImageUrl,
livePreviewUrl,
previewDisabledReason,
previewFormatLabel,
usePreviewTransport,
} from '@/app/lib/use-dashboard'
import { useBoolPref } from '@/app/lib/use-local-pref'
import { type DanmakuFeed, useDanmakuFeed } from '@/app/lib/danmaku-feed'
import styles from './live-preview.module.scss'
const Players = dynamic(() => import('@/app/ui/Player'), { ssr: false })
/** 服务端断开(掉队 / 换直链)后自动重连的次数上限;超过后交给用户手动重试 */
const MAX_AUTO_RECONNECT = 3
const RECONNECT_DELAY_MS = 2000
/** 弹层弹幕开关记在本地,默认开;监视器另有自己的开关(默认关) */
const MODAL_DANMAKU_KEY = 'biliup.preview.danmaku'
type Phase = 'connecting' | 'playing' | 'reconnecting' | 'ended' | 'error'
/** 实际在用的取流方式与角标文案 */
type Source =
| { kind: 'relay'; fallbackReason: null }
| { kind: 'relay'; fallbackReason: string }
| {
kind: 'direct'
url: string
format: 'flv' | 'hls' | null
/** 这条直链是不是失败后重取来的(立刻又失败就不再试直连) */
refetched: boolean
/** 开始用这条直链的时刻:播够一阵再断多半是直链到期(斗鱼 5 min、B 站 1 h),可以再取 */
startedAt: number
}
/**
* 直播预览播放区:复用正在录制的那一路流(`/v1/streamers/{id}/live`)。
*
* 播放器(Artplayer + mpegts.js)挂在 `key={nonce}` 上,重连即换 key 重建;
* 组件卸载时播放器销毁、fetch 中止,服务端随之释放该连接的槽位。
* 既用于卡片弹层,也用于历史记录页的监视器小窗。
*/
export function LivePreviewPlayer({
streamer,
muted = false,
compact = false,
danmakuFeed = null,
onFatal,
}: {
streamer: LiveStreamerEntity
muted?: boolean
/** 监视器小窗:状态文字更简短 */
compact?: boolean
/** 页面持有的实时弹幕连接;null 表示弹幕关。仅平台有弹幕客户端时装弹幕层 */
danmakuFeed?: DanmakuFeed | null
/** 自动重连耗尽或不可恢复错误(415 / 429 / 解码失败)时通知父组件 */
onFatal?: (message: string) => void
}) {
const [nonce, setNonce] = useState(0)
const [phase, setPhase] = useState<Phase>('connecting')
const [message, setMessage] = useState<string | null>(null)
const attemptsRef = useRef(0)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const transport = usePreviewTransport()
const directCapable = !!streamer.preview?.direct?.capable
const directReason = streamer.preview?.direct?.reason ?? null
// direct 模式且平台能直连:先向后端要直链;否则走中转,direct 模式下把原因挂在角标上。
// 取流方式随(房间, 配置, 能力)变化而重算:异步结果与失败回落都带着 key,key 不匹配的一律忽略,
// 这样切换时不需要在 effect 里同步重置 state。
const wantDirect = transport === 'direct' && directCapable
const sourceKey = `${streamer.id}:${transport}:${directCapable}`
const [fetched, setFetched] = useState<{ key: string; source: Source } | null>(null)
const [override, setOverride] = useState<{ key: string; source: Source } | null>(null)
useEffect(() => {
if (!wantDirect) return
let cancelled = false
fetchLiveUrl(streamer.id).then(
(info) => {
if (cancelled) return
const source: Source =
info.direct.capable && info.url
? { kind: 'direct', url: directPlayableUrl(info.url), format: info.format, refetched: false, startedAt: Date.now() }
: { kind: 'relay', fallbackReason: info.direct.reason ?? '该平台不支持直连' }
setFetched({ key: sourceKey, source })
},
(e: unknown) => {
if (cancelled) return
setFetched({
key: sourceKey,
source: { kind: 'relay', fallbackReason: `获取直链失败:${e instanceof Error ? e.message : String(e)}` },
})
}
)
return () => {
cancelled = true
}
}, [wantDirect, sourceKey, streamer.id])
const source = useMemo<Source | null>(() => {
if (override?.key === sourceKey) return override.source
if (!wantDirect) {
return { kind: 'relay', fallbackReason: transport === 'direct' ? (directReason ?? '该平台不支持直连') : null }
}
return fetched?.key === sourceKey ? fetched.source : null
}, [override, fetched, sourceKey, wantDirect, transport, directReason])
const relayFormat = streamer.preview?.format ?? undefined
const codecs = streamer.preview?.codecs ?? null
const relayUrl = livePreviewUrl(streamer.id)
const url = source?.kind === 'direct' ? source.url : relayUrl
const format = source?.kind === 'direct' ? (source.format ?? 'flv') : relayFormat
// 平台有弹幕客户端才装弹幕层;开关只控制订阅与显示
const danmakuLayer = streamer.preview?.danmaku
? { id: streamer.id, feed: danmakuFeed, fontSize: compact ? 15 : 22 }
: undefined
// 出错 / 断开时把封面垫在说明文字后面,而不是一块黑
const cover = liveImageUrl(streamer.id, 'cover', streamer.live_cover_url)
useEffect(
() => () => {
if (timerRef.current) clearTimeout(timerRef.current)
},
[]
)
const scheduleReconnect = useCallback(
(why: string) => {
if (attemptsRef.current >= MAX_AUTO_RECONNECT) {
setPhase('ended')
setMessage(`${why},已重连 ${MAX_AUTO_RECONNECT} 次`)
onFatal?.(why)
return
}
attemptsRef.current += 1
setPhase('reconnecting')
setMessage(`${why},${RECONNECT_DELAY_MS / 1000} 秒后重连(${attemptsRef.current}/${MAX_AUTO_RECONNECT})`)
timerRef.current = setTimeout(() => {
setPhase('connecting')
setMessage(null)
setNonce((n) => n + 1)
}, RECONNECT_DELAY_MS)
},
[onFatal]
)
// 直连出问题(CDN 403 / 直链过期 / 断开):重取一次直链再播;再失败就回落中转,不再试直连
const handleDirectFailure = useCallback(
(why: string) => {
const fallback = (reason: string) => {
setOverride({ key: sourceKey, source: { kind: 'relay', fallbackReason: reason } })
attemptsRef.current = 0
setPhase('connecting')
setMessage(null)
setNonce((n) => n + 1)
}
// 播够 60 s 再断多半是直链到期(斗鱼 token 5 min、B 站 1 h),照常再取一条;
// 刚起播就失败才算「直连不行」——重取一次后仍失败就回落中转
const playedAWhile = source?.kind === 'direct' && Date.now() - source.startedAt > 60_000
if (source?.kind !== 'direct' || (source.refetched && !playedAWhile)) {
fallback(`直连失败:${why}`)
return
}
setPhase('reconnecting')
setMessage('直连中断,重新获取直链…')
fetchLiveUrl(streamer.id).then(
(info) => {
if (info.direct.capable && info.url) {
setOverride({
key: sourceKey,
source: {
kind: 'direct',
url: directPlayableUrl(info.url),
format: info.format,
refetched: true,
startedAt: Date.now(),
},
})
setPhase('connecting')
setMessage(null)
setNonce((n) => n + 1)
} else {
fallback(info.direct.reason ?? `直连失败:${why}`)
}
},
() => fallback(`直连失败:${why},且重取直链失败`)
)
},
[source, sourceKey, streamer.id]
)
const handleEnded = useCallback(() => {
if (source?.kind === 'direct') handleDirectFailure('连接已断开')
else scheduleReconnect('连接已断开')
}, [source, handleDirectFailure, scheduleReconnect])
const handleError = useCallback(
(text: string) => {
if (source?.kind === 'direct') {
handleDirectFailure(text)
return
}
// 415(不支持)/ 429(超限)/ 解码 / 浏览器不支持的编码不会因重试而变化;503 与网络抖动可以再试
const recoverable = /重连|503|网络|连接失败/.test(text) && !/不支持|拒绝|无法起播/.test(text)
if (recoverable) {
scheduleReconnect(text)
return
}
setPhase('error')
setMessage(text)
onFatal?.(text)
},
[source, handleDirectFailure, scheduleReconnect, onFatal]
)
const retry = () => {
attemptsRef.current = 0
setPhase('connecting')
setMessage(null)
setNonce((n) => n + 1)
}
// 播放器一旦起播就当作在放(mpegts.js 没有可靠的 first-frame 事件;错误会另行回调)
useEffect(() => {
if (phase !== 'connecting') return
const t = setTimeout(() => setPhase((p) => (p === 'connecting' ? 'playing' : p)), 1500)
return () => clearTimeout(t)
}, [phase, nonce])
const pending = source === null
const showPlayer = (phase === 'connecting' || phase === 'playing') && !pending
const badge =
source?.kind === 'direct'
? { text: '直连 CDN', tone: 'direct' as const }
: source?.kind === 'relay' && source.fallbackReason
? { text: `已回落中转:${source.fallbackReason}`, tone: 'fallback' as const }
: null
return (
<div className={styles.player} data-phase={phase} data-source={source?.kind ?? 'pending'}>
{showPlayer ? (
<Players
key={`${source?.kind}-${url}-${nonce}`}
url={url}
type={format}
codecs={codecs}
isLive
muted={muted}
autoplay
onEnded={handleEnded}
onError={handleError}
danmaku={danmakuLayer}
/>
) : null}
{badge ? (
<Tooltip
content={
badge.tone === 'direct'
? '浏览器用另取的直链直接向 CDN 拉流,不经 biliup 中转、不影响录制(全局配置 preview_transport = direct)'
: badge.text
}
>
<span className={styles.badge} data-tone={badge.tone} data-compact={compact || undefined}>
{badge.text}
</span>
</Tooltip>
) : null}
{phase !== 'playing' || pending ? (
<div className={styles.overlay}>
{cover && (phase === 'error' || phase === 'ended') ? (
// 封面走同源代理;加载失败只剩底色
// eslint-disable-next-line @next/next/no-img-element
<img
className={styles.overlayCover}
src={cover}
alt=""
aria-hidden="true"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
) : null}
<div className={styles.overlayText}>
{pending
? '正在获取直链…'
: phase === 'connecting'
? source.kind === 'direct'
? '正在连接 CDN…'
: '正在连接录制流…'
: message}
</div>
{phase === 'ended' || phase === 'error' ? (
<Button
size={compact ? 'small' : 'default'}
icon={<IconRefresh />}
theme="light"
onClick={retry}
>
重试
</Button>
) : null}
</div>
) : null}
</div>
)
}
/** 卡片里的「预览」弹层:关闭即卸载播放器、断开连接。 */
export function LivePreviewModal({
streamer,
visible,
onClose,
}: {
streamer: LiveStreamerEntity
visible: boolean
onClose: () => void
}) {
const { Text } = Typography
const name = streamer.remark || streamer.url
const rate = formatRate(streamer.live_bytes_per_sec)
const format = streamer.preview?.format
const danmakuAvailable = !!streamer.preview?.danmaku
const [danmakuPref, setDanmakuPref] = useBoolPref(MODAL_DANMAKU_KEY, true)
const danmakuOn = visible && danmakuAvailable && danmakuPref
const danmakuFeed = useDanmakuFeed([streamer.id], danmakuOn)
const transport = usePreviewTransport()
const notifiedRef = useRef(false)
useEffect(() => {
if (!visible) notifiedRef.current = false
}, [visible])
const handleFatal = useCallback(
(text: string) => {
if (notifiedRef.current) return
notifiedRef.current = true
Toast.error({ content: `预览中断:${text}`, duration: 4 })
},
[]
)
return (
<Modal
visible={visible}
onCancel={onClose}
closeOnEsc
footer={null}
className={styles.modal}
style={{ width: 'min(960px, 94vw)' }}
bodyStyle={{ padding: 0 }}
title={
<div className={styles.modalTitle}>
<span className={styles.modalName} title={name}>
{name}
</span>
<Tag size="small" color="green">
{platformName(streamer.url)}
</Tag>
{previewFormatLabel(format) ? (
<Tag size="small" color="grey">
{previewFormatLabel(format)}
</Tag>
) : null}
<Text type="tertiary" size="small">
{rate ? `写盘 ${rate}` : '写盘速率 —'}
</Text>
<Tooltip
content={
danmakuAvailable
? '显示录制中的实时弹幕(来自本进程的弹幕客户端)'
: '这一路没有弹幕客户端:平台不支持,或未开启对应的 *_danmaku 配置(B 站 / 抖音 / 斗鱼 / 虎牙可开)'
}
>
<span className={styles.danmakuSwitch}>
<Text type="tertiary" size="small">
弹幕
</Text>
<Switch
size="small"
checked={danmakuAvailable && danmakuPref}
disabled={!danmakuAvailable}
onChange={(v) => setDanmakuPref(!!v)}
aria-label="弹幕"
/>
</span>
</Tooltip>
</div>
}
>
{/* 弹层关闭即卸载:不留后台连接 */}
{visible ? (
<LivePreviewPlayer streamer={streamer} danmakuFeed={danmakuOn ? danmakuFeed : null} onFatal={handleFatal} />
) : null}
<div className={styles.modalFoot}>
<Text type="tertiary" size="small">
{transport === 'direct'
? '直连模式:能直连的平台由浏览器直接向 CDN 拉流,不经 biliup;不能的自动回落到录制流中转(角标标出原因)。关闭弹窗即断开。'
: '画面来自正在写盘的同一路流,不另外向直播平台拉流;关闭弹窗即断开。'}
</Text>
</div>
</Modal>
)
}
/**
* 「预览」按钮 + 弹层。录制中且下载器能旁路时可点;否则禁用并用 tooltip 说明原因
* (ffmpeg / streamlink 子进程落盘、fMP4、HEVC、容器尚未确定)。
*/
export function LivePreviewButton({
streamer,
size = 'small',
theme = 'light',
iconOnly = false,
className,
}: {
streamer: LiveStreamerEntity
size?: ButtonProps['size']
theme?: ButtonProps['theme']
iconOnly?: boolean
className?: string
}) {
const [open, setOpen] = useState(false)
const enabled = canPreview(streamer)
const reason = previewDisabledReason(streamer)
const button = (
<Button
size={size}
theme={theme}
type={enabled ? 'primary' : 'tertiary'}
icon={<IconPlay />}
disabled={!enabled}
className={className}
aria-label={iconOnly ? '预览' : undefined}
onClick={(e) => {
e.stopPropagation()
setOpen(true)
}}
>
{iconOnly ? null : '预览'}
</Button>
)
return (
<>
{enabled ? (
button
) : (
<Tooltip content={reason ?? '暂不可预览'}>
{/* disabled 按钮不触发鼠标事件,包一层让 tooltip 仍能弹出 */}
<span className={styles.disabledWrap}>{button}</span>
</Tooltip>
)}
{open ? (
<LivePreviewModal streamer={streamer} visible={open} onClose={() => setOpen(false)} />
) : null}
</>
)
}
+671 -52
View File
@@ -1,92 +1,711 @@
import React, { useEffect, useRef } from 'react'
import Artplayer from 'artplayer'
import artplayerPluginDanmuku from 'artplayer-plugin-danmuku'
import mpegts from 'mpegts.js'
import type { DanmakuFeed, DanmakuFrame } from '@/app/lib/danmaku-feed'
type VideoPlayer = Artplayer | null
/** mpegts.js 能直接解封装的两种容器;其它扩展名交给浏览器原生 <video> */
export type MpegtsType = 'flv' | 'mpegts'
/**
* 直播预览支持的容器:flv / mpegts 走 mpegts.js,fmp4 直接 MediaSource 追加,
* hls(m3u8,浏览器直连 CDN 时的 HLS 直链)走 hls.js(按需加载,只在用到时下载那 ~120 KB)
*/
export type LiveType = MpegtsType | 'fmp4' | 'hls'
interface PlayerConfig {
url: string
height?: string
width?: string
/**
* 容器类型。不传时按 url 扩展名判断(`.flv` → flv,其余原生)。
* 直播预览的地址没有扩展名,由调用方按后端给的 `preview.format`(与响应 Content-Type 一致)传入;
* 直播且未传时,先用一次 GET 探测响应的 Content-Type 再起播。
*/
type?: LiveType
/** fmp4 的 RFC 6381 编码串(后端从 init segment 解出,如 `avc1.64001f,mp4a.40.2`) */
codecs?: string | null
/** 直播:隐藏进度条、开启追帧,播放器随分块到达持续解码 */
isLive?: boolean
muted?: boolean
autoplay?: boolean
/** 直播流结束 / 被服务端断开(如客户端掉队、录制换直链)时回调,调用方决定是否重连 */
onEnded?: () => void
/** mpegts.js 或探测阶段报错(网络 / 解码),附一句可展示的说明 */
onError?: (message: string) => void
/**
* 实时弹幕:`feed` 是页面持有的共享连接(见 `DanmakuFeed`),`id` 是本路直播间;
* `feed` 为 null 时弹幕层隐藏。传了这个 prop 就装 artplayer-plugin-danmuku,开关切换只
* 订阅 / 退订与显示 / 隐藏,不重建播放器。
*/
danmaku?: { id: number; feed: DanmakuFeed | null; fontSize?: number }
}
function playFlv(video: HTMLVideoElement, url: string, art: Artplayer) {
if (mpegts.isSupported()) {
const artWithFlv = art as Artplayer & { flv?: mpegts.Player | null }
if (artWithFlv.flv) {
artWithFlv.flv.destroy()
artWithFlv.flv = null
}
type DanmukuPlugin = ReturnType<ReturnType<typeof artplayerPluginDanmuku>>
const flv = mpegts.createPlayer({
type: 'flv',
url: url,
/** 把后端的 RGB 整数转成 CSS 颜色 */
function rgbInt(color: number): string {
return `#${(color & 0xffffff).toString(16).padStart(6, '0')}`
}
/**
* mpegts.js 的直播拉流 loader:只做一件事——`abort()` 时**一定**中止 fetch。
*
* 自带的 FetchStreamLoader 在 Chrome 上处于 buffering 状态时不调 `AbortController.abort()`,
* 而是等下一个 `read()` 回来再 `reader.cancel()`;解码出错后读取链已经断了,那一次永远等不到,
* 拉流连接就留在服务端占着该路的预览许可直到刷新页面(真实 Twitch TS 房间连开 4 次就 429)。
* 这里每次 `open()` 建一个 AbortController,`abort()` 直接调它,其余行为与原 loader 一致
* (直播不需要 Range,`needStash` 同样为 true)。
*/
class AbortableFetchLoader extends mpegts.BaseLoader {
private controller: AbortController | null = null
private received = 0
_needStash = true
constructor(_seekHandler: unknown, _config: unknown) {
super('abortable-fetch-loader')
}
static isSupported() {
return typeof self.fetch === 'function' && typeof self.ReadableStream === 'function'
}
destroy() {
if (this.isWorking()) this.abort()
super.destroy()
}
open(dataSource: { url: string; withCredentials?: boolean }, range: { from: number; to: number }) {
const controller = new AbortController()
this.controller = controller
this.received = 0
this._status = mpegts.LoaderStatus.kConnecting
fetch(dataSource.url, {
method: 'GET',
mode: 'cors',
cache: 'no-store',
credentials: dataSource.withCredentials ? 'include' : 'same-origin',
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok || !res.body) {
this._status = mpegts.LoaderStatus.kError
// 类型声明里 onError 的第一个参数是 LoaderErrors 接口本身,实际是其中的字符串值
this.onError?.(mpegts.LoaderErrors.HTTP_STATUS_CODE_INVALID as unknown as mpegts.LoaderErrors, {
code: res.status,
msg: res.statusText,
})
return
}
const reader = res.body.getReader()
for (;;) {
const { done, value } = await reader.read()
if (controller.signal.aborted) return
if (done) {
this._status = mpegts.LoaderStatus.kComplete
this.onComplete?.(range.from, range.from + this.received - 1)
return
}
this._status = mpegts.LoaderStatus.kBuffering
const chunk = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer
const byteStart = range.from + this.received
this.received += chunk.byteLength
this.onDataArrival?.(chunk, byteStart, this.received)
}
})
.catch((e: Error & { code?: number }) => {
if (controller.signal.aborted) return
this._status = mpegts.LoaderStatus.kError
// 服务端结束响应(掉队断开 / 换直链)走 EARLY_EOF,mpegts.js 会把 MSE 收尾成 ended
const earlyEof = e.name === 'TypeError' || e.code === 19
const kind = earlyEof ? mpegts.LoaderErrors.EARLY_EOF : mpegts.LoaderErrors.EXCEPTION
this.onError?.(kind as unknown as mpegts.LoaderErrors, { code: e.code ?? -1, msg: e.message })
})
}
artWithFlv.flv = flv
art.on('destroy', () => {
if (artWithFlv.flv) {
artWithFlv.flv.destroy()
artWithFlv.flv = null
}
})
flv.attachMediaElement(video)
flv.load()
} else {
art.notice.show = 'Unsupported playback format: flv'
abort() {
this._status = mpegts.LoaderStatus.kComplete
this.controller?.abort()
this.controller = null
}
}
const Players: React.FC<PlayerConfig> = ({ url, height = '100%', width = '100%' }) => {
/** 销毁 mpegts.js 实例;`destroy()` 内部清 SourceBuffer 在元素出错后可能抛,拉流已由 loader 的 abort 保证断开。 */
function destroyMpegts(player: mpegts.Player) {
try {
player.destroy()
} catch (e) {
console.warn('[Player] mpegts.destroy 抛出异常(拉流已断开)', e)
}
}
/** 一条弹幕送进弹幕层:普通弹幕滚动,礼物 / 醒目留言 / 上舰置顶并描边。 */
function emitFrame(plugin: DanmukuPlugin, frame: DanmakuFrame) {
plugin.emit({
text: frame.text,
color: rgbInt(frame.color),
mode: frame.kind === 'danmaku' ? 0 : 1,
border: frame.kind !== 'danmaku',
})
}
/** 按响应头判断直播流容器:`video/x-flv` → flv,`video/mp2t` → mpegts,`video/mp4` → fmp4。 */
export function liveTypeFromContentType(contentType: string | null | undefined): LiveType | null {
const ct = (contentType ?? '').toLowerCase()
if (ct.includes('flv')) return 'flv'
if (ct.includes('mp2t') || ct.includes('mpegts') || ct.includes('mpeg2-ts')) return 'mpegts'
if (ct.includes('mp4')) return 'fmp4'
return null
}
/**
* 用一次 GET 读到响应头就中止,只为拿 Content-Type。
* 非 2xx 时把服务端的说明(415 / 429 / 503 的正文)原样抛出。
*/
async function probeLiveType(url: string): Promise<LiveType> {
const controller = new AbortController()
try {
const res = await fetch(url, { signal: controller.signal, cache: 'no-store' })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const type = liveTypeFromContentType(res.headers.get('content-type'))
if (!type) throw new Error(`无法识别的流类型: ${res.headers.get('content-type') ?? '未知'}`)
return type
} finally {
controller.abort()
}
}
/** fmp4 预览要求的 MIME;`codecs` 为空时返回 null(Chrome 的 addSourceBuffer 必须带 codecs) */
export function fmp4MimeType(codecs: string | null | undefined): string | null {
const c = (codecs ?? '').trim()
return c ? `video/mp4; codecs="${c}"` : null
}
/** 当前浏览器能否用 MSE 播这组 fmp4 编码;SSR / 无 MediaSource 环境返回 false */
export function canPlayFmp4(codecs: string | null | undefined): boolean {
const mime = fmp4MimeType(codecs)
if (!mime || typeof window === 'undefined' || typeof MediaSource === 'undefined') return false
try {
return MediaSource.isTypeSupported(mime)
} catch {
return false
}
}
/** 直播追帧:缓冲末尾落后超过这么多秒就跳到末尾附近 */
const FMP4_MAX_LATENCY_S = 3
const FMP4_TARGET_REMAIN_S = 0.5
/** 早于当前播放位置这么多秒的缓冲定期清掉,长时间观看不涨内存 */
const FMP4_KEEP_BACKWARD_S = 30
interface Fmp4Options {
codecs: string | null | undefined
autoplay: boolean
onEnded?: () => void
onError?: (message: string) => void
}
interface HlsOptions {
autoplay: boolean
onEnded?: () => void
onError?: (message: string) => void
}
type HlsInstance = import('hls.js').default
/**
* Artplayer customType:HLS 直链(浏览器直连 CDN 的 TS / fMP4 分片流)交给 hls.js。
* 只在直连模式下会走到这里,所以 hls.js 用动态 import,不进首屏包。
* 直播参数:跟到最后 3 个分片、后向缓冲 30 s;分片请求不带凭据(CDN 的 ACAO 是 *)。
* 致命错误(清单 / 分片 404、CORS、解码)报给上层,由 LivePreviewPlayer 重取直链或回落中转。
*/
async function playWithHls(video: HTMLVideoElement, url: string, art: Artplayer, { autoplay, onEnded, onError }: HlsOptions) {
if (art.isDestroy) return
const artWithHls = art as Artplayer & { hls?: HlsInstance | null }
artWithHls.hls?.destroy()
artWithHls.hls = null
const { default: Hls } = await import('hls.js')
if (art.isDestroy) return
if (!Hls.isSupported()) {
// Safari 等原生支持 HLS 的浏览器直接交给 <video>
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url
if (autoplay) video.play().catch(() => {})
return
}
onError?.('当前浏览器不支持 MSE,无法播放 HLS')
return
}
const hls = new Hls({
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
backBufferLength: 30,
maxBufferLength: 30,
enableWorker: true,
xhrSetup: (xhr) => {
xhr.withCredentials = false
},
})
artWithHls.hls = hls
art.on('destroy', () => {
if (artWithHls.hls) {
artWithHls.hls.destroy()
artWithHls.hls = null
}
})
hls.on(Hls.Events.ERROR, (_event, data) => {
if (!data.fatal) return
const status = data.response?.code
const detail =
data.type === Hls.ErrorTypes.NETWORK_ERROR
? status
? `连接失败(HTTP ${status})`
: `网络错误:${data.details}`
: data.type === Hls.ErrorTypes.MEDIA_ERROR
? `浏览器解码失败:${data.details}`
: `播放失败:${data.details}`
onError?.(detail)
})
// 直播清单不再更新(主播下播 / 直链失效但清单还在)→ 当作断开让上层处理
hls.on(Hls.Events.LEVEL_UPDATED, (_event, data) => {
if (data.details.live === false) onEnded?.()
})
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (autoplay) {
video.play().catch(() => {
if (art.isDestroy || artWithHls.hls !== hls) return
video.muted = true
video.play().catch(() => {})
})
}
})
hls.loadSource(url)
hls.attachMedia(video)
}
/**
* Artplayer customType:fMP4 直接交给 MediaSource。
* 服务端先发 init segment(ftyp + moov)再按 moof + mdat 分片广播,正是 MSE 的原生输入,不需要转封装:
* fetch 的 ReadableStream 按到达顺序进 appendBuffer 队列(`updateend` 串行),
* 首个缓冲区出现时把播放位置对齐到缓冲起点,之后落后超过阈值就追到末尾,并定期 remove 旧缓冲。
*/
function playWithMediaSource(
video: HTMLVideoElement,
url: string,
art: Artplayer,
{ codecs, autoplay, onEnded, onError }: Fmp4Options
) {
// 同 playWithMpegts:Artplayer 出错后的自动重连会在实例销毁后仍重设 url,忽略
if (art.isDestroy) return
const artWithMse = art as Artplayer & { fmp4Dispose?: (() => void) | null }
artWithMse.fmp4Dispose?.()
artWithMse.fmp4Dispose = null
const mime = fmp4MimeType(codecs)
if (!mime) {
onError?.('后端未能解析出 fMP4 的编码参数,无法起播')
return
}
if (typeof MediaSource === 'undefined' || !MediaSource.isTypeSupported(mime)) {
onError?.(`浏览器不支持该编码(${codecs}),无法在页面内播放`)
return
}
const controller = new AbortController()
const mediaSource = new MediaSource()
const objectUrl = URL.createObjectURL(mediaSource)
const queue: Uint8Array[] = []
let sourceBuffer: SourceBuffer | null = null
let streamEnded = false
let disposed = false
let aligned = false
let chaser: ReturnType<typeof setInterval> | null = null
const fail = (message: string) => {
if (disposed) return
onError?.(message)
}
const pump = () => {
if (disposed || !sourceBuffer || sourceBuffer.updating || mediaSource.readyState !== 'open') return
const next = queue.shift()
if (next) {
try {
sourceBuffer.appendBuffer(next as BufferSource)
} catch (error) {
// 配额满:先清掉早于当前位置的缓冲再重试这一块
if ((error as DOMException)?.name === 'QuotaExceededError' && video.buffered.length) {
queue.unshift(next)
const cut = Math.max(video.buffered.start(0), video.currentTime - 5)
if (cut > video.buffered.start(0)) sourceBuffer.remove(video.buffered.start(0), cut)
else fail('播放缓冲已满')
} else {
fail(`MSE 追加失败: ${(error as Error)?.message ?? error}`)
}
}
} else if (streamEnded) {
try {
mediaSource.endOfStream()
} catch {
/* 已经结束 */
}
}
}
const trim = () => {
if (!sourceBuffer || sourceBuffer.updating || !video.buffered.length) return
const start = video.buffered.start(0)
const cut = video.currentTime - FMP4_KEEP_BACKWARD_S
if (cut - start > 5) sourceBuffer.remove(start, cut)
}
const chase = () => {
if (disposed || !video.buffered.length) return
const end = video.buffered.end(video.buffered.length - 1)
if (!aligned) {
// 直播分片的时间戳从一个很大的值起步,播放位置得先跳进缓冲区
aligned = true
video.currentTime = Math.max(video.buffered.start(0), end - FMP4_TARGET_REMAIN_S)
if (autoplay) video.play().catch(() => {
video.muted = true
video.play().catch(() => {})
})
return
}
if (end - video.currentTime > FMP4_MAX_LATENCY_S && !video.paused) {
video.currentTime = end - FMP4_TARGET_REMAIN_S
}
}
const dispose = () => {
if (disposed) return
disposed = true
controller.abort()
if (chaser) clearInterval(chaser)
try {
if (mediaSource.readyState === 'open') mediaSource.endOfStream()
} catch {
/* ignore */
}
URL.revokeObjectURL(objectUrl)
}
artWithMse.fmp4Dispose = dispose
art.on('destroy', dispose)
mediaSource.addEventListener('sourceopen', () => {
if (disposed) return
try {
sourceBuffer = mediaSource.addSourceBuffer(mime)
} catch (error) {
fail(`浏览器拒绝该编码(${codecs}): ${(error as Error)?.message ?? error}`)
return
}
sourceBuffer.mode = 'segments'
sourceBuffer.addEventListener('updateend', () => {
trim()
pump()
})
sourceBuffer.addEventListener('error', () => fail('MSE 解码失败'))
chaser = setInterval(chase, 500)
fetch(url, { signal: controller.signal, cache: 'no-store' })
.then(async (res) => {
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `连接失败(HTTP ${res.status})`)
}
if (!res.body) throw new Error('浏览器不支持流式读取响应')
const reader = res.body.getReader()
for (;;) {
const { done, value } = await reader.read()
if (disposed) return
if (done) break
if (value) {
queue.push(value)
pump()
}
}
streamEnded = true
pump()
// 服务端结束了响应(掉队断开 / 换直链 / 录制结束)
onEnded?.()
})
.catch((error: unknown) => {
if (disposed || (error as Error)?.name === 'AbortError') return
fail(error instanceof Error ? error.message : String(error))
})
})
video.src = objectUrl
}
function describeMpegtsError(errorType: string, detail: string, info?: { code?: number; msg?: string }): string {
if (errorType === mpegts.ErrorTypes.NETWORK_ERROR) {
if (detail === mpegts.ErrorDetails.NETWORK_STATUS_CODE_INVALID) {
const code = info?.code
if (code === 415) return '当前下载器 / 容器不支持预览'
if (code === 429) return '预览连接数已达上限,请稍后再试'
if (code === 503) return '录制尚未开始拉流或正在重连'
return `连接失败(HTTP ${code ?? '?'})`
}
return `网络错误: ${info?.msg ?? detail}`
}
if (errorType === mpegts.ErrorTypes.MEDIA_ERROR) {
if (detail === mpegts.ErrorDetails.MEDIA_CODEC_UNSUPPORTED) return '浏览器不支持该编码(可能为 HEVC)'
if (detail === mpegts.ErrorDetails.MEDIA_MSE_ERROR) {
// 典型来源:TS 的 PES 没有按访问单元对齐(如 Twitch),mpegts.js 拆出的帧不完整,浏览器拒绝解码
return '浏览器解码失败:该直播源的封装方式 mpegts.js 无法处理,录制不受影响'
}
return `解码错误: ${info?.msg ?? detail}`
}
return `播放错误: ${info?.msg ?? detail}`
}
interface MpegtsOptions {
type: MpegtsType
isLive: boolean
autoplay: boolean
onEnded?: () => void
onError?: (message: string) => void
}
/** Artplayer customType:用 mpegts.js 解封装 FLV / MPEG-TS 后喂给 MSE。 */
function playWithMpegts(
video: HTMLVideoElement,
url: string,
art: Artplayer,
{ type, isLive, autoplay, onEnded, onError }: MpegtsOptions
) {
if (!mpegts.isSupported()) {
art.notice.show = `当前浏览器不支持 MSE,无法播放 ${type}`
onError?.('当前浏览器不支持 MSE')
return
}
// Artplayer 在 video:error 后会隔 1 s 重设 art.url 最多 5 次(RECONNECT_TIME_MAX),
// 而且不看自己是否已被 destroy——那会在弹层关闭后凭空再拉一路流、占着服务端许可。
// 重连由 LivePreviewPlayer 自己管,这里对已销毁的实例直接不理。
if (art.isDestroy) return
const artWithMpegts = art as Artplayer & { mpegts?: mpegts.Player | null }
if (artWithMpegts.mpegts) {
destroyMpegts(artWithMpegts.mpegts)
artWithMpegts.mpegts = null
}
const player = mpegts.createPlayer(
// 直连 CDN 时是跨域请求:cors 模式、不带 cookie(这些 CDN 的 ACAO 是 *,带凭据反而会被拒);
// 同源的 /live 走 same-origin 凭据,登录 cookie 照常带上(见 AbortableFetchLoader)
{ type, url, isLive, cors: true, withCredentials: false },
isLive
? {
// 直播:不攒缓冲、落后就追,源缓冲区用完即清理,长时间观看不涨内存
enableStashBuffer: false,
customLoader: AbortableFetchLoader,
liveBufferLatencyChasing: true,
liveBufferLatencyMaxLatency: 3,
liveBufferLatencyMinRemain: 0.5,
autoCleanupSourceBuffer: true,
autoCleanupMaxBackwardDuration: 30,
autoCleanupMinBackwardDuration: 10,
}
: {}
)
artWithMpegts.mpegts = player
art.on('destroy', () => {
if (artWithMpegts.mpegts) {
destroyMpegts(artWithMpegts.mpegts)
artWithMpegts.mpegts = null
}
})
player.on(mpegts.Events.ERROR, (errorType: string, detail: string, info?: { code?: number; msg?: string }) => {
const message = describeMpegtsError(errorType, detail, info)
art.notice.show = message
onError?.(message)
})
if (isLive) {
// 服务端结束响应(掉队断开 / 换直链)时 mpegts.js 会把 MSE 收尾成 ended
video.addEventListener('ended', () => onEnded?.(), { once: true })
}
player.attachMediaElement(video)
player.load()
if (autoplay) {
const playing = player.play()
if (playing && typeof (playing as Promise<void>).catch === 'function') {
;(playing as Promise<void>).catch(() => {
// 自动播放被浏览器拦下时静音重试,直播预览宁可无声也不要卡住;
// play() 被打断也可能是实例已被销毁(弹层关闭),那就不要再碰它
if (art.isDestroy || artWithMpegts.mpegts !== player) return
video.muted = true
player.play()
})
}
}
}
const Players: React.FC<PlayerConfig> = ({
url,
height = '100%',
width = '100%',
type,
codecs,
isLive = false,
muted = false,
autoplay = isLive,
onEnded,
onError,
danmaku,
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const playerRef = useRef<VideoPlayer>(null)
const danmakuCapable = !!danmaku
const danmakuId = danmaku?.id
const danmakuFeed = danmaku?.feed ?? null
const danmakuFontSize = danmaku?.fontSize ?? 22
// 回调放进 ref:父组件每次渲染传入的新函数不应重建播放器
const callbacksRef = useRef({ onEnded, onError })
useEffect(() => {
callbacksRef.current = { onEnded, onError }
})
useEffect(() => {
if (!containerRef.current) return
const container = containerRef.current
let cancelled = false
try {
const create = (mediaType: LiveType | null) => {
if (cancelled || !container.isConnected) return
if (playerRef.current) {
playerRef.current.destroy()
playerRef.current = null
}
if (url.endsWith('.flv')) {
playerRef.current = new Artplayer({
container: containerRef.current,
url,
type: 'flv',
customType: {
flv: playFlv,
},
autoSize: true,
fullscreen: true,
fullscreenWeb: true,
autoOrientation: true,
plugins: [],
})
} else {
playerRef.current = new Artplayer({
container: containerRef.current,
url,
autoSize: true,
fullscreen: true,
fullscreenWeb: true,
autoOrientation: true,
plugins: [],
})
const base = {
container,
url,
autoSize: !isLive,
fullscreen: true,
fullscreenWeb: true,
autoOrientation: true,
isLive,
muted,
autoplay,
plugins: danmakuCapable
? [
artplayerPluginDanmuku({
danmuku: [],
speed: 7,
fontSize: danmakuFontSize,
opacity: 0.9,
antiOverlap: true,
synchronousPlayback: false,
emitter: false,
visible: true,
margin: [10, '25%'],
}),
]
: [],
}
} catch (error) {
console.error('播放器初始化失败:', error)
const onEndedCb = () => callbacksRef.current.onEnded?.()
const onErrorCb = (message: string) => callbacksRef.current.onError?.(message)
try {
if (mediaType === 'hls') {
const options: HlsOptions = { autoplay, onEnded: onEndedCb, onError: onErrorCb }
playerRef.current = new Artplayer({
...base,
type: 'hls',
customType: {
hls: (video: HTMLVideoElement, src: string, art: Artplayer) => {
playWithHls(video, src, art, options).catch((e: unknown) =>
onErrorCb(`加载 hls.js 失败:${e instanceof Error ? e.message : String(e)}`)
)
},
},
})
} else if (mediaType === 'fmp4') {
const options: Fmp4Options = { codecs, autoplay, onEnded: onEndedCb, onError: onErrorCb }
playerRef.current = new Artplayer({
...base,
type: 'fmp4',
customType: {
fmp4: (video: HTMLVideoElement, src: string, art: Artplayer) =>
playWithMediaSource(video, src, art, options),
},
})
} else if (mediaType) {
const options: MpegtsOptions = {
type: mediaType,
isLive,
autoplay,
onEnded: onEndedCb,
onError: onErrorCb,
}
playerRef.current = new Artplayer({
...base,
type: mediaType,
customType: {
[mediaType]: (video: HTMLVideoElement, src: string, art: Artplayer) =>
playWithMpegts(video, src, art, options),
},
})
} else {
playerRef.current = new Artplayer(base)
}
} catch (error) {
console.error('播放器初始化失败:', error)
callbacksRef.current.onError?.('播放器初始化失败')
}
}
const declared: LiveType | null = type ?? (url.endsWith('.flv') ? 'flv' : null)
if (declared || !isLive) {
create(declared)
} else {
probeLiveType(url).then(create, (error: unknown) => {
if (cancelled) return
callbacksRef.current.onError?.(error instanceof Error ? error.message : String(error))
})
}
return () => {
cancelled = true
if (playerRef.current) {
playerRef.current.destroy()
playerRef.current = null
}
}
}, [url, height, width])
}, [url, height, width, type, codecs, isLive, muted, autoplay, danmakuCapable, danmakuFontSize])
// 弹幕开关:有 feed → 订阅本路、显示弹幕层;没有 → 退订、隐藏。不重建播放器。
// 播放器可能还在探测 Content-Type(异步创建),所以轮询等到实例出现再挂。
useEffect(() => {
if (danmakuId === undefined || !danmakuFeed) {
const plugin = playerRef.current?.plugins?.artplayerPluginDanmuku as DanmukuPlugin | undefined
plugin?.hide()
return
}
let unsubscribe: (() => void) | null = null
let cancelled = false
const attach = () => {
if (cancelled) return true
const plugin = playerRef.current?.plugins?.artplayerPluginDanmuku as DanmukuPlugin | undefined
if (!plugin) return false
plugin.show()
unsubscribe = danmakuFeed.subscribe(danmakuId, (frame) => emitFrame(plugin, frame))
return true
}
if (!attach()) {
const timer = setInterval(() => {
if (attach()) clearInterval(timer)
}, 300)
return () => {
cancelled = true
clearInterval(timer)
unsubscribe?.()
}
}
return () => {
cancelled = true
unsubscribe?.()
}
}, [danmakuId, danmakuFeed, url, type, codecs])
return <div ref={containerRef} style={{ width, height }} />
}
+65 -5
View File
@@ -3,7 +3,15 @@ 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 } from '@/app/lib/use-dashboard'
import {
timeAgo,
formatDuration,
formatRate,
liveImageUrl,
useNowSec,
canPreview,
} from '@/app/lib/use-dashboard'
import { LivePreviewButton, LivePreviewModal } from './LivePreview'
import styles from './streamer-card.module.scss'
export interface StreamerCardProps {
@@ -40,12 +48,39 @@ function platTint(color: string): string {
* 录制中的直播间封面缩略图(16:9,懒加载)。
* 加载失败就整块消失,卡片回到没有封面时的样式;加载中显示占位底色。
* 调用方用 key={src} 挂载,封面地址变化时状态自然重置。
* 传入 onPreview 时封面可点击,中央显示播放标记,点击打开直播预览。
*/
export function LiveCover({ src, alt }: { src: string; alt: string }) {
export function LiveCover({
src,
alt,
onPreview,
}: {
src: string
alt: string
onPreview?: () => void
}) {
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
if (state === 'error') return null
const clickable = !!onPreview && state === 'ok'
return (
<figure className={styles.cover} data-state={state}>
<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}
@@ -55,6 +90,13 @@ export function LiveCover({ src, alt }: { src: string; alt: string }) {
onLoad={() => setState('ok')}
onError={() => setState('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}
</figure>
)
}
@@ -103,6 +145,9 @@ export default function StreamerCard({
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
@@ -151,8 +196,15 @@ export default function StreamerCard({
</span>
</div>
{/* 录制中:直播间封面 */}
{coverSrc ? <LiveCover key={coverSrc} src={coverSrc} alt={`${name} 的直播间封面`} /> : null}
{/* 录制中:直播间封面(可预览时点击即打开播放器) */}
{coverSrc ? (
<LiveCover
key={coverSrc}
src={coverSrc}
alt={`${name} 的直播间封面`}
onPreview={previewable ? () => setPreviewOpen(true) : undefined}
/>
) : null}
{/* 名称(录制中带头像)+ 实时时长与写盘速率 / 最近录制 */}
<div className={styles.nameRow}>
@@ -172,6 +224,7 @@ export default function StreamerCard({
>
{rate ?? '—'}
</span>
<LivePreviewButton streamer={streamer} size="small" iconOnly className={styles.previewBtn} />
</span>
) : lastRec ? (
<span className={styles.recInfo}>{lastRec}</span>
@@ -193,6 +246,13 @@ export default function StreamerCard({
</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>
)
}
+165
View File
@@ -0,0 +1,165 @@
/* 实时监视:多路小窗网格(历史记录页 Tab,组件可整体挪动) */
.monitor {
display: flex;
flex-direction: column;
gap: 12px;
}
.center {
display: flex;
justify-content: center;
padding: 48px 0;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px 16px;
}
.summary {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.controls {
display: inline-flex;
align-items: center;
gap: 8px;
}
.switchRow {
display: inline-flex;
align-items: center;
gap: 6px;
margin-right: 8px;
}
.hint {
line-height: 1.5;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr));
gap: 14px;
}
/* ===== 小窗 ===== */
.tile {
display: flex;
flex-direction: column;
border: 1px solid var(--semi-color-fill-2);
border-radius: 12px;
background: var(--semi-color-bg-1);
overflow: hidden;
box-shadow: 0 1px 2px rgba(var(--semi-grey-9), 0.04), 0 2px 6px rgba(var(--semi-grey-9), 0.03);
transition: border-color 0.15s ease;
}
.tilePlaying {
border-color: rgba(var(--semi-green-4), 0.6);
}
.tileHead {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px 8px 12px;
min-height: 40px;
}
.tileName {
font-size: 13.5px;
font-weight: 650;
color: var(--semi-color-text-0);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1;
}
.tileRate {
font-size: 12px;
font-weight: 600;
color: var(--semi-color-text-2);
font-variant-numeric: tabular-nums;
white-space: nowrap;
padding: 1px 7px;
border-radius: 999px;
background: var(--semi-color-fill-0);
}
/* 未播放:封面 + 说明,整块可点 */
.idle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 16 / 9;
border: 0;
padding: 0;
margin: 0;
background: var(--semi-color-fill-1);
color: #fff;
cursor: pointer;
overflow: hidden;
text-align: center;
}
.idle:disabled {
cursor: not-allowed;
}
.idle:focus-visible {
outline: 2px solid var(--semi-color-primary);
outline-offset: -2px;
}
.idleCover {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.55;
transition: opacity 0.15s ease;
}
.idle:not(:disabled):hover .idleCover {
opacity: 0.4;
}
.idle:disabled .idleCover {
filter: grayscale(0.6);
opacity: 0.35;
}
.idleText {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
padding: 6px 12px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.55);
max-width: 90%;
line-height: 1.4;
}
.idle:disabled .idleText {
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
}
.tileFoot {
padding: 7px 12px 9px;
font-size: 12px;
color: var(--semi-color-text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
+135
View File
@@ -0,0 +1,135 @@
/* 直播预览:卡片弹层播放区 + 监视器小窗共用 */
.player {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
overflow: hidden;
}
.player > div {
width: 100% !important;
height: 100% !important;
}
/* 连接中 / 重连中 / 出错时盖在画面上的说明层 */
.overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 16px;
text-align: center;
color: rgba(255, 255, 255, 0.88);
background: rgba(0, 0, 0, 0.55);
pointer-events: none;
}
.overlay :global(.semi-button) {
pointer-events: auto;
}
.player[data-phase='connecting'] .overlay,
.player[data-phase='reconnecting'] .overlay {
background: rgba(0, 0, 0, 0.35);
}
/* 出错 / 断开时垫在说明后面的封面 */
.overlayCover {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.35;
z-index: -1;
}
.overlay {
isolation: isolate;
}
.overlayText {
font-size: 13px;
line-height: 1.5;
max-width: 90%;
word-break: break-word;
}
/* ===== 弹层 ===== */
.modal :global(.semi-modal-header) {
margin-bottom: 0;
padding-bottom: 12px;
}
.modalTitle {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
/* 右侧留出关闭按钮的位置 */
padding-right: 28px;
}
.modalName {
font-size: 15px;
font-weight: 650;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
/* 取流方式角标:左上角,不挡播放器控制栏 */
.badge {
position: absolute;
top: 8px;
left: 8px;
/* Artplayer 内部各层 z-index 到 90+,角标要压在它们之上 */
z-index: 120;
max-width: calc(100% - 16px);
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
line-height: 18px;
color: #fff;
background: rgba(0, 0, 0, 0.55);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: auto;
}
.badge[data-tone='direct'] {
background: rgba(22, 163, 74, 0.75);
}
.badge[data-tone='fallback'] {
background: rgba(217, 119, 6, 0.8);
}
.badge[data-compact] {
top: 4px;
left: 4px;
font-size: 11px;
line-height: 16px;
padding: 1px 6px;
}
.danmakuSwitch {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
flex-shrink: 0;
}
.modalFoot {
padding: 10px 24px 14px;
}
/* disabled 的按钮不响应鼠标,包一层让 Tooltip 仍能弹出 */
.disabledWrap {
display: inline-flex;
}
.disabledWrap :global(.semi-button) {
pointer-events: none;
}
+28
View File
@@ -268,6 +268,34 @@ const Global: React.FC = () => {
}}
showClear={true}
/>
<Form.Select
field="preview_transport"
label="直播预览取流方式(preview_transport)"
extraText={
<div style={{ fontSize: '14px' }}>
<div>
<strong>经 biliup 中转</strong>(默认):页面里的预览复用正在录制的那一路流,不向直播平台多拉一路。
浏览器与 biliup 在同一台机器或同一内网时选这个,不多占 CDN 带宽。
</div>
<div>
<strong>浏览器直连 CDN</strong>:biliup 向平台另取一条直链(新 token,不影响录制那条),浏览器自己去
CDN 拉,媒体流量不经过 biliup,适合 biliup 部署在异地服务器、浏览器远程访问的情况,省服务器出口带宽。
只有 CDN 放行跨域的平台能直连(B 站 / 抖音 / 斗鱼 / 虎牙,FLV 与 HLS 都行);Twitch 等未放行跨域的
平台在直连模式下自动回落中转并在播放器角标标出原因。
</div>
</div>
}
placeholder="经 biliup 中转(relay)"
style={{ width: '100%' }}
fieldStyle={{
alignSelf: 'stretch',
padding: 0,
}}
showClear={true}
>
<Form.Select.Option value="relay">经 biliup 中转(relay)</Form.Select.Option>
<Form.Select.Option value="direct">浏览器直连 CDN(direct)</Form.Select.Option>
</Form.Select>
</div>
<Space />
+42
View File
@@ -147,6 +147,40 @@
opacity: 1;
}
/* 可预览时封面可点:中央播放标记,悬停加深 */
.coverClickable {
cursor: pointer;
}
.coverClickable:focus-visible {
outline: 2px solid var(--semi-color-primary);
outline-offset: 2px;
}
.coverPlay {
position: absolute;
left: 50%;
top: 50%;
width: 44px;
height: 44px;
margin: -22px 0 0 -22px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(2px);
opacity: 0.85;
transition: opacity 0.15s ease, transform 0.15s ease, background 0.15s ease;
}
.coverPlay svg {
margin-left: 2px;
}
.coverClickable:hover .coverPlay {
opacity: 1;
transform: scale(1.06);
background: rgba(0, 0, 0, 0.6);
}
/* ===== 名称 + 时长 ===== */
.nameRow {
display: flex;
@@ -216,6 +250,14 @@
text-align: center;
}
/* 直播中:预览按钮(图标) */
.previewBtn {
height: 22px;
min-width: 22px;
padding: 0 6px !important;
align-self: center;
}
/* 未开播:最近录制时间 */
.recInfo {
font-size: 12px;