Files
Noah 48dc8fd65b fix(preview): 中转模式在有 RTT 的链路上频繁缓冲——旁路挪到解析点、掉队原地续播、缓冲分低延迟 2 s / 流畅 5 s 两档 (#1716)
* 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
2026-09-23 06:20:47 +00:00

764 lines
28 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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