@fancaf/live-sdk
v0.7.0
Published
H5 live-streaming player SDK (HLS + fMP4 + hls.js, headless kernel + plugin architecture)
Maintainers
Readme
live-sdk
Headless H5 直播播放器 SDK —— 状态 / 命令 / 事件三契约,UI 完全外置。
Headless H5 live-streaming player SDK — state / command / event contracts, with UI fully externalized.
简体中文
live-sdk 是一个面向 H5 直播场景 的播放器 SDK。内核无 UI、无框架绑定,只暴露三契约;UI、上报、宿主环境适配全部经由插件/适配器扩展。封装复杂度、保留可定制性——开箱即用,也能逐层下沉到完全自绘。
基于 HLS(fMP4/CMAF 容器)+ hls.js 落地:LL-HLS 低延迟、ABR 与手动清晰度、断流重连、网络质量自适应,并内建 端到端能力对齐(客户端能力 × 服务端实际提供)。
特性
- Headless 内核:状态 / 命令 / 事件三契约,UI 是外部消费者,可任意接管。
- 可插拔内核:
HlsKernel(hls.js + MSE / iOS MMS)与NativeKernel(Safari 原生 HLS 回退)自动选路。 - LL-HLS 低延迟:fMP4 分片(
EXT-X-PART)+ 运行时目标延迟调节。 - 清晰度 / ABR:自动 ABR 与手动切档;业务档位与 master m3u8 以
height主键对齐,索引复杂度封装在 SDK 内。 - 端到端能力对齐:
getFeatureStatus()回报lowLatency/abr/qualitySwitch/drm/airplay的客户端与服务端匹配差距。 - 网络自适应:目标延迟、重试次数、退避基数、超时均为「静态值或按网络质量动态求值」。
- 可观测分级:
full(深度采集)/basic(仅<video>标准事件),静态配置。 - 插件化扩展:
BasePlugin生命周期、UIPlugin界面层、ReporterPlugin上报、EnvAdapter宿主适配。 - 框架适配器:React / Vue
usePlayer,零侵入消费三契约。 - 无构建可用:提供 UMD 产物,CDN 直接
<script>引入即可。
安装
npm install @fancaf/live-sdk
# 或
pnpm add @fancaf/live-sdkhls.js 为运行时依赖,随包自动安装。React / Vue 适配器为可选 peer 依赖,按需安装:
npm install react # 仅当使用 @fancaf/live-sdk/react
npm install vue # 仅当使用 @fancaf/live-sdk/vue快速开始
开箱即用(默认 UI)
import { createPlayer } from '@fancaf/live-sdk'
import { mountDefaultUI } from '@fancaf/live-sdk/ui'
const player = createPlayer({
container: '#player',
url: 'https://example.com/live.m3u8',
autoplay: true,
muted: true,
})
mountDefaultUI(player) // 播放/暂停、静音、音量、清晰度、全屏服务端下发地址(PlayConfig Provider)
player.play(async () => {
const res = await fetch('/api/live/play-config')
return res.json() // { url, backup, liveStatus, autoplay, muted, poster, quality }
})React
import { useEffect, useState } from 'react'
import { createPlayer } from '@fancaf/live-sdk'
import { usePlayer } from '@fancaf/live-sdk/react'
function Player({ src }) {
const [player, setPlayer] = useState(null)
useEffect(() => {
const p = createPlayer({ container: '#stage', url: src, autoplay: true, muted: true })
setPlayer(p)
return () => p.destroy()
}, [src])
return player ? <Controls player={player} /> : null
}
function Controls({ player }) {
const { playing, muted } = usePlayer(player)
return (
<button onClick={() => (playing ? player.pause() : player.play())}>
{playing ? '暂停' : '播放'}
</button>
)
}Vue
<script setup>
import { ref, onMounted } from 'vue'
import { createPlayer } from '@fancaf/live-sdk'
import { usePlayer } from '@fancaf/live-sdk/vue'
const player = ref(null)
const stage = ref(null)
onMounted(() => {
player.value = createPlayer({ container: stage.value, url: 'live.m3u8', autoplay: true, muted: true })
})
const state = usePlayer(player) // Ref<PlayerState | null>,就绪前为 null
</script>
<template>
<div ref="stage" />
<button v-if="state" @click="state.playing ? player.pause() : player.play()">
{{ state.playing ? '暂停' : '播放' }}
</button>
</template>无构建(CDN)
<script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
<script src="https://unpkg.com/@fancaf/live-sdk/dist/live-sdk.umd.js"></script>
<script src="https://unpkg.com/@fancaf/live-sdk/dist/live-sdk-ui.umd.js"></script>
<script>
const { createPlayer } = LiveSdk
const { mountDefaultUI } = LiveSdkUI
const player = createPlayer({ container: '#player', url: 'live.m3u8', autoplay: true, muted: true })
mountDefaultUI(player)
</script>核心概念
三契约
| 契约 | API | 说明 |
|---|---|---|
| 状态 | getState() / subscribe(cb) | 低频字段快照;仅在变更时回调 |
| 命令 | play pause mute setVolume switchQuality switchURL requestFullscreen exitFullscreen seek setPlaybackRate setPoster setLiveLatency | 意图式操作 |
| 事件 | on(name, cb) / once | first_frame manifest_parsed features_updated error live_status live_status_error 等 |
| 查询 | getStats() / bufferInfo() / speedInfo() / getSessionReport() | 瞬时指标 vs 会话累计(见下) |
状态(PlayerState)
interface PlayerState {
playing: boolean
volume: number
muted: boolean
qualities: Quality[] // 已映射到 streams 的有效档位(含业务原始 label)
currentQuality: number | null // 当前档位(Quality.id)
sessionState: SessionState // 会话真相:idle/loading/ready/playing/paused/stalled/error/ended
usingBackup: boolean // 当前是否在播 PlayConfig.backup 备用流
currentTime: number // 播放位置;按「整秒变化」节流更新
duration: number // 总时长;直播为 Infinity
fullscreen: boolean // 覆盖三种来源:标准全屏 / 容器级全屏(含 video 的祖先)/ iOS 原生视频全屏
playbackRate: number // 当前倍速(经浏览器钳制后读回的真实值)
capabilities: KernelCapabilities
[ext: `app.${string}`]: unknown
}
playing与sessionState的分工(卡顿时二者刻意分叉,判错会误报):playing回答「用户看到的是播放还是暂停」—— 转圈、降级提示、埋点分类应判sessionState; 播放/暂停按钮形态应判playing。 卡顿(stalled)期间playing仍为true(已起播、只是缓冲),而sessionState为'stalled'。
usingBackup的复位点有三处:play()新一轮起播、switchURL()显式换源 →false; 断流重连切到backup(第 1 次重试)→true。
状态快照只放对直播主场景有意义的低频字段。播放进度、缓冲区间等高频/可观测数据走
getStats()/bufferInfo()或player.media原生引用,不进快照。
能力对齐(getFeatureStatus)
player.on('features_updated', (report) => {
// report.features: [{ feature, client, server, matched, detail }]
// report.summary: { matched, mismatched }
})client 取「SDK + 平台」最弱一侧,server 来自 manifest 探测(EXT-X-PART / 多 EXT-X-STREAM-INF / EXT-X-KEY)。
API
createPlayer(config): Player
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| container | string \| HTMLElement | 必填 | 挂载容器 |
| url | string | — | 缺省播放地址(动态下发走 play(provider)) |
| kernel | KernelConstructor | 自动选路 | 自定义内核 |
| hlsConfig | object | — | 透传 hls.js 原生配置 |
| preset | string \| PluginConstructor[] | 'live' | 插件组合 |
| autoplay | boolean | false | 构造后自动发起一次 play()(需同时给 url) |
| muted | boolean | false | 静音 |
| ignores | string[] | — | 关闭 Preset 内指定功能插件 |
| network | Partial<NetworkConfig> | 内置 | 网络敏感策略参数 |
| observability | 'full' \| 'basic' | 'full' | 观测档位 |
| locale | 'en' \| 'zh' | 'en' | 运行期消息语言(错误 / 日志 / 上报 / 内置 UI 控件文案);全局语义 |
| env | EnvAdapter | WebEnvAdapter | 宿主环境适配 |
| posterMode | 'native' \| 'overlay' | 'native' | 封面图呈现方式;MSE 路径建议 'overlay' |
接入排查:容器零尺寸告警。「接入后画面不显示」最常见的原因是容器没有高度——
height: 100%的元素在父级无确定高度时实际为 0,此时 SDK 一切正常(play()成功、无 error), 但一个像素都看不见。play()时会检查并在宽或高为 0 时告警一次:容器尺寸为 0(0×0),播放器不会有可见画面。请给容器或其父级确定的高度,例如 style="width:100%;height:300px"。只在
play()时检查(不在构造时):构造时容器合法为 0 的场景很常见(未激活的 tab、路由过渡、懒挂载), 那时告警误报率过高。环境测不到尺寸时(非浏览器)不会误报。
命令
| 方法 | 返回 | 说明 |
|---|---|---|
| play(input?) | Promise<void> | 起播 / 恢复:无参且已起播过 = 恢复播放(不重拉流);带参(URL / PlayConfig / provider)= 起播或重新起播 |
| pause() | void | 暂停 |
| mute(m) / setVolume(v) | void | 静音 / 音量 |
| switchQuality(id) | Promise<void> | 切清晰度(Quality.id;-1 恢复自动 ABR)。返回 Promise 是因为内部会 await 钩子;语句式调用无需 await |
| switchURL(url) | Promise<void> | 运行中切流(保留会话状态) |
| requestFullscreen(target?) / exitFullscreen() | void | 进入 / 退出全屏(iOS 原生视频全屏亦可退出)。传 target 可指定全屏元素:缺省全屏 <video>;传容器(如 player.root)做容器级全屏,自绘控件在全屏内仍可见可点 |
| seek(time) | void | 定位(秒),自动钳制到 [0, duration]。直播下为 noop(判据由内核 isLive 给出,不从 duration 反推);点播/重播正常生效 |
| setPlaybackRate(rate) | void | 设置倍速,写后读回。直播主场景不建议(变速会持续累积/消耗延迟);点播/重播为正常用法 |
| setPoster(poster?) | void | 运行时更换封面(空值 = 移除);呈现方式仍由 posterMode 决定 |
| setLiveLatency(target?, max?) | void | 运行时覆盖 LL-HLS 目标延迟;传空 = 清除覆盖、恢复 network 配置的动态策略 |
play() 与 switchURL() 语义一致:resolve = 已起播,reject = SDK 尽力后失败(fatal)。可恢复错误走内部自动重连,不会 reject。
play()/pause()会同步更新getState().playing(不等待浏览器异步派发的play/pause媒体事件),因此 UI 可在命令返回后立即读取快照渲染按钮,不会出现「画面已暂停、按钮仍是播放中」的错位。若需最精确的切换判据,可读player.media.paused。
playing表达的是「呈现给用户的播放/暂停语义」而非瞬时会话态:断流重连(error → retry → loading)期间按钮保持「播放中」图标,不会被退避等待闪回成「播放」;反过来,用户在重连期间按下的暂停也会被尊重,重试成功后不会自动复活为播放。
拦截内置逻辑(Hooks)
useHooks(name, fn) 可以在内置命令执行前后插入自己的逻辑,最典型的用法是拦截:
// 未登录时不允许切清晰度(也可以在这里补一次鉴权后再放行)
player.useHooks('switchQuality', async (ctx) => {
if (ctx.phase === 'before' && !isLoggedIn()) ctx.cancelled = true
if (ctx.phase === 'after') report('quality', { id: ctx.id, applied: ctx.applied })
})三个命令已接线:'play' / 'switchQuality' / 'switchURL'(其余命令目前没有钩子)。约定如下:
| 字段 | 说明 |
|---|---|
| ctx.phase | 同名钩子会被调用两次:'before'(内置逻辑前)与 'after'(内置逻辑后) |
| ctx.cancelled | 仅 'before' 可写。置 true 则跳过内置逻辑,命令直接返回 |
| ctx.applied | 仅 'after' 可得。内置逻辑是否真的生效;false = 判定为 no-op(如内核不支持切档、id 不在档位表里、play() 走恢复分支) |
| 入参 | 'play' → ctx.input;'switchQuality' → ctx.id;'switchURL' → ctx.url |
fn 可以是 async —— 'before' 阶段会被 await,因此「先查权限、再放行」这类异步前置判断是可靠的(代价是命令返回时机随之推迟)。useHooks 返回解绑函数。
为什么用「写回
ctx」而不是「返回布尔值」:HookFn的返回类型是void | Promise<void>(钩子不承担返回值契约),所以拦截决策统一走可变上下文。这也让同一个处理器能靠ctx.phase同时承担前后两个阶段。
查询类方法
| 方法 | 说明 |
|---|---|
| getStats() / bufferInfo() / speedInfo() | 瞬时指标 / 缓冲区间 / 下载速率 |
| getSessionReport() | 会话累计指标:首帧耗时 / 卡顿次数与时长 / 实际播放时长 / 起播时刻 |
| getFeatureStatus() | 端到端能力对齐报告(见上) |
| getLastRetryDiagnostic() | 最近一次重试诊断快照;无重试时为 null |
为什么累计指标不并入 getStats():StatsInfo 的字段语义是「此刻这一瞬间的播放质量」(码率 / fps / 丢帧),累计量混进去后,「尚未起播」与「值就是 0」在类型上无法区分。因此分三层各归其位:瞬时(getStats)/ 累计(getSessionReport)/ 低频语义(PlayerState)。
player.getSessionReport()
// {
// firstFrameCost: 812, // 首帧耗时 ms;尚未出首帧为 null
// stallCount: 2, // 本轮会话卡顿次数
// stallDuration: 3400, // 累计卡顿时长 ms(进行中的卡顿实时计入)
// watchTime: 61200, // 累计「实际播放」时长 ms —— 加载/暂停/卡顿都不计入
// loadStartTime: 1732000000000, // 本轮起播时刻;从未起播为 null
// }会话边界:由
play(PlayConfig)的新一轮起播重置(全部清零)。switchURL()不重置(同一次观看行为换源);play()(无参、恢复播放)也不重置(那是续播,不是新会话)。 若需要「会话挂钟时长」(含暂停与卡顿),请用Date.now() - loadStartTime自行计算,不要复用watchTime承载两种解读。
重试诊断(排查用)
可恢复错误由 SDK 自动重连。每次错误 / 重连时,SDK 会采集一份「当前播放地址 + 当前网络环境」快照,随 error 事件、retry 事件与上报记录三处一起给出,便于线上定位「哪条流、在什么网络下、重试到第几次失败」:
player.on('error', (e) => {
if (e.fatal) showErrorUI(e)
console.warn(e.code, e.diagnostic)
// {
// url, primaryUrl, isBackup, // 本次请求地址 / 主地址 / 是否已切备用流
// networkQuality, online, visibility, // good|fair|poor|offline / 在线 / 前后台
// effectiveType, downlink, rtt, // Network Information API(不支持则为 undefined)
// bufferBehind, bufferRemaining, currentTime,
// retryCount, delay, errorCode, time,
// }
})同样的快照也会随 retry 事件载荷(e.diagnostic)与上报插件收到的 ReportRecord.data.diagnostic 一起下发——接自定义 ReporterPlugin 即可直接转发到埋点/日志系统。
两条通道信息对等:事件通道给
err.code/err.domain/err.fatal/err.diagnostic,上报通道给record.code/record.data.domain/record.level/record.data.diagnostic—— 走哪条都能拿到同样的信息。 唯一要紧的是别同时开两条(同一错误会各上报一次,见下方「不要注册 ReporterPlugin 收错误」的说明)。
错误域(err.domain):按「该去哪儿排查」分流
错误码是枚举(13 个,还会随版本增加),而看板只关心粗粒度的归因方向。PlayerError.domain
直接给出,也可对任意 code 调 errorDomainOf(code):
| 域 | 归因方向(该去哪儿排查) |
|---|---|
| network | 服务端 / CDN / 链路 —— manifest_load_error manifest_404 frag_load_error network_error load_timeout retry_exhausted |
| decode | 内容 / 转码 / 内核 —— media_decode_error media_src_not_supported drm_no_license |
| config | 接入侧配置 / 平台能力 —— config_resolve_failed no_supported_kernel play_failed |
| unknown | 未归类 —— 映射表未覆盖,如实暴露而不是猜 |
player.on('error', (e) => {
if (e.domain === 'network') reportCdnIssue(e)
else if (e.domain === 'decode') reportTranscodeIssue(e)
else if (e.domain === 'config') reportIntegrationIssue(e)
})有了这一层,接入方不必自建「错误码 → 方向」映射表——那张表会随 SDK 新增错误码而失同步。
unknown刻意独立成一档:别把它并进decode("播放器自己的问题")。那样会让真实的未知故障被伪装成解码问题,排查方向跑偏。SDK 侧有一道契约测试遍历全量
ERROR_CODE,未登记域即失败——所以你不会收到「本该有域却是 unknown」的错误码。
消息编号与语言:[LV-xxxx] + locale
运行期消息(含内置 UI 控件文案)= 稳定编号 + 按语言取的文案。默认英文:
[live-sdk] [LV-3006] reconnect attempt #1 (network_error) → https://cdn/a.m3u8
[live-sdk] [LV-1004] autoplay blocked, waiting for a user gesture
[LV-2001] media failed to load传 locale: 'zh' 切中文 —— 同一条编号,文案换语言,界面上的 tooltip 也一起变:
[live-sdk] [LV-3006] 重连第 1 次 (network_error) → https://cdn/a.m3u8
[live-sdk] [LV-2001] 媒体加载失败
全屏按钮 tooltip →「全屏」(UI 文案不带编号,见下)| 说明 | 内容 |
|---|---|
| 编号是稳定标识 | [LV-xxxx] 形如 LV-<分区><序号>(分区见 MSG 常量)。文案会变、编号不变 —— 用户报障可直接引用编号,告警规则也能锚编号,不必匹配易变文案 |
| ⚠️ 不要按 message 文本做分支或匹配 | 请用 err.code / err.domain(机器可读部分恒为英文标识),或锚 MSG 编号 |
| locale 默认 'en' | SDK 发布在公开 npm、README 为中英双语,日志与错误首先面向更广的读者;中文使用方一行配置切换 |
| locale 是全局语义(同 setLogLevel) | 由 createPlayer 时写入,同一页面多实例共用最后一次设置(UI 控件也在这个语义内)。需要按实例区分语言时,消费与语言无关的 code / domain / MSG 编号 |
| 覆盖面 | PlayerError.message、logger.* 与 console.* 的输出、上报记录的 message、getFeatureStatus().detail、live_status_error.error、平台提供的 zeroSizeHint,以及内置 UI 控件的 title / aria-label / 清晰度下拉项(分区 LV-7xxx) |
| UI 文案继承同一个 locale,但不带编号 | 编号是给开发引用、给告警锚定的,出现在 tooltip 里只是噪声(读屏还会把编号逐字念出来)。所以 UI 走 uiText()(纯文案)、日志走 t()(带编号)—— 同一张文案表、同一个 locale,只差一个编号前缀 |
| 运行中切语言会立即重绘已挂载的 UI | setLocale() 会通知订阅者,控件用最近状态快照重绘;否则会出现「语言变了、tooltip 没变」——player.subscribe 只在状态变化时触发,而切语言不改状态 |
| 自绘 UI 不受影响 | 内置控件的文案不构成契约,自绘 UI 自己决定文案与语言策略 |
const player = createPlayer({ container: '#player', locale: 'zh' }) // 中文日志、错误与内置控件 tooltip
player.on('error', (e) => {
// 锚编号:换语言、换版本都不会断(锚文案会)
if (e.message.includes('[LV-3004]')) showRetryExhausted()
})事件
Events 枚举成员(值即对应 snake_case 字符串,player.on(Events.FIRST_FRAME, cb) 与 player.on('first_frame', cb) 等价):
LOAD_START MANIFEST_PARSED FIRST_FRAME PLAY PAUSE PLAYING STALLED RECOVERED
RETRY ERROR ENDED QUALITY_CHANGE ABR_CHANGE BUFFER_UPDATE SPEED_UPDATE
VISIBILITY_CHANGE FEATURES_UPDATED KERNEL_EVENT COMMAND⚠️ 直播中的
ended不等于「直播结束」(0.7.0 起修正):MSE 路径下内核把duration写成 playlist edge,流停止更新时播放点会追到它、浏览器随即派发原生ended。此时 SDK 按断流恢复 处理(派发retry+ 重连),不派发ENDED;只有内核已确认直播结束(playlist 出现#EXT-X-ENDLIST)之后到来的ended才会照常派发。业务若要展示「直播已结束」, 应依据live_status(业务接口)或kernel_event的live_changed,不要用ENDED。
插件还会派发两个独立事件名(刻意不进 Events 枚举 —— 它们属于 preset: 'live' 的可选旁路能力,不是播放内核契约的一部分;不接对应配置的接入方永远收不到):
'live_status' // LivePolling:直播状态发生变化
LIVE_STATUS_ERROR_EVENT // 'live_status_error':LivePolling 轮询失败COMMAND:统一命令观测(12 个命令全覆盖)
语义事件(play / quality_change / …)是为驱动 UI 设计的,各自载荷不同;而「用户点了什么、有没有生效」
是观测问题。COMMAND 把这件事归一 —— 一次订阅覆盖全部 12 个命令,不必逐命令订阅再兜底。
player.on('command', ({ name, phase, applied, time }) => {
// phase: 'before' | 'after'(每个命令成对派发);applied 仅 after 有值
if (phase === 'after' && applied === false) console.warn(`${name} 未生效(no-op)`)
})applied === false 的典型场景(都是「命令返回了但没起作用」,不是错误):
| 命令 | no-op 条件 |
|---|---|
| seek | 直播中不生效(内核 isLive === true);实例已销毁 |
| switchQuality | 内核无 qualitySwitch 能力;id 不在档位表;被 before 钩子拦截 |
| switchURL | 内核未初始化;切流失败;被 before 钩子拦截 |
| setLiveLatency | 内核未实现 setLiveLatency(如 NativeKernel) |
| play | 实例已销毁;被 before 钩子拦截;被更新的 play() 取代;起播/恢复失败 |
play({ autoplay: false })的applied为true—— 它完成了被要求的事(加载但不自动播),不是空转。⚠️
setVolume在滑块拖动时可能高频派发(range 的input事件连续触发),统计交互时请自行节流。命令名可在运行时枚举:
COMMAND_NAMES(12 个,与PlayerCommands的键一一对应)。
PLAY 与 PLAYING 的区别
两者都表示「开始播了」,但处在不同的时间点(与 <video> 原生事件语义一致):
| 事件 | 含义 | 触发时机 |
|---|---|---|
| PLAY | 播放请求已被内核接受 | paused 由 true 转 false 时(自动播放成功、用户点播、重连后恢复各一次) |
| PLAYING | 媒体真正开始输出 | 首帧可渲染时;每次起播(含换源)都会派发 |
PLAY 与 PAUSE 严格成对;PLAYING 则与 STALLED / RECOVERED 一起描述播放质量。
BUFFER_UPDATE:缓冲水位档位变化
缓冲水位只随 timeupdate / progress 变化(~4Hz)。若每次都派发,等于给所有订阅方塞一条 4Hz 高频流;若完全不派发,接入方就只能自开 setInterval 轮询 bufferInfo()。折中做法是只在档位跨越边界时派发:
player.on('buffer_update', (b) => {
// { level, remaining, length, totalRemaining, totalLength, behind, buffers }
if (b.level <= 1) showLowBufferHint() // 剩余可播 < 3s
})level 由「当前播放块的剩余可播时长」在 BUFFER_LEVEL_THRESHOLDS = [1, 3, 5, 10, 20](秒)上分档得出,取值 0(最紧张)~ 5(最充裕)。两个要点:
- 按当前块(
remaining)而非全量并集(totalRemaining)分档 —— 判定「还能不能连续播下去」只取决于当前块;孤岛场景下 buffer 里囤着 30s 但当前块只剩 0.5s 时,真正会发生的是卡顿。 - 载荷带全量
BufferInfo,所以按并集口径或延迟(behind)口径判定的接入方也能只订阅这一个事件;需要自定义阈值时直接读原始秒数即可,BUFFER_LEVEL_THRESHOLDS与bufferLevelOf()均已导出。
查询式接口 bufferInfo() 仍然保留,用于「事件之外的按需读取」。
直播状态轮询与失败处理(LivePolling)
preset: 'live' 内置 LivePolling:给了 PlayConfig.liveStatus 就按固定间隔轮询,状态发生变化时派发结构化 live_status(只在变化时派发,避免订阅方反复重渲染)。
失败语义:轮询是旁路能力,任何失败都不得影响播放、不得触发重连;但「不打扰」不等于「悄悄死掉」——
早期实现是空 catch {},接入方无法区分下面两种截然不同的处境:
| 处境 | 业务侧观感 | |---|---| | 轮询正常,服务端状态确实没变(预期行为) | 「状态一直没变」 | | 轮询已持续失败、实际上已经死了(故障) | 「状态一直没变」 |
二者观感完全相同,后者会让人相信一个错误的事实。因此现在:
import { LIVE_STATUS_ERROR_EVENT } from '@fancaf/live-sdk'
player.on('live_status', (s) => { /* { status, previousStatus, raw, time } */ })
player.on(LIVE_STATUS_ERROR_EVENT, (e) => {
// { url, failCount, error, time }
// failCount:连续失败次数(成功即归零)—— 可据此判断是「偶发抖动」还是「持续故障」
})- 每次失败都有日志(
logger.warn,不节流 —— 日志本就是给人排查用的); - 失败还经
live_status_error事件外抛,并按failCount节流:第 1 次立刻上报(故障要马上可见), 之后取 3、10 及每满 30 次(30/60/90…),兼顾「持续故障仍有心跳信号」与「长时间断网不刷屏」; 判据是静态纯函数LivePolling.shouldReportFailure(n),可单测锚定; - 独立事件而非并入
Events.ERROR:状态接口 500 不该被记成「直播播放失败」,否则接入方的err.fatal兜底分支、错误率统计、Sentry 捕获会被污染; - 失败按指数退避:间隔 =
min(interval × 2^failCount, maxInterval)(maxInterval默认 5 分钟, 可在start()前改写),成功一次立即复位为基础间隔 —— 既不放弃,也不按原间隔无脑撞墙; res.ok校验:fetch对 4xx/5xx 不 reject,不校验res.ok时「500 + JSON 错误体」既不进catch、也取不到状态字段,会变成连痕迹都没有的静默失败;- 状态值显式归一:
{"status": 0}(数字)会被String()归一,否则与字符串lastStatus恒不相等, 去重整体失效、每轮都误判为「状态变化」; stop()会清掉待执行句柄并使在途轮次作废:后台暂停 / 重新start()后不会留下引用旧地址的野定时器。
| 成员 | 说明 |
|---|---|
| start(url, interval?) / stop() | 起停轮询(Player#pollLiveStatus(interval?) 亦可达) |
| shouldReportFailure(failCount) | 静态节流判据 |
| maxInterval | 退避间隔上限(ms),默认 300000 |
扩展点
| 扩展 | 用途 |
|---|---|
| BasePlugin | 定制行为插件(生命周期 create/init/ready/destroy) |
| UIPlugin | 界面层插件,挂进 player.root |
| ReporterPlugin | 自定义上报(report(record)) |
| EnvAdapter | 宿主环境适配(可见性 / 网络质量,如 App JSBridge) |
| KernelConstructor | 自定义媒体内核 |
class MyReporter extends BasePlugin {
report(record) {
navigator.sendBeacon('/log', JSON.stringify(record))
}
}
createPlayer({ container: '#player', preset: [MyReporter] })对接 Sentry(参考实现)
SDK 不内置 Sentry 适配器。 Sentry 属第三方系统能力,不属播放器核心职责(见能力边界·类型四);
SDK 只给上报通道契约,「往哪发」由接入方按自己的埋点体系实现。可直接复制的样板见
examples/reporter-sentry.ts:
import { BasePlugin } from 'live-sdk'
const SENTRY_LEVEL = { fatal: 'fatal', warn: 'warning', info: 'info' } // 坑 ②
export class SentryReporter extends BasePlugin {
static readonly pluginName = 'sentryReporter'
private sentry
init(config) { this.sentry = config?.sentry }
report(record) {
if (!this.sentry) return // 未注入 = 降级为无操作
const level = SENTRY_LEVEL[record.level] ?? 'info'
if (record.type === 'error') {
this.sentry.captureException(new Error(record.code), {
level,
extra: { code: record.code, ...record.data }, // 坑 ①:不能平铺
})
return
}
// 非 error(重连 event / 业务手动 report)→ 面包屑,形成「错误前的上下文轨迹」
this.sentry.addBreadcrumb?.({ category: record.type, message: record.code, level, data: record.data })
}
}
player.registerPlugin(SentryReporter, { sentry: Sentry })两个坑(样板里都标了,务必照抄):
| # | 坑 | 不照做的后果 |
|---|---|---|
| ① | record.data 必须包在 extra 下 | Sentry 合并 CaptureContext 用的是显式字段白名单(tags/extra/contexts/user/level/…),没有透传机制,顶层未知键被静默丢弃 → message / domain / diagnostic 全丢,而客户端不会报错,只有亲自去看 Sentry 才会发现 |
| ② | level 要映射:'warn' → 'warning' | Sentry 的合法等级是 'fatal' \| 'error' \| 'warning' \| 'log' \| 'info' \| 'debug',没有 'warn';直接透传会让等级落在无效值上、告警规则失效 |
examples/不是摆设:该文件由examples/tsconfig.json编译、由test/reporter-sentry-example.test.ts断言行为(extra里有 diagnostic、除level/extra外无多余顶层键、三档 level 映射、breadcrumb 分流), 且这两项检查都在npm run verify里 —— 示例写错会失败,避免「文档里的代码悄悄腐烂」。
包结构
| 入口 | 内容 |
|---|---|
| @fancaf/live-sdk | 内核 + 三契约 + 插件基类 + 官方 Reporter |
| @fancaf/live-sdk/ui | 默认 UI 包(mountDefaultUI) |
| @fancaf/live-sdk/react | React usePlayer |
| @fancaf/live-sdk/vue | Vue usePlayer |
产物:ESM(.es.js)用于现代构建与 SSR;UMD(.umd.js)用于 CDN 无构建。本包不提供 CJS(直播播放器无 Node 运行时场景)。
兼容性
| 平台 | 内核 | 说明 |
|---|---|---|
| Android / 桌面 Chrome / Edge | HlsKernel(hls.js + MSE) | 全能力 |
| iOS Safari 17.1+ / macOS Safari 17.1+ | HlsKernel(MMS) | 全能力 |
| iOS Safari < 17.1 | NativeKernel | 仅降级播放,深度观测自动落 basic |
| iOS 原生 / 无 MSE 环境 | NativeKernel | 同上 |
前置依赖:
observability: 'full'要求平台支持 MSE(iOS/macOS Safari 17.1+ 的 MMS,或标准 MSE)。低于此版本为静态已知边界,直接走NativeKernel,非运行时探测降级。
能力边界(Non-Goals)
以下能力是技术选型下的主动边界收缩,不是「尚未实现」。接入前请先对照本表——若你的业务强依赖其中任意一类,live-sdk 不是合适的选择,建议改用 xgplayer / mpegts.js 等全场景播放器。
按根因分为四类,每类共享同一个设计决策:
类型一:不做 VOD(时间轴可控的点播场景)
根源:live-sdk 是直播内核——直播的时间轴受 live edge 约束、不可随意摆布,因此「面向时间轴的相对操作」在直播态下不具备语义。
seek/setPlaybackRate已进入命令集(服务点播 / 重播回放),但在直播中分别表现为 noop 与不建议使用 —— 命令的存在不代表语义边界消失。
| 边界项 | 说明 | 现状 |
|---|---|---|
| 渐进式点播文件(普通 .mp4 直连播放) | 选型 hls.js 单引擎,不做 range 请求/分片加载;「完整 MP4 文件」与选定 fMP4 流式容器是两回事 | 仍不支持:挂 VOD 内核 / DashKernel,或改用 mpegts.js |
| 倍速播放(setPlaybackRate) | 直播是无限线性流,变速只会破坏「边缘跟随 / 低延迟」语义——调慢持续累积延迟,调快在缓冲耗尽时反复等待 | 命令已提供;直播主场景不建议使用,点播 / 重播回放为正常用法 |
| 定位 / 跳转(seek) | 直播无「跳到某处」语义 | 命令已提供,但直播中为 noop(判据由内核 isLive 给出——不要用 duration 反推:MSE 路径下 hls.js 会把直播流的 duration 写成有限的 playlist edge);点播 / 重播正常生效 |
注意:HLS 点播流(
#EXT-X-ENDLIST)是支持的(走同一内核);「不做 VOD」特指上表这些面向时间轴的操作与渐进式.mp4文件。详见技术规格 §1.3。
类型二:只做 HLS 单协议
根源:以「单协议做深」换架构简洁——内核层预留扩展点,但不实现其他协议。多协议意味着自带 demux/remux 栈,是数量级的维护成本。
| 不支持 | 说明 | 如需支持 |
|---|---|---|
| FLV / DASH 协议 | 仅落地 HLS | 实现对应 Kernel 并注入 kernel 配置 |
| WebSocket-MP4(ws://) | 非 HTTP 渐进式传输,不在 HLS 范畴 | 同上,需自实现内核 |
| mkv 容器的音轨 / 字幕抽取 | 无多容器 demux 实现 | 需自研 demux |
类型三:能力上限 = 浏览器上限(不做编解码栈)
根源:不维护自研解码/编码补齐栈。这层成本被外包给浏览器,换来体积与维护面的数量级缩减——代价是「浏览器不支持的就是不支持」。
| 不支持 | 说明 | 如需支持 | |---|---|---| | H.265 / AV1 软件解码回退 | 无自研解码栈 | 自带 wasm 解码器,或换自研内核 | | G.711 / 非标音频编码补齐 | 同上,交由浏览器 | 同上 |
类型四:非播放器核心职责(属业务态或平台能力)
根源:SDK 只做「把直播播出来」这一件事,不替业务拍板,也不重复造系统能力。
| 不支持 | 说明 | 如需支持 |
|---|---|---|
| DRM(FairPlay / Widevine / PlayReady) | 属跨端 + 安全能力;getFeatureStatus().drm 恒 absent | 接入 EME / 第三方 DRM SDK |
| 弹幕 / 礼物 / 外链等互动 | 业务态能力,SDK 不替业务拍板 | 作为第三方插件接入 |
| 内建字幕 / 投屏 / 画中画控件 | UI 外置;投屏在原生回退路径由系统接管(见 airplay) | 自绘 UI / 用系统原生能力 |
选型速查
| 你的核心诉求 | 判断 | |---|---| | H5 直播 + 干净可控的内核 + 自绘 UI / 跨端复用 | ✅ 适合 live-sdk | | 只要 HLS 直播,不需要点播/弹幕/投屏全家桶 | ✅ 适合 live-sdk | | 直播 + 点播/回放/短视频 一体 | ❌ 选 xgplayer(或另挂 VOD 内核) | | 需要 FLV/DASH/mkv,或客户端软解兜底 | ❌ 选 xgplayer | | 需要 DRM 版权保护 | ❌ 选带 DRM 的方案 | | 需要内建弹幕、字幕、投屏等开箱控件 | ❌ 选 xgplayer |
与 xgplayer 的逐项差异,见 对比分析。
文档
- 用户故事(验收用例) —— 54 条用例,格式:目标 / 配置 / 交互 / 预期
- 与 xgplayer 的对比分析 —— 选型边界与逐项差异
开发
npm install
npm run clean # 清空 dist(build 已内置,无需手动执行)
npm run build # 构建 core / ui / react / vue + 生成 d.ts
npm run verify # 类型契约校验 + 导出符号校验 + 运行时冒烟
npm run typecheck # 仅类型检查
build会先清空dist/—— 4 个 vite 配置串行写入同一个dist(故都是emptyOutDir: false),tsc --emitDeclarationOnly也不清理输出目录,因此历史上没有任何一步会删旧文件。 后果是被删除/改名的源文件会在dist留下孤儿,而npm pack打的正是磁盘上的dist—— 孤儿会随包发布(0.6.0 发布前实测:dist/reporter/SentryReporter.d.ts仍在,而该类已从源码删除)。 现在由npm run clean修根因、verify/artifacts.mjs做第二层校验。
测试
三层分工(详见技术规格 §8.3):单测跑 src 逻辑,冒烟与 E2E 跑 dist 产物。
npm test # Vitest 单测(状态机 / buffer 口径 / 退避策略)
npm run test:watch # 单测 watch
npm run test:coverage # 单测 + 覆盖率
npm run e2e:install # 首次拉取 chromium + webkit 内核
npm run e2e # Playwright E2E(chromium + webkit 两个 project)
npm run verify:all # 全链路:单测 → 构建 → 冒烟 → E2E国内网络拉浏览器内核慢时,可加镜像:
PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright npm run e2e:install
E2E 覆盖「内核 → Player → UI」的跨层联动(断流重连与去重、近尾 ended 判定、Pointer Events、销毁清理),通过注入 MockKernel 驱动异常分支,不依赖真实直播流,因此不依赖网络。选 Playwright 的关键理由之一是它自带 WebKit——这是唯一能覆盖 Safari 原生 HLS 回退路径(NativeKernel)的引擎。
已知 flaky(已查明成因,非断言问题):本地全量跑(6 worker、
retries: 0)偶发 1 例失败, 报错固定为browserContext.close: EPERM: operation not permitted ... traces/*.network—— 即 Playwright 写 trace 产物时被系统拒绝(并发写同一test-results/.playwright-artifacts-*)。 每次失败的用例都不同、且隔离运行全部通过(实测 5 次全量跑:失败 1~3 例不等、用例各异, 而所有失败详情无一例外都是browserContext.close: EPERM)。 清test-results/可降低概率但不能根除;--workers=1与 CI 的workers: 1+retries: 2可规避。 详见技术实现档案 §8.11。
npm run verify 里还有六道静态门,专门拦「声明了、导出了,但其实没接线 / 没人用 / 没打进包 / 依赖越层」这类静态检查拦不住的失效:
| 门 | 拦什么 | 加它的原因 |
|---|---|---|
| verify/layers.mjs 分层依赖 | 跨层 import 必须符合 spec §3.9 的矩阵(core 不得依赖实现层);core 内不得出现 document./window./navigator.(只认运行时引用,不认类型收窄);core 不得依赖「平台专有」的 utils | core/Player.ts 曾直接 import 内核 / env / 默认插件 / 媒体面 —— 于是「换媒体面、换内核、换宿主」三件事每一件都要改 core |
| verify/events.mjs 事件活性 | 每个 Events 枚举成员必须至少有一个真实 emit 派发点,零派发即构建失败 | 「on() 注册会成功、但永不触发」的静默失效,类型校验、冒烟、E2E、单测都拦不住(详见技术实现档案 §9) |
| verify/exports.mjs 公开面形状 | 顶层导出 / 枚举与常量内容与清单精确集合比对,缺失与多余都失败 | 原实现只问「名字在不在」,于是成员级的删除/改名完全不被拦住 |
| verify/surface.mjs 公开面活性 | 清单里每个公开名在 src/ 必须有消费者,或有测试覆盖;两者皆无须登记豁免并写理由 | sniffer 曾有 5 个函数零引用零测试、占该文件 51%,一路活到 0.6.0 才被人工发现(随 0.6.0 删除) |
| verify/artifacts.mjs 产物卫生 | dist/**/*.d.ts 必须能对应到 src/**/*.ts;package.json 的 exports 目标必须存在;dist/*.es.js 必须都被 exports 引用 | 构建链不清理 dist,删掉/改名的源文件会留下孤儿声明,而 npm pack 打的正是磁盘上的 dist |
| verify/visibility.mjs 成员可见性 | Player 的公开实例属性只允许白名单里的 root / media(且必须 readonly);公开方法必须登记在 public-surface.mjs#PLAYER_PUBLIC 里;白名单自身也不许腐烂(登记了却已不存在的名字同样失败) | kernelReady = false 与 runHooks() 两处都漏了 private —— 根因是它们被兄弟类跨类读取,而 TS 的 private 是按类封装的(兄弟类也算外部),标了编译不过,于是被放宽成公开成员、写进了 dist/*.d.ts(kernelReady 还因此成了接入方可写的字段)。其余八道门都只看模块导出,没有一道看得到类成员的可见性 |
events/exports/surface三者的盲区各不相同,也正因此才会三个都要:events只管事件有没有派发点、不看导出;exports只管导出名字对不对、不看有没有人用;surface只管有没有人用、不管名字是否合规(新增的未登记导出会被它直接跳过)。
exports与surface还必须成对:形状门把新增导出「逼」进清单(verify/public-surface.mjs), 活性门再审清单里每一项的活性。单用任一个都有盲区 —— 只用形状门,那 5 个死函数当年照样全绿; 只用活性门,未登记的新导出会被直接跳过。
能力探测放在哪:宿主能力(MSE)在平台实现内(
platform/web/capabilities,不对外导出); 「这个媒体设备能不能播某个 MIME」是契约能力,走player.canPlay(mime)。 旧版从主入口导出的sniffer命名空间已移除 —— 它整个模块都是 Web 实现, 媒体设备能力上移到MediaSurface,宿主能力归平台层(详见技术实现档案 §8.12)。
许可
本项目完全自发,采用 MIT 协议 —— 可自由使用、修改、分发,包括商业用途,仅需保留版权与许可声明。
English
live-sdk is a player SDK built for H5 live-streaming scenarios. Its kernel ships without UI and without framework bindings — it exposes only three contracts, while UI, reporting, and host-environment adaptation are all added through plugins/adapters. Complexity is encapsulated, customizability is preserved: usable out of the box, yet able to be peeled down layer by layer to a fully custom UI.
Built on HLS (fMP4/CMAF container) + hls.js: LL-HLS low latency, ABR and manual quality switching, stream-interruption reconnection, network-quality adaptation, plus built-in end-to-end capability alignment (client capabilities × what the server actually provides).
Features
- Headless kernel: state / command / event contracts; the UI is an external consumer and can be replaced entirely.
- Pluggable kernels:
HlsKernel(hls.js + MSE / iOS MMS) andNativeKernel(Safari native HLS fallback) with automatic routing. - LL-HLS low latency: fMP4 parts (
EXT-X-PART) with runtime target-latency tuning. - Quality / ABR: automatic ABR plus manual switching; business quality tiers are aligned to the master m3u8 by
heightas the primary key, with index complexity encapsulated inside the SDK. - End-to-end capability alignment:
getFeatureStatus()reports client-vs-server gaps forlowLatency/abr/qualitySwitch/drm/airplay. - Network adaptation: target latency, retry count, backoff base, and timeouts are all "static values or dynamically evaluated from network quality".
- Tiered observability:
full(deep collection) /basic(standard<video>events only), statically configured. - Session metrics built in:
PlayerState.sessionState(session truth) +getSessionReport()(first-frame cost / stall count & duration / watch time), so integrations need not hand-roll the same instrumentation. - Plugin extensibility:
BasePluginlifecycle,UIPluginfor the view layer,ReporterPluginfor reporting,EnvAdapterfor host adaptation. - Framework adapters: React / Vue
usePlayerthat consume the three contracts with zero intrusion. - Build-free usage: UMD bundles are provided, so a CDN
<script>tag is all you need.
Installation
npm install @fancaf/live-sdk
# or
pnpm add @fancaf/live-sdkhls.js is a runtime dependency and is installed automatically. React / Vue adapters are optional peer dependencies — install them on demand:
npm install react # only when using @fancaf/live-sdk/react
npm install vue # only when using @fancaf/live-sdk/vueQuick Start
Out of the box (default UI)
import { createPlayer } from '@fancaf/live-sdk'
import { mountDefaultUI } from '@fancaf/live-sdk/ui'
const player = createPlayer({
container: '#player',
url: 'https://example.com/live.m3u8',
autoplay: true,
muted: true,
})
mountDefaultUI(player) // play/pause, mute, volume, quality, fullscreenServer-provided URL (PlayConfig Provider)
player.play(async () => {
const res = await fetch('/api/live/play-config')
return res.json() // { url, backup, liveStatus, autoplay, muted, poster, quality }
})React
import { useEffect, useState } from 'react'
import { createPlayer } from '@fancaf/live-sdk'
import { usePlayer } from '@fancaf/live-sdk/react'
function Player({ src }) {
const [player, setPlayer] = useState(null)
useEffect(() => {
const p = createPlayer({ container: '#stage', url: src, autoplay: true, muted: true })
setPlayer(p)
return () => p.destroy()
}, [src])
return player ? <Controls player={player} /> : null
}
function Controls({ player }) {
const { playing, muted } = usePlayer(player)
return (
<button onClick={() => (playing ? player.pause() : player.play())}>
{playing ? 'Pause' : 'Play'}
</button>
)
}Vue
<script setup>
import { ref, onMounted } from 'vue'
import { createPlayer } from '@fancaf/live-sdk'
import { usePlayer } from '@fancaf/live-sdk/vue'
const player = ref(null)
const stage = ref(null)
onMounted(() => {
player.value = createPlayer({ container: stage.value, url: 'live.m3u8', autoplay: true, muted: true })
})
const state = usePlayer(player) // Ref<PlayerState | null>, null until ready
</script>
<template>
<div ref="stage" />
<button v-if="state" @click="state.playing ? player.pause() : player.play()">
{{ state.playing ? 'Pause' : 'Play' }}
</button>
</template>Build-free (CDN)
<script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
<script src="https://unpkg.com/@fancaf/live-sdk/dist/live-sdk.umd.js"></script>
<script src="https://unpkg.com/@fancaf/live-sdk/dist/live-sdk-ui.umd.js"></script>
<script>
const { createPlayer } = LiveSdk
const { mountDefaultUI } = LiveSdkUI
const player = createPlayer({ container: '#player', url: 'live.m3u8', autoplay: true, muted: true })
mountDefaultUI(player)
</script>Core Concepts
The three contracts
| Contract | API | Description |
|---|---|---|
| State | getState() / subscribe(cb) | Low-frequency field snapshot; callbacks fire only on change |
| Commands | play pause mute setVolume switchQuality switchURL requestFullscreen exitFullscreen seek setPlaybackRate setPoster setLiveLatency | Intent-based operations |
| Events | on(name, cb) / once | first_frame manifest_parsed features_updated error live_status live_status_error, etc. |
| Query | getStats() / bufferInfo() / speedInfo() / getSessionReport() | Instantaneous metrics vs. session accumulators (see below) |
State (PlayerState)
interface PlayerState {
playing: boolean
volume: number
muted: boolean
qualities: Quality[] // effective tiers already mapped to streams (incl. the original business label)
currentQuality: number | null // current tier (Quality.id)
sessionState: SessionState // session truth: idle/loading/ready/playing/paused/stalled/error/ended
usingBackup: boolean // whether PlayConfig.backup is currently in use
currentTime: number // playback position; throttled to whole-second changes
duration: number // total duration; Infinity for live
fullscreen: boolean // three sources: standard fullscreen / container-level (an ancestor of video) / iOS native video fullscreen
playbackRate: number // effective rate, read back after browser clamping
capabilities: KernelCapabilities
[ext: `app.${string}`]: unknown
}How
playingandsessionStatedivide the work (they deliberately diverge while stalling — judging the wrong one gives wrong reports):playinganswers "does the user see play or pause"; the play/pause button shape should readplaying. Spinners, degradation hints and analytics classification should readsessionState. While stalling,playingstaystrue(playback has started, it is only buffering) whereassessionStateis'stalled'.
usingBackupis reset in three places: a freshplay()and an explicitswitchURL()→false; switching tobackupduring a stall-reconnect (first retry) →true.
The snapshot only holds low-frequency fields meaningful to the primary live-streaming scenario. High-frequency or observable data such as playback progress and buffered ranges goes through
getStats()/bufferInfo()or the rawplayer.mediareference — never into the snapshot.
Capability alignment (getFeatureStatus)
player.on('features_updated', (report) => {
// report.features: [{ feature, client, server, matched, detail }]
// report.summary: { matched, mismatched }
})client takes the weaker side of "SDK + platform", while server comes from manifest probing (EXT-X-PART / multiple EXT-X-STREAM-INF / EXT-X-KEY).
API
createPlayer(config): Player
| Field | Type | Default | Description |
|---|---|---|---|
| container | string \| HTMLElement | required | Mount container |
| url | string | — | Default playback URL (for dynamic delivery use play(provider)) |
| kernel | KernelConstructor | auto-routed | Custom kernel |
| hlsConfig | object | — | Pass-through to native hls.js config |
| preset | string \| PluginConstructor[] | 'live' | Plugin composition |
| autoplay | boolean | false | Fire one play() after construction (requires url) |
| muted | boolean | false | Mute |
| ignores | string[] | — | Disable specific feature plugins inside the Preset |
| network | Partial<NetworkConfig> | built-in | Network-sensitive policy parameters |
| observability | 'full' \| 'basic' | 'full' | Observability tier |
| locale | 'en' \| 'zh' | 'en' | Language of runtime messages (errors / logs / reports / built-in UI control text); global semantics |
| env | EnvAdapter | WebEnvAdapter | Host-environment adapter |
| posterMode | 'native' \| 'overlay' | 'native' | How the poster is rendered; 'overlay' recommended on the MSE path |
Integration triage: zero-size container warning. The most common reason "nothing shows up after integrating" is a container with no height — a
height: 100%element resolves to 0 when its parent has no definite height. The SDK is perfectly healthy at that point (play()succeeds, no error event), yet not a single pixel is visible.play()checks for this and warns once when either dimension is 0:容器尺寸为 0(0×0),播放器不会有可见画面。请给容器或其父级确定的高度,例如 style="width:100%;height:300px"。It only checks on
play()(not at construction): a container legitimately being 0 at construction is common (inactive tab, route transition, lazy mount), where warning would be too noisy. When the environment cannot measure size (non-browser), it does not misreport.
Commands
| Method | Returns | Description |
|---|---|---|
| play(input?) | Promise<void> | Start / resume: no argument and already started = resume playback (no re-fetch); with argument (URL / PlayConfig / provider) = start or restart |
| pause() | void | Pause |
| mute(m) / setVolume(v) | void | Mute / volume |
| switchQuality(id) | Promise<void> | Switch quality (Quality.id; -1 restores auto ABR). Returns a Promise because it awaits hooks; statement-style calls need no await |
| switchURL(url) | Promise<void> | Switch stream at runtime (session state preserved) |
| requestFullscreen(target?) / exitFullscreen() | void | Enter / exit fullscreen (iOS native video fullscreen can also be exited). Pass target to choose the element: defaults to <video>; pass a container (e.g. player.root) for container-level fullscreen so custom controls stay visible and clickable |
| seek(time) | void | Seek (seconds), auto-clamped to [0, duration]. No-op while live (decided by the kernel's isLive, not inferred from duration); works for VOD / replay |
| setPlaybackRate(rate) | void | Set playback rate, read back afterwards. Not recommended for the primary live scenario (rate changes accumulate/consume latency); fine for VOD / replay |
| setPoster(poster?) | void | Swap the poster at runtime (empty = remove); rendering still follows posterMode |
| setLiveLatency(target?, max?) | void | Override the LL-HLS target latency at runtime; passing nothing clears the override and restores the dynamic policy from network |
play() and switchURL() share the same semantics: resolve = playing, reject = failure after the SDK has done its best (fatal). Recoverable errors go through internal automatic reconnection and will not reject.
play()/pause()synchronously updategetState().playing(without waiting for the browser's asynchronously dispatchedplay/pausemedia events), so the UI can read the snapshot immediately after the command returns and render the button — no more "video paused but the button still shows playing". For the most precise switching criterion, readplayer.media.paused.
playingexpresses the "play/pause semantics presented to the user", not a transient session state: during reconnection (error → retry → loading) the button keeps the "playing" icon and will not flicker back to "play" while waiting for backoff; conversely, a pause pressed by the user during reconnection is respected and will not be revived to playing after a successful retry.
Intercepting built-in logic (Hooks)
useHooks(name, fn) inserts your own logic around a built-in command — most typically to intercept it:
// Disallow quality switching while signed out (or run an auth refresh, then let it through)
player.useHooks('switchQuality', async (ctx) => {
if (ctx.phase === 'before' && !isLoggedIn()) ctx.cancelled = true
if (ctx.phase === 'after') report('quality', { id: ctx.id, applied: ctx.applied })
})Three commands are wired: 'play' / 'switchQuality' / 'switchURL' (no hooks on the others yet). The conventions:
| Field | Meaning |
|---|---|
| ctx.phase | The same hook is called twice: 'before' (before the built-in logic) and 'after' (after it) |
| ctx.cancelled | Writable in 'before' only. Setting true skips the built-in logic and returns immediately |
| ctx.applied | 'after' only. Whether the built-in logic actually took effect; false = resolved to a no-op (kernel lacks quality switching, unknown id, or play() took the resume branch) |
| Arguments | 'play' → ctx.input; 'switchQuality' → ctx.id; 'switchURL' → ctx.url |
fn may be async — the 'before' phase is awaited, so asynchronous gates ("check the permission first, then proceed") are reliable (at the cost of delaying the command's return). useHooks returns an unbind function.
Why write back to
ctxinstead of returning a boolean:HookFn's return type isvoid | Promise<void>(hooks carry no return-value contract), so the interception decision travels through a mutable context. That also lets a single handler serve both phases by branching onctx.phase.
Query methods
| Method | Description |
|---|---|
| getStats() / bufferInfo() / speedInfo() | Instantaneous metrics / buffered ranges / download speed |
| getSessionReport() | Session accumulators: first-frame cost / stall count & duration / watch time / load start |
| getFeatureStatus() | End-to-end capability alignment report (see above) |
| getLastRetryDiagnostic() | Most recent retry diagnostic snapshot; null when there has been no retry |
Why the accumulators are not merged into getStats(): StatsInfo fields mean "the playback quality at this very instant" (bitrate / fps / dropped frames). Mixing accumulators in makes "not started yet" and "the value really is 0" indistinguishable at the type level. Hence three separate homes: instantaneous (getStats) / cumulative (getSessionReport) / low-frequency semantic (PlayerState).
player.getSessionReport()
// {
// firstFrameCost: 812, // ms; null until the first frame is available
// stallCount: 2, // stall episodes this session
// stallDuration: 3400, // accumulated stall ms (an in-progress stall counts live)
// watchTime: 61200, // accumulated *actually playing* ms — loading/paused/stalled excluded
// loadStartTime: 1732000000000, // session load start; null if never started
// }Session boundary: reset by a fresh
play(PlayConfig)(everything zeroed).switchURL()does not reset (same viewing session, different source);play()with no argument (resume) does not reset either (that is a resume, not a new session). If you need "session wall-clock duration" (including pauses and stalls), computeDate.now() - loadStartTimeyourself rather than reusingwatchTimefor two meanings.
Retry diagnostics (for troubleshooting)
Recoverable errors are reconnected automatically by the SDK. On every error / reconnection, the SDK captures a "current playback URL + current network environment" snapshot and delivers it in three places — the error event, the retry event, and the report record — making it easy to locate in production "which stream, on what network, and at which retry attempt it failed":
player.on('error', (e) => {
if (e.fatal) showErrorUI(e)
console.warn(e.code, e.diagnostic)
// {
// url, primaryUrl, isBackup, // requested URL / primary URL / whether backup was used
// networkQuality, online, visibility, // good|fair|poor|offline / online / foreground-background
// effectiveType, downlink, rtt, // Network Information API (undefined if unsupported)
// bufferBehind, bufferRemaining, currentTime,
// retryCount, delay, errorCode, time,
// }
})The same snapshot is also delivered with the retry event payload (e.diagnostic) and to reporting plugins as ReportRecord.data.diagnostic — wire up a custom ReporterPlugin and forward it straight to your analytics/logging system.
The two channels carry equivalent information: the event channel gives
err.code/err.domain/err.fatal/err.diagnostic; the reporting channel givesrecord.code/record.data.domain/record.level/record.data.diagnostic. Whichever you pick, you get the same facts. The only thing to avoid is using both at once (each error would then be reported twice — see the note on not registering a ReporterPlugin to collect errors).
Error domain (err.domain): triage by "where to look"
Error codes are an enum (13 values and growing), while a dashboard only cares about the coarse attribution direction. PlayerError.domain provides it directly; for any raw code string call errorDomainOf(code):
| Domain | Where to look | Codes |
|---|---|---|
| network | server / CDN / transport | manifest_load_error manifest_404 frag_load_error network_error load_timeout retry_exhausted |
| decode | content / transcode / kernel | media_decode_error media_src_not_supported drm_no_license |
| config | integration config / platform capability | config_resolve_failed no_supported_kernel play_failed |
| unknown | unclassified — reported honestly rather than guessed | — |
player.on('error', (e) => {
if (e.domain === 'network') reportCdnIssue(e)
else if (e.domain === 'decode') reportTranscodeIssue(e)
else if (e.domain === 'config') reportIntegrationIssue(e)
})With this layer, integrations no longer maintain their own "code → direction" table — such a table inevitably falls out of sync as the SDK adds error codes.
unknownis deliberately its own bucket: do not fold it intodecode("the player's own problem"). That would disguise genuine unknown failures as decode issues and send triage in the wrong direction.A contract test walks the full
ERROR_CODEset and fails if any code is unregistered — so you will never receive an error that should have a domain but reportsunknown.
Message IDs and language: [LV-xxxx] + locale
A runtime message (including built-in UI control text) = stable ID + text resolved by language. English by default:
[live-sdk] [LV-3006] reconnect attempt #1 (network_error) → https://cdn/a.m3u8
[live-sdk] [LV-1004] autoplay blocked, waiting for a user gesture
[LV-2001] media failed to loadPass locale: 'zh' to switch to Chinese — same ID, different text, and the on-screen tooltips switch with it:
[live-sdk] [LV-3006] 重连第 1 次 (network_error) → https://cdn/a.m3u8
[LV-2001] 媒体加载失败| Note | Detail |
|---|---|
| The ID is the stable part | [LV-xxxx] is LV-<section><number> (sections documented on the MSG constant). Text changes, IDs do not — users can quote the ID in a bug report, and your alerting rules can key on it instead of on volatile text |
| ⚠️ Never branch on or match message text | Use err.code / err.domain (machine-readable parts are always English), or key on the MSG ID |
| locale defaults to 'en' | The SDK ships on public npm with a bilingual README, so logs and errors address the wider audience first; Chinese users switch with a single config line |
| locale is global (like setLogLevel) | Written at createPlayer time; multiple instances on one page share the last setting (UI controls are covered by the same rule). For per-instance language, consume the language-independent code / domain / MSG IDs |
| Coverage | PlayerError.message, all logger.* / console.* output, message in report records, getFeatureStatus().detail, live_status_error.error, platform-provided zeroSizeHint, and built-in UI control text (title / aria-label / quality dropdown items, section LV-7xxx) |
| UI text inherits the same locale but carries no ID | IDs exist for developers to quote and for alerts to key on — inside a tooltip they are just noise (screen readers would spell them out). So UI goes through uiText() (plain text) and logs through t() ([LV-xxxx] prefixed): one message table, one locale, the only difference being the ID prefix |
| Switching language at runtime repaints mounted UI immediately | setLocale() notifies subscribers and controls repaint from their last state snapshot; otherwise you get "language changed but the tooltip did not" — player.subscribe only fires on state changes, and switching language is not one |
| Custom UIs are unaffected | The built-in controls' text is not a contract; a custom UI decides its own copy and language strategy |
const player = createPlayer({ container: '#player', locale: 'zh' })
player.on('error', (e) => {
// Key on the ID: it survives language and version changes (matching text does not)
if (e.message.includes('[LV-3004]')) showRetryExhausted()
})
fullscreen button tooltip → 「全屏」 (UI text carries no ID, see below)Events
Events enum members (values are the corresponding snake_case strings, so player.on(Events.FIRST_FRAME, cb) is equivalent to player.on('first_frame', cb)):
LOAD_START MANIFEST_PARSED FIRST_FRAME PLAY PAUSE PLAYING STALLED RECOVERED
RETRY ERROR ENDED QUALITY_CHANGE ABR_CHANGE BUFFER_UPDATE SPEED_UPDATE
VISIBILITY_CHANGE FEATURES_UPDATED KERNEL_EVENT COMMAND⚠️
endedwhile live does not mean "the live stream has ended" (fixed in 0.7.0): over MSE the kernel writesdurationas the playlist edge, so once the stream stops being refreshed the playhead catches up to it and the browser fires a nativeended. In that case the SDK treats it as a stream-interruption recovery (emitsretryand reconnects) and does not emitENDED; only anendedarriving after the kernel has confirmed the stream ended (playlist contains#EXT-X-ENDLIST) is propagated. To show "the live stream has ended", rely onlive_status(your own API) or thelive_changedkernel_event— not onENDED.
Plugins additionally dispatch two standalone event names (deliberately kept out of the Events enum — they belong to the optional side-channel provided by preset: 'live', not to the playback kernel contract; integrations that never configure them will never receive them):
'live_status' // LivePolling: live status changed
LIVE_STATUS_ERROR_EVENT // 'live_status_error': LivePolling request failedCOMMAND: unified command observability (all 12 commands)
Semantic events (play / quality_change / …) exist to drive UI and each carries a different payload. "What the user did, and whether it took effect" is an observability question. COMMAND unifies it — one subscription covers all 12 commands instead of per-command subscriptions plus fallbacks.
player.on('command', ({ name, phase, applied, time }) => {
// phase: 'before' | 'after' (every command dispatches both); applied only exists on 'after'
if (phase === 'after' && applied === false) console.warn(`${name} was a no-op`)
})Typical applied === false cases ("the command returned but had no effect", not an error):
| Command | No-op condition |
|---|---|
| seek | while live (kernel isLive === true); destroyed instance |
| switchQuality | kernel lacks qualitySwitch; id not in the quality table; intercepted by a before hook |
| switchURL | kernel not initialised; switch failed; intercepted by a before hook |
| setLiveLatency | kernel doesn't implement setLiveLatency (e.g. NativeKernel) |
| play | destroyed instance; intercepted by a before hook; superseded by a newer play(); start/resume failed |
play({ autoplay: false })reportsapplied: true— it did what it was asked (load, don't auto-play); it is not a no-op.⚠️
setVolumecan fire at high frequency while dragging a range slider (inputfires continuously) — throttle it if you aggregate interactions.Command names are enumerable at runtime:
COMMAND_NAMES(12, in one-to-one correspondence with the keys ofPlayerCommands).
PLAY vs PLAYING
Both mean "playback started", but at different points in time (mirroring the native <video> event semantics):
| Event | Meaning | Fires when |
|---|---|---|
| PLAY | the play request has been accepted by the kernel | paused flips true → false (autoplay success, user tap, post-reconnect resume) |
| PLAYING | the media is actually rendering | first frame is presentable; every start (including source switches) |
PLAY is strictly paired with PAUSE; PLAYING pairs with STALLED / RECOVERED to describe playback quality.
BUFFER_UPDATE: buffer-level bucket changes
Buffer levels only change on timeupdate / progress (~4 Hz). Emitting on every tick would push a 4 Hz stream to every subscriber; emitting never would force integrations to setInterval-poll bufferInfo(). The middle ground is to emit only when the level crosses a bucket boundary:
player.on('buffer_update', (b) => {
// { level, remaining, length, totalRemaining, totalLength, behind, buffers }
if (b.level <= 1) showLowBufferHint() // less than 3s of playable data left
})level is derived from the current playback block's remaining playable time, bucketed on BUFFER_LEVEL_THRESHOLDS = [1, 3, 5, 10, 20] (seconds), ranging from 0 (tightest) to 5 (most comfortable). Two things to note:
- Bucketed on the current block (
remaining), not the union (totalRemaining) — whether playback can continue depends solely on the current block. In an island scenario where 30s is buffered elsewhere but only 0.5s remains in the current block, a stall is what actually happens. - The payload carries the full
BufferInfo, so integrations judging by the union or by latency (behind) can live off this one event. For custom thresholds, read the raw seconds —BUFFER_LEVEL_THRESHOLDSandbufferLevelOf()are both exported.
The query-style bufferInfo() remains available for on-demand reads outside the event.
Live status polling and failure handling (LivePolling)
preset: 'live' bundles LivePolling: give it a PlayConfig.liveStatus and it polls at a fixed interval, dispatching a structured live_status only when the status changes (deduplication keeps subscribers from re-rendering on every tick).
Failure semantics: polling is a side-channel — a failure must never affect playback or trigger a reconnect. But "not disruptive" is not the same as "dies quietly":
| Situation | What the integration sees | |---|---| | Polling works, the server status simply has not changed (expected) | "the status never changed" | | Polling has been failing continuously and is effectively dead (a fault) | "the status never changed" |
The two are indistinguishable, so the latter makes you believe a false fact. Therefore:
import { LIVE_STATUS_ERROR_EVENT } from '@fancaf/live-sdk'
player.on('live_status', (s) => { /* { status, previousStatus, raw, time } */ })
player.on(LIVE_STATUS_ERROR_EVENT, (e) => {
// { url, failCount, error, time }
// failCount: consecutive failures (reset to 0 on success) — tells jitter from a sustained outage
})- Every failure is logged (
logger.warn, unthrottled — logs exist to be read by humans); - Failures are also surfaced via the
live_status_errorevent, throttled byfailCount: the 1st failure is reported immediately (a fault must be visible at once), then the 3rd, 10th and every 30th (30/60/90…) afterwards — balancing "a sustained outage still emits a heartbeat signal" against "a long offline period must not flood the reporting channel". The predicate is the static pure functionLivePolling.shouldReportFailure(n), unit-testable; - A standalone event rather than folding into
Events.ERROR: a 500 on the status endpoint must not be recorded as "live playback failed", or the integration'serr.fatalfallback branch, error-rate metrics and Sentry capture all get polluted; - Exponential backoff on failure: interval =
min(interval × 2^failCount, maxInterval)(maxIntervaldefaults to 5 minutes and can be set beforestart()), reset to the base interval as soon as one request succeeds — neither giving up nor hammering at a fixed interval; res.okvalidation:fetchdoes not reject on 4xx/5xx, so without checkingres.oka "500 + JSON error body" neither enterscatchnor yields a status field — a silent failure with no trace at all;- Explicit status normalisation:
{"status": 0}(a number) is coerced withString(), otherwise it never equals the stringlastStatus, deduplication breaks entirely and every tick is misread as "status changed"; stop()clears the pending handle and voids the in-flight round: pausing on background / re-start()leaves no stray timer pointing at a stale URL.
| Member | Description |
|---|---|
| start(url, interval?) / stop() | Start / stop polling (also reachable via Player#pollLiveStatus(interval?)) |
| shouldReportFailure(failCount) | Static throttling predicate |
| maxInterval | Backoff ceiling in ms, default 300000 |
Extension Points
| Extension | Purpose |
|---|---|
| BasePlugin | Custom behavior plugin (lifecycle create/init/ready/destroy) |
| UIPlugin | View-layer plugin, mounted into player.root |
| ReporterPlugin | Custom reporting (report(record)) |
| EnvAdapter | Host-environment adaptation (visibility / network quality, e.g. App JSBridge) |
| KernelConstructor | Custom media kernel |
class MyReporter extends BasePlugin {
report(record) {
navigator.sendBeacon('/log', JSON.stringify(record))
}
}
createPlayer({ container: '#player', preset: [MyReporter] })Wiring up Sentry (reference implementation)
The SDK does not ship a Sentry adapter. Sentry is third-party system capability, which is not the player's core responsibility (see Capability boundaries · type 4); the SDK provides only the reporting channel contract — where records go is up to your own telemetry stack. A copy-pasteable blueprint lives in
examples/reporter-sentry.ts:
import { BasePlugin } from 'live-sdk'
const SENTRY_LEVEL = { fatal: 'fatal', warn: 'warning', info: 'info' } // trap ②
export class SentryReporter extends BasePlugin {
static readonly pluginName = 'sentryReporter'
private sentry
init(config) { this.sentry = config?.sentry }
report(record) {
if (!this.sentry) return // not injected = degrade to no-op
const level = SENTRY_LEVEL[record.level] ?? 'info'
if (record.type === 'error') {
this.sentry.captureException(new Error(record.code), {
level,
extra: { code: record.code, ...record.data }, // trap ①: do NOT flatten
})
return
}
// non-error (retry events / manual report) → breadcrumb, building a pre-error trail
this.sentry.addBreadcrumb?.({ category: record.type, message: record.code, level, data: record.data })
}
}
player.registerPlugin(SentryReporter, { sentry: Sentry })Two traps (both marked in the blueprint — please copy them verbatim):
| # | Trap | What happens if you skip it |
|---|---|---|
| ① | record.data must be nested under extra | Sentry merges CaptureContext using an explicit field allowlist (tags/extra/contexts/user/level/…), with no pass-through, so unknown top-level keys are silently dropped → message / domain / diagnostic are all lost, with no client-side error — you only find out by looking at Sentry |
| ② | level must be mapped: 'warn' → 'warning' | Sentry's valid levels are 'fatal' \| 'error' \| 'warning' \| 'log' \| 'info' \| 'debug' — there is no 'warn'; passing it through lands the level on an invalid value and breaks alert rules |
examples/is not decoration: that file is compiled viaexamples/tsconfig.jsonand its behaviour is asserted bytest/reporter-sentry-example.test.ts(extracarries the diagnostic, no extra top-level keys beyondlevel/extra, all three level mappings, breadcrumb routing) — both wired intonpm run verify, so a wrong example fails the build instead of quietly rotting in the docs.
Package Structure
| Entry | Contents |
|---|---|
| @fancaf/live-sdk | Kernel + three contracts + plugin base classes + official Reporter |
| @fancaf/live-sdk/ui | Default UI package (mountDefaultUI) |
| @fancaf/live-sdk/react | React usePlayer |
| @fancaf/live-sdk/vue | Vue usePlayer |
Bundles: ESM (.es.js) for modern builds and SSR; UMD (.umd.js) for build-free CDN usage. This package does not ship CJS (a live-streaming player has no Node runtime scenario).
Compatibility
| Platform | Kernel | Notes |
|---|---|---|
| Android / desktop Chrome / Edge | HlsKernel (hls.js + MSE) | Full capability |
| iOS Safari 17.1+ / macOS Safari 17.1+ | HlsKernel (MMS) | Full capability |
| iOS Safari < 17.1 | NativeKernel | Degraded playback only; deep observability automatically falls back to basic |
| Native iOS / no-MSE environments | NativeKernel | Same as above |
Prerequisite:
observability: 'full'requires the platform to support MSE (MMS on iOS/macOS Safari 17.1+, or standard MSE). Below that version it is a statically known boundary — the SDK goes straight toNativeKernel, not a runtime-probed downgrade.
Capability Boundaries (Non-Goals)
The following capabilities are deliberate boundary contractions of the technical choices, not "not yet implemented". Check this table before integrating — if your business strongly depends on any category below, live-sdk is not the right fit, and a full-scenario player such as xgplayer / mpegts.js would serve you better.
Grouped by root cause into four categories; each category shares a single design decision:
Category 1: No VOD (controllable-timeline playback)
Root cause: live-sdk is a live-streaming kernel — a live timeline is constrained by the live edge and cannot be freely positioned, so "operations relative to the timeline" carry no meaning in the live state.
seek/setPlaybackRatehave entered the command set (to serve VOD / replay playback), but while live they are a no-op and not recommended respectively — a command existing does not erase the semantic boundary.
| Boundary item | Notes | Status |
|---|---|---|
| Progressive VOD files (plain .mp4 direct playback) | A single-engine hls.js choice that does not do range requests / segment loading; "a complete MP4 file" and the chosen fMP4 streaming container are two different things | Still unsupported: attach a VOD kernel / DashKernel, or switch to mpegts.js |
| Playback rate (setPlaybackRate) | Live is an infinite linear stream; changing rate only breaks the "edge following / low latency" semantics — slowing down accumulates latency, speeding up repeatedly stalls when the buffer runs dry | Command provided; not recommended for the primary live scenario, fine for VOD / replay |
| Positioning / seeking (seek) | Live has no "jump to a position" semantics | Command provided, but a no-op while live (decided by the kernel's isLive — do not infer it from duration: over MSE, hls.js writes a live stream's duration as the finite playlist edge); works for VOD / replay |
Note: HLS VOD streams (
#EXT-X-ENDLIST) are supported (through the same kernel); "no VOD" here specifically means the timeline-relative operations above and progressive.mp4files. See the technical specification §1.3.
Category 2: HLS single protocol only
Root cause: trading breadth for architectural simplicity — "go deep on one protocol". The kernel layer reserves extension points but implements nothing else. Multi-protocol means carrying your own demux/remux stack, an order-of-magnitude maintenance cost.
| Not supported | Notes | If you need it |
|---|---|---|
| FLV / DASH protocols | Only HLS is implemented | Implement the corresponding Kernel and inject it via the kernel config |
| WebSocket-MP4 (ws://) | Not HTTP progressive transfer; outside the HLS scope | Same as above — a custom kernel is required |
| mkv container audio-track / subtitle extraction | No multi-container demux implementation | A custom demux is required |
Category 3: Ceiling = the browser's ceiling (no codec stack)
Root cause: no in-house decoding / codec-filling stack is maintained. That cost is outsourced to the browser in exchange for an order-of-magnitude reduction in size and maintenance surface — at the price that "what the browser cannot do, we cannot do".
| Not supported | Notes | If
