* fix(preview): stream-gears FLV 在解析点旁路,不等写盘循环整 GOP 落盘 httpflv 的写盘循环把 tag 攒在 flv_tags_cache 里,等下一个关键帧到来才把整个 GOP 一起写出——预览的旁路点原来就挂在这个写出循环里,于是预览端每个 GOP 间隔(B 站 常见 2–4 s)收到一次整 GOP 的突发、其余时间一个字节都不来,且整体落后一个 GOP。 播放器拿到的缓冲恰好在下一个突发到达时耗尽,零余量:同机 0 ms 下勉强连上,链路 上稍有 RTT / 抖动就 waiting;GOP 长于 mpegts.js 的追帧阈值时更是每个 GOP 都跳一次 再停一次(实测 GOP 4.2 s 的房间同机也有 85% 时间处于 waiting)。 把旁路挪到 tag 解析完、进缓存之前:预览按 tag 实时收到,与 mesio 路径(在条目进 管线前 tee)一致。写盘循环一个字节不变;旁路仍只是 push,不 await、不返回错误。 实测(arrival-probe,40 s):同一斗鱼房间直连 CDN 到达连续(最大静默 0.24 s), 中转修前每 2.0 s 一个 ~940 KB 突发、20 个静默间隙全部 > 1 s。 Refs #1712 #1713 * feat(preview): 快照保留最近 6 s 的 GOP,新订阅者起播就有几秒缓冲 原来快照只含「当前 GOP」:新订阅者从零到一个 GOP 之间的缓冲起步,之后到达 = 消耗, 缓冲永远这么薄,链路上几十到几百毫秒的抖动就 waiting。浏览器直连 CDN 时 CDN 会先 给几秒的 GOP 缓存,中转得给同样的深度。 PreviewSink 里多一个已完成 GOP 的队列(按关键帧到达时刻计,保留 SNAPSHOT_WINDOW = 6 s 以内的),快照 = 文件头 + 序列头 + 队列里的 GOP + 当前 GOP,实际深度在 6 s 到 6 s + 一个 GOP 之间。字节上限 MAX_SNAPSHOT_BYTES = 16 MB(6 s × 20 Mbps),超过先丢最旧的 GOP;单个 GOP 超过 MAX_GOP_BYTES 作废时历史一并放掉(不再连续);序列头内容变了或 fMP4 换了 init 时历史也放掉(旧 GOP 与新参数不配),内容相同的重发(stream-gears 分段时重放 onMetaData / 序列头)不影响。热路径只多几次 usize 比较与一次 VecDeque push,仍不 await、不返回错误;无订阅者时同样保留(第一个订阅者也要立刻有缓冲), 每路多占约 6 s 的码率(1080p 2–5 MB)。 `with_snapshot_window(Duration::ZERO)` 给只关心分块判定的测试用。 Refs #1712 #1713 * fix(preview): 客户端掉队时从最近关键帧原地续播,不再断开重连 广播缓冲被覆盖(Lagged)时原来直接结束响应,浏览器看到「连接已断开,2 秒后重连」 的加载态,再花一次 HTTP 往返 + 等下一个关键帧才恢复;带宽长期偏紧的链路上就是 反复「触发加载」。 现在 hub 多一个 `resubscribe(previous, timeout)`:复用原订阅的连接许可(不占第二 个名额),向写入端再要一份从最近关键帧起的快照与新接收端;响应在同一条连接里续 发这份快照——FLV 去掉文件头(`Subscription::snapshot_after_header`,解码器不能中途 再收到文件头;序列头照给,掉队期间可能换过),fMP4 的 init segment 照发(MSE 接受 中途再来一份)。mpegts.js 的 remuxer 会把批次之间的时间戳跳变收平,画面表现为跳 过一段而不是转圈;一次响应最多 MAX_RESYNCS_PER_RESPONSE = 20 次,再多说明客户端 长期跟不上,结束响应交给播放器。每次重对齐 warn 日志带 skipped 与累计次数。 Refs #1712 #1713 * perf(preview): 客户端追赶积压时把广播缓冲里攒着的分块合并写出 实时转发每收到一个分块就写一个 chunked 帧(FLV 一个 tag 几百字节到几十 KB)。 客户端跟得上时缓冲里本来就只有一个,无从合并;追赶积压(起播快照刚发完、链路 抖动之后)时缓冲里攒着几十上百个,逐个写出是几十上百次 socket 写与 chunked 帧。 `coalesce` 在 `recv()` 到手后用 `try_recv` 把已经在缓冲里的后续分块合并成一个 响应块(上限 COALESCE_BYTES = 64 KB),缓冲空着就原样透传不复制,不引入任何等待; 合并途中撞上 Lagged 时把跳过数交回主循环按掉队处理,不吞掉。 Refs #1712 #1713 * fix(ui): 中转模式的播放器维持约 5 s 缓冲,用倍速而不是跳变追帧 mpegts.js 原来对中转、直连一律 liveBufferLatencyMaxLatency 3 s / MinRemain 0.5 s: 缓冲一超过 3 s 就跳到末尾前 0.5 s,等于把服务端刚给的几秒快照直接扔掉,之后只剩 0.5 s 余量,链路一抖就 waiting;GOP 长于 3 s 的流更是每个 GOP 跳一次再停一次。 按取流方式分两套参数(`Players` 新 prop `transport`,LivePreviewPlayer 按实际 source 传): - relay:liveSync 倍速追帧——落后 > 9 s 用 1.1× 追到 5 s 附近,≤ 5 s 恢复 1×; 只在落后 > 20 s(长时间暂停后)才直接跳到末尾前 5 s。与服务端 6 s 的快照窗口配合, 播放器起播就有约 5 s 缓冲并一直保持。 - direct:沿用原参数(CDN 自带 GOP 缓存,ForgQi 实测异地直连不卡,不动)。 fMP4 的 MediaSource 播放器(只在中转路径上)同样改成:起播对齐到末尾前 5 s、 落后 > 9 s 用 1.1× 追、> 20 s 才跳;另加空洞跳过——服务端掉队重对齐后分片时间戳 前跳,缓冲区出现空洞,停在空洞前沿时跳到下一段起点,不再原地转圈。 Refs #1712 #1713 * style(preview): cargo fmt 与 clippy(len_zero) * feat(preview): /live 支持 snapshot_ms,起播快照按播放器要的缓冲深度回溯 新订阅者拿到多深的快照,播放器就有多深的起播缓冲,也就是多长的中转延迟。之前一律给整个 6 s 保留窗口,只适合「流畅」档;低延迟档要 2 s 就只该拿 2 s,而不是拿 6–10 s 再用 1.1 倍速追 40 s。 - PreviewHub::subscribe_with_depth(depth):从当前 GOP 往前取,取到第一个关键帧到达时刻早于 「现在 − depth」的 GOP 为止(含),起播缓冲落在 depth 到 depth + 一个 GOP 之间;Some(ZERO) 只给当前 GOP,None 给整个窗口。Subscription 记住 depth,Lagged 重新对齐沿用。 - GET /v1/streamers/{id}/live?snapshot_ms=2000;不带参数行为不变。「开始直播预览」日志多一个 snapshot_ms 字段。 - 保留窗口 SNAPSHOT_WINDOW 仍是 6 s,服务端内存不变;热路径只在有订阅请求时多一次 VecDeque::make_contiguous(历史只有几个 GOP)。 - 单测 +2:深度切分的边界(零 / 跨线 / 大于全部历史 / 空历史)、带深度订阅与重对齐沿用深度。 Refs #1713 #1716 * feat(ui): 中转预览分「低延迟 / 流畅」两档,默认低延迟 2 s,卡了自动升档 ForgQi 在 #1713 下问「中转有办法不延迟 5 s 也不卡顿吗」。主因(整 GOP 成坨下发)修掉后 5 s 是 过度配置:同一条模拟链路(60 ms RTT ± 16 ms、丢包重传停顿、20 Mbps)上四路真实房间扫 1 / 1.5 / 2 / 3 / 5 s 目标缓冲各播 180 s,三路 FLV 从 1 s 起就 0 次 waiting;只有 HLS 来的 fMP4 要盖住一个分片(B 站 1–3 s),3 s 起 0 次、4 s 余量最低约 1–2 s。 - app/lib/live-buffer.ts:缓冲控制统一到一处(原来 mpegts.js 的 liveSync 管 FLV、fMP4 播放器 自己一套 chase):起播跳到「末尾 − target」,快照落地那几秒里超了再对齐(慢链路上几 MB 要 几秒才到齐,每对齐一次多看 2 s、最多 15 s),稳态超 max 倍速 / 超 hard 跳 / 跳过重对齐留下的 空洞,稳态里缓冲耗尽一次报 onStall。档位:low = FLV 2 s / fMP4·TS 4 s,smooth = 5 s (目标与原 0d41140 相同,倍速阈值 9 → 7)。mpegts.js 在中转模式下关掉自己的追帧,直连一行未动。 - /live 请求带 snapshot_ms = 目标缓冲,起播就是目标深度,不再从 6–10 s 追回来。 - 低延迟档在稳态里卡一次 ≥ 1 s 或 60 s 内两次,本次播放升到流畅档重连(更深的快照起播), 角标「卡顿,已切到流畅档」;换房间或改偏好即复位。 - 弹层标题栏与监视器工具栏各一个「低延迟 / 流畅」开关,记 localStorage biliup.preview.latency, 默认低延迟;直连模式下不显示。 实测(同一模拟链路,180 s,hub 最新时间戳 → 已渲染):低延迟档三路 FLV 延迟中位 2.1–2.4 s、 fMP4 2.8–3.7 s,四路 0 次 waiting;流畅档 5.1 s。每 18 s 停 2.5 s 的链路上低延迟档卡两次后 自动升档,之后 130 s 0 次。 Refs #1713 #1716
764 lines
28 KiB
TypeScript
764 lines
28 KiB
TypeScript
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'
|
||
import { attachLiveBufferControl, type LiveBufferPolicy, RELAY_PROFILES, type StallInfo } from '@/app/lib/live-buffer'
|
||
|
||
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
|
||
/**
|
||
* 直播的取流方式:`relay`(经 biliup 中转,缓冲深度由 `buffer` 决定,见 `live-buffer.ts`)
|
||
* 或 `direct`(浏览器直连 CDN,沿用 mpegts.js 的低延迟追帧)。不传按中转处理。
|
||
*/
|
||
transport?: LiveTransport
|
||
/** 中转流的缓冲策略;不传用「流畅」档。播放中途换档不重建播放器,下一次 tick 生效 */
|
||
buffer?: LiveBufferPolicy
|
||
muted?: boolean
|
||
autoplay?: boolean
|
||
/** 直播流结束 / 被服务端断开(如客户端掉队、录制换直链)时回调,调用方决定是否重连 */
|
||
onEnded?: () => void
|
||
/** mpegts.js 或探测阶段报错(网络 / 解码),附一句可展示的说明 */
|
||
onError?: (message: string) => void
|
||
/** 中转流稳态期间缓冲耗尽一次(waiting → playing),调用方据此决定要不要换更深的档 */
|
||
onStall?: (info: StallInfo) => void
|
||
/**
|
||
* 实时弹幕:`feed` 是页面持有的共享连接(见 `DanmakuFeed`),`id` 是本路直播间;
|
||
* `feed` 为 null 时弹幕层隐藏。传了这个 prop 就装 artplayer-plugin-danmuku,开关切换只
|
||
* 订阅 / 退订与显示 / 隐藏,不重建播放器。
|
||
*/
|
||
danmaku?: { id: number; feed: DanmakuFeed | null; fontSize?: number }
|
||
}
|
||
|
||
type DanmukuPlugin = ReturnType<ReturnType<typeof artplayerPluginDanmuku>>
|
||
|
||
/** 把后端的 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 })
|
||
})
|
||
}
|
||
|
||
abort() {
|
||
this._status = mpegts.LoaderStatus.kComplete
|
||
this.controller?.abort()
|
||
this.controller = null
|
||
}
|
||
}
|
||
|
||
/** 销毁 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
|
||
}
|
||
}
|
||
|
||
/** 直播预览的取流方式:决定播放器维持多深的缓冲 */
|
||
export type LiveTransport = 'relay' | 'direct'
|
||
|
||
/**
|
||
* 直连 CDN 的流沿用 mpegts.js 自己的追帧参数:落后超过 3 s 就跳到末尾前 0.5 s
|
||
* (CDN 自带 GOP 缓存,ForgQi 实测异地直连不卡)。中转流的缓冲策略在 `live-buffer.ts`。
|
||
*/
|
||
const DIRECT_MAX_LATENCY_S = 3
|
||
const DIRECT_TARGET_REMAIN_S = 0.5
|
||
/** 早于当前播放位置这么多秒的缓冲定期清掉,长时间观看不涨内存 */
|
||
const FMP4_KEEP_BACKWARD_S = 30
|
||
|
||
interface Fmp4Options {
|
||
codecs: string | null | undefined
|
||
autoplay: boolean
|
||
buffer: () => LiveBufferPolicy
|
||
onEnded?: () => void
|
||
onError?: (message: string) => void
|
||
onStall?: (info: StallInfo) => 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` 串行),
|
||
* 播放位置的对齐与追帧交给 `attachLiveBufferControl`(与 FLV 中转流同一套),并定期 remove 旧缓冲。
|
||
*/
|
||
function playWithMediaSource(
|
||
video: HTMLVideoElement,
|
||
url: string,
|
||
art: Artplayer,
|
||
{ codecs, autoplay, buffer, onEnded, onError, onStall }: 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 detachBufferControl: (() => void) | 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 dispose = () => {
|
||
if (disposed) return
|
||
disposed = true
|
||
controller.abort()
|
||
detachBufferControl?.()
|
||
detachBufferControl = null
|
||
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 解码失败'))
|
||
// fMP4 只在中转路径上出现(直连的 HLS 走 hls.js),缓冲策略按中转流来;
|
||
// 直播分片的时间戳从一个很大的值起步,控制器的首次对齐会把播放位置跳进缓冲区,之后才真正播起来
|
||
detachBufferControl = attachLiveBufferControl(video, buffer, { onStall })
|
||
if (autoplay) {
|
||
video.play().catch(() => {
|
||
if (disposed) return
|
||
video.muted = true
|
||
video.play().catch(() => {})
|
||
})
|
||
}
|
||
|
||
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
|
||
transport: LiveTransport
|
||
buffer: () => LiveBufferPolicy
|
||
autoplay: boolean
|
||
onEnded?: () => void
|
||
onError?: (message: string) => void
|
||
onStall?: (info: StallInfo) => void
|
||
}
|
||
|
||
/**
|
||
* mpegts.js 的直播缓冲参数。
|
||
* 中转:mpegts.js 自己的追帧 / liveSync 关掉,缓冲深度由 `attachLiveBufferControl` 按档位维持
|
||
* (跟 fMP4 中转流同一套,档位能在播放中途改);直连:沿用原来的追帧参数。
|
||
*/
|
||
export function mpegtsLiveConfig(transport: LiveTransport): mpegts.Config {
|
||
const common = {
|
||
enableStashBuffer: false,
|
||
customLoader: AbortableFetchLoader,
|
||
autoCleanupSourceBuffer: true,
|
||
autoCleanupMaxBackwardDuration: 30,
|
||
autoCleanupMinBackwardDuration: 10,
|
||
}
|
||
if (transport === 'relay') {
|
||
return { ...common, liveBufferLatencyChasing: false, liveSync: false }
|
||
}
|
||
return {
|
||
...common,
|
||
liveBufferLatencyChasing: true,
|
||
liveBufferLatencyMaxLatency: DIRECT_MAX_LATENCY_S,
|
||
liveBufferLatencyMinRemain: DIRECT_TARGET_REMAIN_S,
|
||
}
|
||
}
|
||
|
||
/** Artplayer customType:用 mpegts.js 解封装 FLV / MPEG-TS 后喂给 MSE。 */
|
||
function playWithMpegts(
|
||
video: HTMLVideoElement,
|
||
url: string,
|
||
art: Artplayer,
|
||
{ type, isLive, transport, buffer, autoplay, onEnded, onError, onStall }: 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; detachBuffer?: (() => void) | null }
|
||
if (artWithMpegts.mpegts) {
|
||
destroyMpegts(artWithMpegts.mpegts)
|
||
artWithMpegts.mpegts = null
|
||
}
|
||
artWithMpegts.detachBuffer?.()
|
||
artWithMpegts.detachBuffer = null
|
||
|
||
const player = mpegts.createPlayer(
|
||
// 直连 CDN 时是跨域请求:cors 模式、不带 cookie(这些 CDN 的 ACAO 是 *,带凭据反而会被拒);
|
||
// 同源的 /live 走 same-origin 凭据,登录 cookie 照常带上(见 AbortableFetchLoader)
|
||
{ type, url, isLive, cors: true, withCredentials: false },
|
||
// 直播:不攒 IO 缓冲、源缓冲区用完即清理(长时间观看不涨内存),缓冲深度按取流方式定
|
||
isLive ? mpegtsLiveConfig(transport) : {}
|
||
)
|
||
artWithMpegts.mpegts = player
|
||
if (isLive && transport === 'relay') {
|
||
artWithMpegts.detachBuffer = attachLiveBufferControl(video, buffer, { onStall })
|
||
}
|
||
art.on('destroy', () => {
|
||
artWithMpegts.detachBuffer?.()
|
||
artWithMpegts.detachBuffer = null
|
||
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,
|
||
transport = 'relay',
|
||
buffer,
|
||
muted = false,
|
||
autoplay = isLive,
|
||
onEnded,
|
||
onError,
|
||
onStall,
|
||
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, onStall })
|
||
const bufferRef = useRef<LiveBufferPolicy>(buffer ?? RELAY_PROFILES.smooth)
|
||
useEffect(() => {
|
||
callbacksRef.current = { onEnded, onError, onStall }
|
||
bufferRef.current = buffer ?? RELAY_PROFILES.smooth
|
||
})
|
||
|
||
useEffect(() => {
|
||
if (!containerRef.current) return
|
||
const container = containerRef.current
|
||
let cancelled = false
|
||
|
||
const create = (mediaType: LiveType | null) => {
|
||
if (cancelled || !container.isConnected) return
|
||
if (playerRef.current) {
|
||
playerRef.current.destroy()
|
||
playerRef.current = null
|
||
}
|
||
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%'],
|
||
}),
|
||
]
|
||
: [],
|
||
}
|
||
const onEndedCb = () => callbacksRef.current.onEnded?.()
|
||
const onErrorCb = (message: string) => callbacksRef.current.onError?.(message)
|
||
const onStallCb = (info: StallInfo) => callbacksRef.current.onStall?.(info)
|
||
const bufferPolicy = () => bufferRef.current
|
||
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,
|
||
buffer: bufferPolicy,
|
||
onEnded: onEndedCb,
|
||
onError: onErrorCb,
|
||
onStall: onStallCb,
|
||
}
|
||
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,
|
||
transport,
|
||
buffer: bufferPolicy,
|
||
autoplay,
|
||
onEnded: onEndedCb,
|
||
onError: onErrorCb,
|
||
onStall: onStallCb,
|
||
}
|
||
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, type, codecs, isLive, transport, 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 }} />
|
||
}
|
||
|
||
export default Players
|