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:
39 files changed
+5684
-92
No files matched your search
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))]
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user