@video-lab/protocol 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/dist/index.cjs +1909 -0
- package/dist/index.d.cts +11090 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +11090 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1849 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +50 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1909 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let zod = require("zod");
|
|
3
|
+
//#region src/configs.ts
|
|
4
|
+
/** 媒体类型。`auto` = 从 URL 后缀推断 */
|
|
5
|
+
const MediaTypeSchema = zod.z.enum([
|
|
6
|
+
"mp4",
|
|
7
|
+
"hls",
|
|
8
|
+
"flv",
|
|
9
|
+
"auto"
|
|
10
|
+
]);
|
|
11
|
+
/** 已经完成归一化、可实际路由的媒体类型。`auto` 只允许停留在消费方输入侧。 */
|
|
12
|
+
const SourceEntryTypeSchema = zod.z.enum([
|
|
13
|
+
"mp4",
|
|
14
|
+
"hls",
|
|
15
|
+
"flv"
|
|
16
|
+
]);
|
|
17
|
+
/** 封面配置 */
|
|
18
|
+
const PosterConfigSchema = zod.z.union([zod.z.string(), zod.z.object({
|
|
19
|
+
url: zod.z.string(),
|
|
20
|
+
loading: zod.z.enum(["eager", "lazy"]).default("lazy").optional(),
|
|
21
|
+
fit: zod.z.enum([
|
|
22
|
+
"cover",
|
|
23
|
+
"contain",
|
|
24
|
+
"fill"
|
|
25
|
+
]).default("cover").optional()
|
|
26
|
+
})]);
|
|
27
|
+
/**
|
|
28
|
+
* 暂停时盖在画面中央的静态图(ADR-043 / issue #121)。
|
|
29
|
+
*
|
|
30
|
+
* 形状**刻意与 {@link PosterConfigSchema} 对齐**(同为 `string | { url, fit, … }`),
|
|
31
|
+
* 消费方不用学两套。
|
|
32
|
+
*
|
|
33
|
+
* **渲染在 iframe 内,与封面相反** —— 封面为了抢首帧挪到了宿主侧,暂停图不抢首帧
|
|
34
|
+
*(暂停发生时 iframe 早已 ready),放宿主侧反而让静态 iframe 那条路拿不到,撞 cross-mode-parity。
|
|
35
|
+
*
|
|
36
|
+
* **边界(ADR-025 已判的那条线)**:静态图 + 可选关闭按钮进 SDK —— 可序列化、无调度逻辑,
|
|
37
|
+
* 与 `poster` 同性质;**带倒计时 / 跳转 / 推荐列表的不进** —— 那是业务调度,归团队层。
|
|
38
|
+
* inline 模式另有 `<sentinel-pause>` 的默认 slot(能塞任意节点),表达力更强但
|
|
39
|
+
* 过不了 postMessage,所以三条 iframe 路只有本字段有效。
|
|
40
|
+
*/
|
|
41
|
+
const PauseImageConfigSchema = zod.z.union([zod.z.string(), zod.z.object({
|
|
42
|
+
url: zod.z.string(),
|
|
43
|
+
/**
|
|
44
|
+
* 默认 `contain` —— 暂停图通常是完整构图,`cover` 会把边裁掉。
|
|
45
|
+
*
|
|
46
|
+
* ⚠️ 默认值**由渲染侧兜**,不写成 zod 的 `.default()`:`.default(x).optional()`
|
|
47
|
+
* 里 optional 会短路,默认值永远不生效(`PosterConfigSchema` 的 `loading` / `fit`
|
|
48
|
+
* 就是这个形态,parse `{url}` 出来还是 `{url}`)。写在 schema 上会让消费方
|
|
49
|
+
* 以为 parse 完就有值,反而误导。
|
|
50
|
+
*/
|
|
51
|
+
fit: zod.z.enum([
|
|
52
|
+
"cover",
|
|
53
|
+
"contain",
|
|
54
|
+
"fill"
|
|
55
|
+
]).optional(),
|
|
56
|
+
/** 给图加一个关闭按钮,用户点了本次播放不再显示。默认 `false` */
|
|
57
|
+
closable: zod.z.boolean().optional()
|
|
58
|
+
})]);
|
|
59
|
+
/** 字幕轨 */
|
|
60
|
+
const SubtitleTrackSchema = zod.z.discriminatedUnion("mode", [zod.z.object({
|
|
61
|
+
mode: zod.z.literal("url"),
|
|
62
|
+
url: zod.z.string(),
|
|
63
|
+
locale: zod.z.string(),
|
|
64
|
+
label: zod.z.string(),
|
|
65
|
+
isDefault: zod.z.boolean().optional()
|
|
66
|
+
}), zod.z.object({
|
|
67
|
+
mode: zod.z.literal("content"),
|
|
68
|
+
/**
|
|
69
|
+
* 内联字幕正文,**必须是 WebVTT**(以 `WEBVTT` 行开头)。
|
|
70
|
+
*
|
|
71
|
+
* 字幕能力来自 xgplayer `TextTrack` 插件(wrap `xgplayer-subtitles@3.0.26`),
|
|
72
|
+
* 那份产物里 `WEBVTT` 出现 1 次、`srt` **0 次** —— 引擎只认这一种格式。
|
|
73
|
+
*
|
|
74
|
+
* 这里曾并列一个必填的 `contentType: z.enum(['text/vtt','text/srt'])`,
|
|
75
|
+
* 但 `'text/srt'` 从来不可能工作,而且没有任何一层读过这个字段。
|
|
76
|
+
* 既然只剩一个合法值,它就不携带任何信息 —— 已按 ADR-039 删除。
|
|
77
|
+
*/
|
|
78
|
+
content: zod.z.string(),
|
|
79
|
+
locale: zod.z.string(),
|
|
80
|
+
label: zod.z.string(),
|
|
81
|
+
isDefault: zod.z.boolean().optional()
|
|
82
|
+
})]);
|
|
83
|
+
/** HLS 协议专属配置 */
|
|
84
|
+
const HlsConfigSchema = zod.z.object({
|
|
85
|
+
/**
|
|
86
|
+
* LL-HLS 显式开启(ADR-024)。
|
|
87
|
+
*
|
|
88
|
+
* ⚠️ **所有浏览器都必须显式开**,否则不会启用低延迟模式。
|
|
89
|
+
*
|
|
90
|
+
* 这里从前写的是「非 Safari 必须显式开;Safari 走原生,自动检测 EXT-X-PART-INF」——
|
|
91
|
+
* **那句话被 ADR-056 作废了**:HLS 内核现在按能力选,有 MSE 或 MMS 就走 hls.js,
|
|
92
|
+
* 于是 Safari / iOS 也不再走原生,那条「平台白送的自动低延迟」路径没有了。
|
|
93
|
+
* 后果是**静默的**:不显式开的话,原本靠它的直播只是退回普通 HLS 延迟,不报错。
|
|
94
|
+
*/
|
|
95
|
+
lowLatencyMode: zod.z.boolean().optional() });
|
|
96
|
+
/** 媒体元信息 */
|
|
97
|
+
const MediaMetadataSchema = zod.z.object({
|
|
98
|
+
title: zod.z.string().optional(),
|
|
99
|
+
description: zod.z.string().optional(),
|
|
100
|
+
duration: zod.z.number().optional()
|
|
101
|
+
});
|
|
102
|
+
/**
|
|
103
|
+
* 多源里的单个候选源。`type` 必填——多源场景下不允许靠后缀猜。
|
|
104
|
+
*
|
|
105
|
+
* ⚠️ **这里曾经有 `quality` / `bitrate` / `codec`,已按 #276 删除**(判据见 ADR-039)。
|
|
106
|
+
* 三个都是零消费点:清晰度档位 100% 来自 hls.js 的 `levels`(`readQualityLevels`),
|
|
107
|
+
* 和消费方声明的候选源无关;选源规则只按 `type`(容器格式)走,这三个不参与任何一步。
|
|
108
|
+
*
|
|
109
|
+
* **别看着「候选源没有清晰度信息」好心加回来** —— `sources[]` 是「同一内容的不同封装」,
|
|
110
|
+
* 不是「不同分辨率」。把它重新解释成清晰度候选是**新增能力**,要先回答「声明值和 manifest
|
|
111
|
+
* 实际档位不符时听谁的」(ADR-046 在 `metadata.duration` 上答过同类问题:**听引擎的**),
|
|
112
|
+
* 走 issue + ADR,不是往这里补一个字段。
|
|
113
|
+
*
|
|
114
|
+
* 它们此前没被发现,是因为 `contract-fields` 的数组递归有 bug(zod 3 的
|
|
115
|
+
* `ZodArray._def.type` 装的是元素 schema 不是类型名),**数组元素字段整层从没进过清单**(#275 修)。
|
|
116
|
+
*/
|
|
117
|
+
const SourceEntrySchema = zod.z.object({
|
|
118
|
+
url: zod.z.string(),
|
|
119
|
+
type: SourceEntryTypeSchema
|
|
120
|
+
});
|
|
121
|
+
/**
|
|
122
|
+
* 单源 / 多源对象共有的字段。
|
|
123
|
+
*
|
|
124
|
+
* ⚠️ 这里曾经有 `poster`,已按 ADR-049 删除 —— 零消费点,且**顶层 `PlayerConfig.poster`
|
|
125
|
+
* 覆盖同一能力并且真的接到了 xgplayer**。墓碑见 {@link PlayerConfigSchema} 的 JSDoc。
|
|
126
|
+
*/
|
|
127
|
+
const sourceCommonShape = {
|
|
128
|
+
/**
|
|
129
|
+
* 直播标记。**SDK 不探测,完全靠消费方传。**
|
|
130
|
+
*
|
|
131
|
+
* 它管两件事:① 选源优先级(直播优先 FLV,点播优先 HLS,见 `source-router.ts`);
|
|
132
|
+
* ② 播放器的 `isLive` —— iOS 后台切回的自愈策略、弹幕模式、进度条能不能拖。
|
|
133
|
+
*
|
|
134
|
+
* ⚠️ **写在 source 上,但生效范围是整个 player。** ② 是**构造期**配置,
|
|
135
|
+
* 运行时改不了 —— 所以 `load()` 换源时新旧 `live` 不一致会抛
|
|
136
|
+
* `E_METHOD_NOT_SUPPORTED`,要销毁重建(#220)。别被它挂在 source 上的位置误导。
|
|
137
|
+
*
|
|
138
|
+
* ⚠️ **漏填是静默失效**:直播不写 `live: true` → 走点播优先级选中 HLS →
|
|
139
|
+
* 延迟从 ~2s 变 ~8s,不报错不告警(#217)。
|
|
140
|
+
*/
|
|
141
|
+
live: zod.z.boolean().optional(),
|
|
142
|
+
hls: HlsConfigSchema.optional(),
|
|
143
|
+
subtitles: zod.z.array(SubtitleTrackSchema).optional(),
|
|
144
|
+
metadata: MediaMetadataSchema.optional()
|
|
145
|
+
};
|
|
146
|
+
/** 单源对象 */
|
|
147
|
+
const SingleSourceObjectSchema = zod.z.object({
|
|
148
|
+
url: zod.z.string(),
|
|
149
|
+
/**
|
|
150
|
+
* 默认 `auto`(从后缀推断)。
|
|
151
|
+
*
|
|
152
|
+
* ⚠️ 带 query 的 URL(如 `video.m3u8?token=xxx`)推断不出来,**必须显式传 `type`**,
|
|
153
|
+
* 否则会走 MP4 处理路径。签名 URL 场景尤其注意(ADR-022)。
|
|
154
|
+
*/
|
|
155
|
+
type: MediaTypeSchema.optional(),
|
|
156
|
+
...sourceCommonShape
|
|
157
|
+
});
|
|
158
|
+
/**
|
|
159
|
+
* 多源对象。直播 FLV + HLS 双路兜底的标准形态。
|
|
160
|
+
*
|
|
161
|
+
* ⚠️ FLV 单源在 iOS / 微信 / UC / 夸克 下必然抛 `E_MEDIA_NOT_SUPPORTED`,
|
|
162
|
+
* **FLV 永远和 HLS 一起放进 sources 数组**。
|
|
163
|
+
*/
|
|
164
|
+
const MultiSourceObjectSchema = zod.z.object({
|
|
165
|
+
sources: zod.z.array(SourceEntrySchema).min(1),
|
|
166
|
+
...sourceCommonShape
|
|
167
|
+
});
|
|
168
|
+
/**
|
|
169
|
+
* 媒体源。三种写法:URL 字符串(最简)、单源对象、多源对象。
|
|
170
|
+
*
|
|
171
|
+
* **这是 wire schema——只含可 JSON 序列化的字段**。
|
|
172
|
+
* ⛔ **`source.onBeforeRequest` 已废弃(ADR-057),下个 major 删除** ——
|
|
173
|
+
* 它不在这里、也不在任何 Schema 里,而且 player-core 里从来没有消费点(#332)。
|
|
174
|
+
* 认证走签名 URL + 长有效期(ADR-022)。见 {@link SourceRequestHook}。
|
|
175
|
+
*/
|
|
176
|
+
const MediaSourceSchema = zod.z.union([
|
|
177
|
+
zod.z.string(),
|
|
178
|
+
MultiSourceObjectSchema,
|
|
179
|
+
SingleSourceObjectSchema
|
|
180
|
+
]);
|
|
181
|
+
/** i18n 配置 */
|
|
182
|
+
const LocaleConfigSchema = zod.z.union([zod.z.string(), zod.z.object({
|
|
183
|
+
locale: zod.z.string(),
|
|
184
|
+
fallbackLocales: zod.z.array(zod.z.string()).optional(),
|
|
185
|
+
/**
|
|
186
|
+
* 翻译表。**SDK 不内置任何翻译,只提供注入机制** —— 覆盖层文案
|
|
187
|
+
*(loading / error.* / retry / close)全部由消费方从这里传入。
|
|
188
|
+
* 消费面用 `resolveLocaleMessages` 把它解析成扁平表,再把解析好的**字符串**
|
|
189
|
+
* 传给覆盖层元素;缺 key 时原样返回 key(可见失败,不抛错)。
|
|
190
|
+
*
|
|
191
|
+
* **两个命名空间,同一张表**:
|
|
192
|
+
* - 覆盖层:`loading` / `retry` / `close` / `error.<CODE>` —— **无内置翻译**
|
|
193
|
+
* - 播放内核自带控件:`controls.*` —— 内置中 / 英,你传的会**覆盖**(#208 / ADR-051)
|
|
194
|
+
*
|
|
195
|
+
* 语言标签本身仍只有中 / 英两档(`zh-*` → 中文,其余 → 英文,见 player-core 的 `toXgLang`),
|
|
196
|
+
* 但**那不再限制能翻成什么语言**:传 `locale:'vi-VN'` + `messages['vi-VN']['controls.play']`,
|
|
197
|
+
* 控件就是越南语 —— 标签落在 `en` 那一档,而那一档的文案被换掉了。
|
|
198
|
+
* `controls.*` 的 key 全集见 USER-GUIDE § 9.4;**表外的静默忽略**。
|
|
199
|
+
*
|
|
200
|
+
* ⚠️ 这段从前写着「控件不读 messages」—— 那是 #208 修掉的现状,别照着旧描述下结论。
|
|
201
|
+
*/
|
|
202
|
+
messages: zod.z.record(zod.z.string(), zod.z.record(zod.z.string(), zod.z.string())).optional()
|
|
203
|
+
})]);
|
|
204
|
+
/**
|
|
205
|
+
* 弹幕单条(streaming push 用)。见 ADR-028 / ARCHITECTURE § 8.3.7。
|
|
206
|
+
*
|
|
207
|
+
* 直播场景:业务从自己的数据源(WebSocket / 轮询,SDK 不参与)拿到一条,
|
|
208
|
+
* 经 `pushDanmaku` 命令喂进来,到点即显(不绑视频时间)。
|
|
209
|
+
*/
|
|
210
|
+
const DanmakuItemSchema = zod.z.object({
|
|
211
|
+
/** 唯一 id(去重 / 更新用) */
|
|
212
|
+
id: zod.z.string(),
|
|
213
|
+
text: zod.z.string(),
|
|
214
|
+
/** 滚动 / 顶部固定 / 底部固定;默认 scroll */
|
|
215
|
+
type: zod.z.enum([
|
|
216
|
+
"scroll",
|
|
217
|
+
"top",
|
|
218
|
+
"bottom"
|
|
219
|
+
]).optional(),
|
|
220
|
+
/** hex / rgb 颜色;默认白 */
|
|
221
|
+
color: zod.z.string().optional(),
|
|
222
|
+
/**
|
|
223
|
+
* 这条弹幕出现的时间点,**毫秒**,视频时间轴(#275)。
|
|
224
|
+
*
|
|
225
|
+
* **不给 = 立刻出现** —— 所以 `pushDanmaku` 的现有行为一个字节都不变。
|
|
226
|
+
* 只在 `mode: 'preload'` 下有调度意义:引擎按它排期,seek 时自己重排(实测:
|
|
227
|
+
* seek 回 0 后整池重放,已放过的标记被清掉 —— 见 `danmaku-preload.spec.ts`)。
|
|
228
|
+
*
|
|
229
|
+
* ⚠️ **精度是 ±1 秒,不是精确到点。** 渲染引擎按区间匹配而非到点触发,
|
|
230
|
+
* `start` 给的是**目标时刻**,实际出现可能提前或延后至多 1 秒。这是引擎行为,
|
|
231
|
+
* SDK 已把窗口从它的默认 2 秒收到 1 秒(`DANMAKU_PRELOAD_WINDOW_MS`,那里写了为什么
|
|
232
|
+
* 不能更小)。**要逐帧对齐的字幕型需求请用字幕轨(ADR-027),不要用弹幕。**
|
|
233
|
+
*
|
|
234
|
+
* ⚠️ **单位是毫秒,而契约里 `seek` / `startTime` / `currentTime` 都是秒。**
|
|
235
|
+
* 这处不一致是**故意的**:这个字段直连 danmu.js 的同名同义字段,
|
|
236
|
+
* 中间不做换算就不会有换算错误。代价是消费方要记住它和别的时间字段单位不同 ——
|
|
237
|
+
* **这是真的代价,不是「其实没关系」**(见 `archive/docs/specs/danmaku-preload-mode.md`)。
|
|
238
|
+
*/
|
|
239
|
+
start: zod.z.number().nonnegative().optional()
|
|
240
|
+
});
|
|
241
|
+
/**
|
|
242
|
+
* 弹幕配置(PlayerConfig.danmaku,默认关闭)。见 ADR-028 / ARCHITECTURE § 8.3.7。
|
|
243
|
+
*
|
|
244
|
+
* **两种模式**:`streaming`(直播,`pushDanmaku` 逐条)/ `preload`(点播,挂载时整包给,
|
|
245
|
+
* 引擎按每条的 `start` 排期,见 #275)。ADR-028 当初只做 streaming,preload 是它说的「后续 minor」。
|
|
246
|
+
* **弹幕渲染是 SDK 技术**(xgplayer Danmu 引擎:轨道调度 / 防重叠);**数据源 / 发送框 / 过滤归团队层**
|
|
247
|
+
* (ADR-025,同字幕菜单)——SDK 关掉 xgplayer 内置弹幕按钮/面板。
|
|
248
|
+
*/
|
|
249
|
+
const DanmakuConfigSchema = zod.z.object({
|
|
250
|
+
enabled: zod.z.boolean(),
|
|
251
|
+
/** preload=一次性(点播,配合 `items`);streaming=pushDanmaku 逐条(直播)。默认 streaming */
|
|
252
|
+
mode: zod.z.enum(["preload", "streaming"]).optional(),
|
|
253
|
+
/**
|
|
254
|
+
* 预载弹幕池(**仅 `mode: 'preload'`**)。挂载时整包交给渲染引擎,
|
|
255
|
+
* 由引擎按每条的 `start` 排期、seek 时重排 —— **SDK 不自己写调度器**
|
|
256
|
+
* (写了就得自己处理 seek / 倍速 / 轨道冲突,那是渲染引擎的本职)。
|
|
257
|
+
*
|
|
258
|
+
* 想在运行时**换掉整包**目前不支持:那需要新增一个契约方法,要在五种接入方式上
|
|
259
|
+
* 各实现一遍并永久维护。等有「一万条首屏太慢」这类实测需求再加,那是向后兼容的 minor。
|
|
260
|
+
*/
|
|
261
|
+
items: zod.z.array(DanmakuItemSchema).optional(),
|
|
262
|
+
/** 显示配置(透传给渲染引擎) */
|
|
263
|
+
display: zod.z.object({
|
|
264
|
+
/** 显示区域高度:顶部 / 半屏 / 全屏 */
|
|
265
|
+
area: zod.z.enum([
|
|
266
|
+
"top",
|
|
267
|
+
"half",
|
|
268
|
+
"full"
|
|
269
|
+
]).optional(),
|
|
270
|
+
opacity: zod.z.number().min(0).max(1).optional(),
|
|
271
|
+
/** 速度倍率 */
|
|
272
|
+
speed: zod.z.number().min(.5).max(2).optional(),
|
|
273
|
+
fontSize: zod.z.number().min(12).max(48).optional(),
|
|
274
|
+
/**
|
|
275
|
+
* 最大轨道数(同时最多几行弹幕)。**与 `area` 互斥**,同传整份配置被拒。
|
|
276
|
+
*
|
|
277
|
+
* **是「最多」不是「精确」**:设得比播放器高度装得下的还多时,SDK 会把它钳到
|
|
278
|
+
* 容器装得下的条数 —— 否则多出来的轨道整个落在可视区外,被分配到那些轨道上的
|
|
279
|
+
* 弹幕会**静默看不见**(渲染引擎只有「精确 N 条」和「按高度算」两种模式,
|
|
280
|
+
* 「取较小」得靠 SDK 在运行时补,见 player-core 的 `attachDanmakuLineClamp`)。
|
|
281
|
+
*
|
|
282
|
+
* 钳制跟随播放器尺寸变化(全屏 / 容器 resize)。
|
|
283
|
+
*/
|
|
284
|
+
maxLines: zod.z.number().int().positive().optional()
|
|
285
|
+
}).optional()
|
|
286
|
+
}).superRefine((cfg, ctx) => {
|
|
287
|
+
if (cfg.items && cfg.items.length > 0 && cfg.mode !== "preload") ctx.addIssue({
|
|
288
|
+
code: "custom",
|
|
289
|
+
path: ["items"],
|
|
290
|
+
message: `danmaku.items 只在 mode: 'preload' 下有效。当前 mode 是 ${cfg.mode ?? "streaming"}(默认)—— 渲染引擎在直播模式下会忽略每条的 start,整包会一股脑全飘出来。要么设 mode: "preload",要么改用 pushDanmaku 逐条推。`
|
|
291
|
+
});
|
|
292
|
+
if (cfg.display?.maxLines !== void 0 && cfg.display.area && cfg.display.area !== "full") ctx.addIssue({
|
|
293
|
+
code: "custom",
|
|
294
|
+
path: ["display", "maxLines"],
|
|
295
|
+
message: `danmaku.display.maxLines 与 area: '${cfg.display.area}' 不能同时用 —— 渲染引擎里两者互斥(给了轨道条数,区域比例就失效),同传只会有一个生效。要限制条数就去掉 area(或用 area: 'full'),要限制区域就去掉 maxLines。`
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
/**
|
|
299
|
+
* 场景预设。预设只是一组默认值,消费方传的单个字段永远覆盖预设
|
|
300
|
+
* (见 {@link resolvePreset})。预设内容见 `presets.ts`。
|
|
301
|
+
*
|
|
302
|
+
* 定义在这里而不是 presets.ts,是为了让 PlayerConfig 能引用它而不产生循环依赖。
|
|
303
|
+
*/
|
|
304
|
+
const PresetNameSchema = zod.z.enum(["homepage-preview"]);
|
|
305
|
+
/**
|
|
306
|
+
* 播放器初始化配置。iframe 模式下握手成功后作为第一条 command 下发,
|
|
307
|
+
* 所以必须整体可 JSON 序列化。
|
|
308
|
+
*
|
|
309
|
+
* ### 曾经存在、已删除的字段(别重新发明)
|
|
310
|
+
*
|
|
311
|
+
* 都是「声明了但全仓没人读」的幽灵字段,判据见 ADR-039。zod 默认 `strip`,
|
|
312
|
+
* 消费方继续传不会报错也不会生效,TS 侧会编译失败 —— 这是期望的:
|
|
313
|
+
* 让「传了没用」从静默失效变成显式失败。
|
|
314
|
+
*
|
|
315
|
+
* | 字段 | 删于 | 为什么 |
|
|
316
|
+
* |---|---|---|
|
|
317
|
+
* | `telemetry`(sessionId / userId / tags / mode) | ADR-037 | 零消费点。埋点由消费方监听契约事件自行上报,SDK 不代发(也发不了 —— 红线禁 HTTP 库)。**替代形态在输出侧,不是把这个字段加回来** —— 见下 |
|
|
318
|
+
* | `crossOrigin` | ADR-037 | 零消费点,且语义与 CORS / 签名 URL(ADR-022)/ CDN 响应头三者耦合,接线前得先想清楚 |
|
|
319
|
+
* | `consent`(analytics / thirdPartyCookies / personalization) | ADR-040 | 零消费点。`analytics` 曾门控 `stalled` 的 emit,但那个门控保护不了隐私(数据从没离开浏览器),只掐死 UI,已按 ADR-026「实现更正」移除;另两个子字段从来就没被读过。**合规过滤属于消费方的上报层** |
|
|
320
|
+
* | `controls.minimal`(连同 `ControlsConfig` / `ControlsConfigSchema`) | ADR-045 | 零消费点 —— 传 `{minimal:true}` 与传 `{}` 效果完全一致(实现只看 `controls !== false`)。声明写的「遥控器 / TV 场景」在 v1.0 范围外,无任何需求在等它。它是那个 schema 唯一的字段,`controls` 一并从 union 收窄为纯布尔 |
|
|
321
|
+
* | `source.poster`(单源 / 多源对象共有) | ADR-049 | 零消费点。`source-normalize` 认真地把它搬进 `NormalizedSource.poster`,而**全仓没有任何一处读那个属性** —— 从来没生效过。**顶层 `poster` 覆盖同一能力且真的接到了 xgplayer**,它也表达不了「每个候选源一张封面」(它挂在 source 对象上,不在 `SourceEntry` 上)。封面要跟着 `load()` 换,消费方改 `poster` prop 即可 |
|
|
322
|
+
*/
|
|
323
|
+
/**
|
|
324
|
+
* ⚠️ **别把 `telemetry` 加回来 —— 它的替代形态在输出侧**(ADR-074)。
|
|
325
|
+
*
|
|
326
|
+
* 上表删 `telemetry` 的论据是「纯粹的透传字段:SDK 收下来什么也不做」,那条论据
|
|
327
|
+
* **仍然成立且永久成立**。ADR-074 加的 {@link PlaybackContext} 方向相反:
|
|
328
|
+
* 它是 SDK **交出去**的、而且**只有 SDK 知道**的那几格(实际选中的内核 / 选中的候选源 /
|
|
329
|
+
* 出错位置 / 当前档位 / 会话 id)。
|
|
330
|
+
*
|
|
331
|
+
* 判据是「消费方能不能在 SDK 外面自己拿到」。`userId` / `tags` 拿得到,所以**永久不进**;
|
|
332
|
+
* 想加请先推翻 ADR-037,不要引用 ADR-074 当依据。
|
|
333
|
+
*/
|
|
334
|
+
const PlayerConfigSchema = zod.z.object({
|
|
335
|
+
source: MediaSourceSchema,
|
|
336
|
+
/** 场景预设。会被同名的显式字段覆盖 */
|
|
337
|
+
preset: PresetNameSchema.optional(),
|
|
338
|
+
autoplay: zod.z.boolean().optional(),
|
|
339
|
+
muted: zod.z.boolean().optional(),
|
|
340
|
+
loop: zod.z.boolean().optional(),
|
|
341
|
+
/** 初始倍速。运行时改倍速走 `setPlaybackRate` 命令 */
|
|
342
|
+
playbackRate: zod.z.number().min(.25).max(4).optional(),
|
|
343
|
+
volume: zod.z.number().min(0).max(1).optional(),
|
|
344
|
+
playsinline: zod.z.boolean().optional(),
|
|
345
|
+
preload: zod.z.enum([
|
|
346
|
+
"none",
|
|
347
|
+
"metadata",
|
|
348
|
+
"auto"
|
|
349
|
+
]).optional(),
|
|
350
|
+
startTime: zod.z.number().min(0).optional(),
|
|
351
|
+
/**
|
|
352
|
+
* 显示/隐藏 xgplayer 自带控件。**只有布尔**。
|
|
353
|
+
*
|
|
354
|
+
* 曾经是 `boolean | { minimal?: boolean }`,但 `minimal` 零消费点(传了和没传
|
|
355
|
+
* 完全一样),已按 ADR-045 删除;它是那个对象里唯一的字段,只剩空对象的 union
|
|
356
|
+
* 分支不携带任何信息,一并收窄。将来真要做 TV / 遥控器,重新放宽成
|
|
357
|
+
* `boolean | { minimal?: boolean }` 是 **minor 不是 breaking**,不是单向门。
|
|
358
|
+
*/
|
|
359
|
+
controls: zod.z.boolean().optional(),
|
|
360
|
+
/** 是否响应用户交互。首页预览卡片用 false,点击透传给外层卡片 */
|
|
361
|
+
interactive: zod.z.boolean().optional(),
|
|
362
|
+
poster: PosterConfigSchema.optional(),
|
|
363
|
+
/** 暂停时盖在画面中央的静态图。渲染在 iframe 内(与封面相反),见 ADR-043 */
|
|
364
|
+
pauseImage: PauseImageConfigSchema.optional(),
|
|
365
|
+
locale: LocaleConfigSchema.optional(),
|
|
366
|
+
/** 弹幕配置(默认关闭,ADR-028)。渲染归 SDK,数据源/发送/过滤归团队层 */
|
|
367
|
+
danmaku: DanmakuConfigSchema.optional(),
|
|
368
|
+
/**
|
|
369
|
+
* 打开 **iframe 通信层**日志(Penpal 的握手与消息收发)。
|
|
370
|
+
*
|
|
371
|
+
* 透传给 penpal 的 `debug` 选项,gate 的是它自己的 `console.log('[Penpal]', …)`。
|
|
372
|
+
* 排查「握手超时 / 命令不达 / 事件丢失」时打开,能看到 SYN / SYN-ACK / ACK 的实际往返。
|
|
373
|
+
*
|
|
374
|
+
* ⚠️ **inline 模式无作用** —— 那条路没有 iframe、没有 Penpal。
|
|
375
|
+
* xgplayer 3.0.26 的 `defaultConfig` 也没有任何 debug / logLevel 字段可映射,
|
|
376
|
+
* 按 ADR-029「无真实需求驱动,不投机造能力」不为 inline 硬造一套日志。
|
|
377
|
+
*
|
|
378
|
+
* 实现细节:host 侧(frame-core)读到它后,除了给 `connectToChild` 开 debug,
|
|
379
|
+
* 还会在 iframe URL 上追加 `?debug=1` —— 因为本字段是**经 Penpal 连接**送进 iframe 的,
|
|
380
|
+
* 等它到达时连接早已建好,没法再回头配置那条连接。
|
|
381
|
+
*/
|
|
382
|
+
debug: zod.z.boolean().optional()
|
|
383
|
+
});
|
|
384
|
+
//#endregion
|
|
385
|
+
//#region src/errors.ts
|
|
386
|
+
/**
|
|
387
|
+
* 错误分类。player-core 把 xgplayer 的内部错误映射到这几类,
|
|
388
|
+
* 映射规则见 `packages/protocol/CLAUDE.md § xgplayer 错误 → PlayerError 映射规则`。
|
|
389
|
+
*/
|
|
390
|
+
const ErrorCategorySchema = zod.z.enum([
|
|
391
|
+
"manifest",
|
|
392
|
+
"media",
|
|
393
|
+
"network",
|
|
394
|
+
"auth",
|
|
395
|
+
"env",
|
|
396
|
+
"autoplay"
|
|
397
|
+
]);
|
|
398
|
+
/**
|
|
399
|
+
* 全部错误码。命名规则 `E_<CATEGORY>_<SPECIFIC>`。
|
|
400
|
+
* 权威来源:ARCHITECTURE.md § 8.6。
|
|
401
|
+
*
|
|
402
|
+
* **每一个码都必须有真实发射点**(ADR-034)。`player-core/tests/contract-coverage.test.ts`
|
|
403
|
+
* 静态扫描全仓库生产代码强制这条 —— 加码不加实现,测试直接红。
|
|
404
|
+
*
|
|
405
|
+
* 原则:**能删不留**。一个我们暂时检测不到的错误码,留在契约里是谎言;
|
|
406
|
+
* 而删掉之后将来能检测了再加回来,是向后兼容的 minor,代价极低。
|
|
407
|
+
*/
|
|
408
|
+
const ErrorCodeSchema = zod.z.enum([
|
|
409
|
+
"E_MANIFEST_PARSE",
|
|
410
|
+
"E_MEDIA_DECODE",
|
|
411
|
+
"E_MEDIA_ABORTED",
|
|
412
|
+
"E_MEDIA_NOT_SUPPORTED",
|
|
413
|
+
"E_SUBTITLE_LOAD_FAILED",
|
|
414
|
+
"E_AUTOPLAY_BLOCKED",
|
|
415
|
+
"E_NETWORK",
|
|
416
|
+
"E_NETWORK_TIMEOUT",
|
|
417
|
+
"E_AUTH_EXPIRED",
|
|
418
|
+
"E_ENV_CSP_BLOCKED",
|
|
419
|
+
"E_METHOD_NOT_SUPPORTED",
|
|
420
|
+
"E_DANMAKU_SEND_FAILED",
|
|
421
|
+
"E_INTERNAL",
|
|
422
|
+
"E_UNKNOWN",
|
|
423
|
+
"E_PLAYER_DESTROYED",
|
|
424
|
+
"E_HANDSHAKE_TIMEOUT",
|
|
425
|
+
"E_HANDSHAKE_VERSION_MISMATCH",
|
|
426
|
+
"E_LOAD_FAILED",
|
|
427
|
+
"E_FRAME_CRASHED"
|
|
428
|
+
]);
|
|
429
|
+
/**
|
|
430
|
+
* 警告码。命名规则 `W_<CATEGORY>_<SPECIFIC>`。
|
|
431
|
+
* 警告不中断播放,只通过 `compatwarning` 事件上抛给消费方。
|
|
432
|
+
*/
|
|
433
|
+
const WarningCodeSchema = zod.z.enum([
|
|
434
|
+
"W_BROWSER_INCOMPATIBLE",
|
|
435
|
+
"W_VERSION_MISMATCH",
|
|
436
|
+
"W_EVENT_REJECTED"
|
|
437
|
+
]);
|
|
438
|
+
/**
|
|
439
|
+
* 错误码 → 元数据。**唯一真相**:player-core 的 error-mapping、
|
|
440
|
+
* frame-core 的 fallback、消费方的重试 UI 全部读这张表,不要各自硬编码。
|
|
441
|
+
*/
|
|
442
|
+
const ERROR_META = {
|
|
443
|
+
E_MANIFEST_PARSE: {
|
|
444
|
+
category: "manifest",
|
|
445
|
+
retryable: false
|
|
446
|
+
},
|
|
447
|
+
E_MEDIA_DECODE: {
|
|
448
|
+
category: "media",
|
|
449
|
+
retryable: true
|
|
450
|
+
},
|
|
451
|
+
E_MEDIA_ABORTED: {
|
|
452
|
+
category: "media",
|
|
453
|
+
retryable: true
|
|
454
|
+
},
|
|
455
|
+
E_MEDIA_NOT_SUPPORTED: {
|
|
456
|
+
category: "media",
|
|
457
|
+
retryable: false
|
|
458
|
+
},
|
|
459
|
+
E_SUBTITLE_LOAD_FAILED: {
|
|
460
|
+
category: "media",
|
|
461
|
+
retryable: true
|
|
462
|
+
},
|
|
463
|
+
E_AUTOPLAY_BLOCKED: {
|
|
464
|
+
category: "autoplay",
|
|
465
|
+
retryable: false
|
|
466
|
+
},
|
|
467
|
+
E_NETWORK: {
|
|
468
|
+
category: "network",
|
|
469
|
+
retryable: true
|
|
470
|
+
},
|
|
471
|
+
E_NETWORK_TIMEOUT: {
|
|
472
|
+
category: "network",
|
|
473
|
+
retryable: true
|
|
474
|
+
},
|
|
475
|
+
E_AUTH_EXPIRED: {
|
|
476
|
+
category: "auth",
|
|
477
|
+
retryable: false
|
|
478
|
+
},
|
|
479
|
+
E_ENV_CSP_BLOCKED: {
|
|
480
|
+
category: "env",
|
|
481
|
+
retryable: false
|
|
482
|
+
},
|
|
483
|
+
E_METHOD_NOT_SUPPORTED: {
|
|
484
|
+
category: "env",
|
|
485
|
+
retryable: false
|
|
486
|
+
},
|
|
487
|
+
E_DANMAKU_SEND_FAILED: {
|
|
488
|
+
category: "env",
|
|
489
|
+
retryable: true
|
|
490
|
+
},
|
|
491
|
+
E_INTERNAL: {
|
|
492
|
+
category: "env",
|
|
493
|
+
retryable: false
|
|
494
|
+
},
|
|
495
|
+
E_UNKNOWN: {
|
|
496
|
+
category: "env",
|
|
497
|
+
retryable: false
|
|
498
|
+
},
|
|
499
|
+
E_PLAYER_DESTROYED: {
|
|
500
|
+
category: "env",
|
|
501
|
+
retryable: false
|
|
502
|
+
},
|
|
503
|
+
E_HANDSHAKE_TIMEOUT: {
|
|
504
|
+
category: "network",
|
|
505
|
+
retryable: true
|
|
506
|
+
},
|
|
507
|
+
E_HANDSHAKE_VERSION_MISMATCH: {
|
|
508
|
+
category: "env",
|
|
509
|
+
retryable: false
|
|
510
|
+
},
|
|
511
|
+
E_LOAD_FAILED: {
|
|
512
|
+
category: "network",
|
|
513
|
+
retryable: true
|
|
514
|
+
},
|
|
515
|
+
E_FRAME_CRASHED: {
|
|
516
|
+
category: "env",
|
|
517
|
+
retryable: false
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
/**
|
|
521
|
+
* `PlayerError.cause` 的形状。**只用于 debug**,不要在业务逻辑里依赖它 ——
|
|
522
|
+
* 业务判断用 `code` / `category` / `retryable`。
|
|
523
|
+
*
|
|
524
|
+
* **为什么是固定结构而不是 `unknown`(#270)**:错误对象要跨 iframe 传输,
|
|
525
|
+
* 而 `postMessage` 用的是**结构化克隆**。塞进来的原始错误常常挂着 DOM 对象
|
|
526
|
+
* (xgplayer 的错误就带着 `MediaError`),结构化克隆遇到它**直接抛 `DataCloneError`**,
|
|
527
|
+
* 整条 envelope 发不出去 —— 消费方拿不到真正的错误码。
|
|
528
|
+
*
|
|
529
|
+
* ⚠️ **这里曾经写着「必须可 JSON 序列化」,规矩是对的,前提是错的**:
|
|
530
|
+
* `JSON.stringify` 遇到不可序列化的东西**静默丢弃**,`structuredClone` **抛异常**,
|
|
531
|
+
* 两者对同一件事的处理正好相反。契约按 JSON 语义写、传输按克隆语义跑,于是没人执行。
|
|
532
|
+
* 现在字段全是原始类型,**结构化克隆不可能再抛**。
|
|
533
|
+
*
|
|
534
|
+
* **不带 `stack`**:跨 iframe 的 stack 指向 iframe 内部的 bundle 文件,宿主侧看到的是
|
|
535
|
+
* 一串陌生路径,排查价值低而体积不小(embed-app 的全局上报已用 `source`/`lineno` 提供等价信息)。
|
|
536
|
+
*/
|
|
537
|
+
const ErrorCauseSchema = zod.z.object({
|
|
538
|
+
/** 原始错误的 `name`;取不到时是 `'Unknown'` */
|
|
539
|
+
name: zod.z.string(),
|
|
540
|
+
message: zod.z.string(),
|
|
541
|
+
/** `MediaError.code`(1–4)等结构化码 —— 错误分类本来就是靠它做的,排查时缺它答不了「decode 还是 src_not_supported」 */
|
|
542
|
+
code: zod.z.number().optional(),
|
|
543
|
+
/** HTTP 状态码,区分 401 / 403 用 */
|
|
544
|
+
status: zod.z.number().optional()
|
|
545
|
+
}).catchall(zod.z.union([
|
|
546
|
+
zod.z.string(),
|
|
547
|
+
zod.z.number(),
|
|
548
|
+
zod.z.boolean()
|
|
549
|
+
]));
|
|
550
|
+
/** `String(x)` 的截断上限 —— cause 是旁路信息,不该把 envelope 撑大 */
|
|
551
|
+
const CAUSE_MESSAGE_MAX = 500;
|
|
552
|
+
function readNumber(source, key) {
|
|
553
|
+
const v = source[key];
|
|
554
|
+
return typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* 把任意 `cause` 归一成 wire-safe 的 {@link ErrorCause}。
|
|
558
|
+
*
|
|
559
|
+
* **幂等**:已经是这个形状的再过一遍还是它自己 —— 靠的是下面这段通用读取本身,
|
|
560
|
+
* 不是靠一条「已经合规就原样返回」的快路径。**那条快路径试过,是错的**:
|
|
561
|
+
* 它会把已经合规的对象整个放行,于是 `message` 的截断对它不生效
|
|
562
|
+
* ——而 `new Error('x'.repeat(5000))` 恰好就能通过 schema 校验(`name`/`message`
|
|
563
|
+
* 在 Error 的原型链上,zod 读得到),5000 字符原样进了 envelope。
|
|
564
|
+
* `makePlayerError` 会被嵌套调用(frame-core 解包出 playerError 再重包),幂等是必须的,
|
|
565
|
+
* 但它得由「每次都真的走一遍归一」来保证,不是由「认出来就跳过」。
|
|
566
|
+
*
|
|
567
|
+
* 取值优先级刻意和 `player-core/src/error-mapping.ts` 的分类逻辑对齐:
|
|
568
|
+
* 那边靠 `mediaError.code` 分类,这边就把同一个 code 留下来,否则排查时人要去猜。
|
|
569
|
+
*/
|
|
570
|
+
function serializeCause(cause) {
|
|
571
|
+
if (cause === null || typeof cause !== "object") return {
|
|
572
|
+
name: "Unknown",
|
|
573
|
+
message: String(cause).slice(0, CAUSE_MESSAGE_MAX)
|
|
574
|
+
};
|
|
575
|
+
const obj = cause;
|
|
576
|
+
const media = obj.mediaError ?? void 0;
|
|
577
|
+
const name = typeof obj.name === "string" ? obj.name : media ? "MediaError" : typeof obj.errorType === "string" ? obj.errorType : "Unknown";
|
|
578
|
+
const rawMessage = typeof obj.message === "string" ? obj.message : typeof media?.message === "string" ? media.message : String(cause);
|
|
579
|
+
const code = readNumber(obj, "code") ?? (media ? readNumber(media, "code") : void 0);
|
|
580
|
+
const status = readNumber(obj, "status") ?? readNumber(obj, "httpCode");
|
|
581
|
+
const extras = {};
|
|
582
|
+
for (const [k, v] of Object.entries(obj)) if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") extras[k] = v;
|
|
583
|
+
return {
|
|
584
|
+
...extras,
|
|
585
|
+
name,
|
|
586
|
+
message: rawMessage.slice(0, CAUSE_MESSAGE_MAX),
|
|
587
|
+
...code === void 0 ? {} : { code },
|
|
588
|
+
...status === void 0 ? {} : { status }
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* 错误对象。**跨 iframe 传输,所以每个字段都必须能过结构化克隆** ——
|
|
593
|
+
* `cause` 由 {@link makePlayerError} 统一归一成 {@link ErrorCause},见 #270。
|
|
594
|
+
*/
|
|
595
|
+
const PlayerErrorSchema = zod.z.object({
|
|
596
|
+
code: ErrorCodeSchema,
|
|
597
|
+
message: zod.z.string(),
|
|
598
|
+
retryable: zod.z.boolean(),
|
|
599
|
+
category: ErrorCategorySchema,
|
|
600
|
+
cause: ErrorCauseSchema.optional()
|
|
601
|
+
});
|
|
602
|
+
/**
|
|
603
|
+
* 构造 PlayerError。category 和 retryable 一律从 {@link ERROR_META} 推出来,
|
|
604
|
+
* 不接受调用方传入——避免同一个 code 在不同包里被标成不同的 retryable。
|
|
605
|
+
*
|
|
606
|
+
* @example
|
|
607
|
+
* makePlayerError('E_NETWORK', '拉流失败', originalError)
|
|
608
|
+
* // → { code: 'E_NETWORK', message: '拉流失败', category: 'network', retryable: true, cause: ... }
|
|
609
|
+
*/
|
|
610
|
+
/**
|
|
611
|
+
* `E_PLAYER_DESTROYED` 的统一文案。
|
|
612
|
+
*
|
|
613
|
+
* **三个包各自发射它**(`frame-core` 的 `send()` 闸、`react` / `vue` 的 prop effect)——
|
|
614
|
+
* 文案分三份写就是三份会漂的副本,而这条需求的标题恰恰是「五面说同一句话」
|
|
615
|
+
*(`archive/docs/specs/destroyed-player-command-signal.md`)。
|
|
616
|
+
*
|
|
617
|
+
* 契约只钉 `code`,不钉 `message`;放在这里是**为了不漂**,不是把文案升级成契约。
|
|
618
|
+
*/
|
|
619
|
+
const PLAYER_DESTROYED_MESSAGE = "播放器已销毁,这条命令没有生效 —— 重挂组件才能继续";
|
|
620
|
+
function makePlayerError(code, message, cause) {
|
|
621
|
+
const meta = ERROR_META[code];
|
|
622
|
+
return {
|
|
623
|
+
code,
|
|
624
|
+
message,
|
|
625
|
+
category: meta.category,
|
|
626
|
+
retryable: meta.retryable,
|
|
627
|
+
...cause === void 0 ? {} : { cause: serializeCause(cause) }
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
//#endregion
|
|
631
|
+
//#region src/observability.ts
|
|
632
|
+
/**
|
|
633
|
+
* 观测信号(ADR-075 · #490)。**xgplayer 的 `DefaultPreset` 一直在算,而 `player-core`
|
|
634
|
+
* 从来没读过** —— `Stats` / `XGLogger` / `FpsDetect` 三个插件每次播放都在工作,
|
|
635
|
+
* 而 `player-core/src` 对它们的引用数是 0。
|
|
636
|
+
*
|
|
637
|
+
* ─── 为什么归一函数住这里,而不是各消费面各写一份 ───
|
|
638
|
+
*
|
|
639
|
+
* 上游 payload **带原始 DOM 对象**(`player.js:1449` 的 `emitUserAction` 把整个
|
|
640
|
+
* `event` 塞进去)。它过不了 iframe 的 `structuredClone`,Zod 也接不住 ——
|
|
641
|
+
* 照搬会让 **iframe 两条通道当场炸而 inline 两条不炸**,那才是真正的 parity 事故。
|
|
642
|
+
*
|
|
643
|
+
* 所以归一是**纯函数**,四个消费面共用一份。形状逐字沿用 ADR-074 的
|
|
644
|
+
* `redactSourceUrl()`:同样是「上游给的东西不能直接进契约」。
|
|
645
|
+
*
|
|
646
|
+
* ⚠️ **本模块只收上游真的会发的信号。** 原计划的第四条 `bandwidth`
|
|
647
|
+
* (`DOWNLOAD_SPEED_CHANGE`)**被查掉了** —— `TestSpeed` 的 `defaultConfig` 是
|
|
648
|
+
* `openSpeed: false` + `url: ''`,`afterCreate` 直接 return,而剩下唯一入口
|
|
649
|
+
* `real_time_speed` **全 node_modules 没有人发**。进契约就是第二个
|
|
650
|
+
* 「只在注释里存在的 `healthreport`」(#391 抓到的那种)。
|
|
651
|
+
*
|
|
652
|
+
* @see docs/adr/ADR-075-preset-plugin-attribution.md
|
|
653
|
+
*/
|
|
654
|
+
/**
|
|
655
|
+
* 首帧可见耗时(ADR-075 决策②)。来自 `XGLogger` 的 `xglog` / `type: 'firstFrame'`。
|
|
656
|
+
*
|
|
657
|
+
* ⚠️ **和 `pnpm test:e2e:kpi`(#148)量的不是一件事,两者不可互换。**
|
|
658
|
+
* 那条 KPI 量的是**端到端**(含页面加载 / SDK 初始化),本字段是**播放器内部**口径
|
|
659
|
+
* (从内核开始加载到第一帧可见)。**决定是不统一** —— 统一意味着其中一个要放弃
|
|
660
|
+
* 自己的用途,而两个用途都真实存在。
|
|
661
|
+
*/
|
|
662
|
+
const FirstFramePayloadSchema = zod.z.object({
|
|
663
|
+
/** 首帧可见耗时,毫秒。上游字段名就叫 `fvt`(first video time) */
|
|
664
|
+
fvt: zod.z.number() });
|
|
665
|
+
/**
|
|
666
|
+
* 画面冻结(ADR-075 决策②)。来自 `FpsDetect` 的 `FPS_STUCK`。
|
|
667
|
+
*
|
|
668
|
+
* ⚠️ **它不叫 `framedrop`,因为它不是掉帧率。** 上游的触发判据是「连续
|
|
669
|
+
* `stuckCount`(默认 3)个 tick 解码帧数 ≤ `reportFrame`(默认 0),**且缓冲够、
|
|
670
|
+
* 没暂停、页面没隐藏**」—— 检出的是**画面冻住**。payload 里确实带
|
|
671
|
+
* `droppedVideoFrames`,但那是附带数据,不是触发判据。
|
|
672
|
+
*
|
|
673
|
+
* ⚠️ **和 `stalled` 不重复。** `stalled` 是**缓冲驱动**的等待,而本信号的前提
|
|
674
|
+
* 恰恰是**缓冲是够的** —— 缓冲够却出不了新帧,指向的是解码侧而不是网络侧。
|
|
675
|
+
*
|
|
676
|
+
* ⚠️ **仅 PC。** `FpsDetect` 在 `presets/default.js` 的 `case 'pc'` 分支才装载,
|
|
677
|
+
* 手机上**结构性不触发**。这不违反 cross-mode-parity(ADR-012)—— 五种接入方式在
|
|
678
|
+
* 同一台设备上表现一致,差异沿的是**平台轴**不是模式轴。但形状正是 ADR-065 那个
|
|
679
|
+
* 陷阱(「这条通道上没有」被读成「这个东西没用」),所以登记在 `upstream-gap-registry`。
|
|
680
|
+
*/
|
|
681
|
+
const FrameFreezePayloadSchema = zod.z.object({
|
|
682
|
+
/** 冻住了多久(毫秒)= 各次采样 `checkInterval` 之和 */
|
|
683
|
+
durationMs: zod.z.number(),
|
|
684
|
+
/** 累计丢帧数(`droppedVideoFrames`,取最后一次采样)。**它不是本事件的触发原因** */
|
|
685
|
+
droppedFrames: zod.z.number(),
|
|
686
|
+
/** 累计解码帧数(`totalVideoFrames`,取最后一次采样) */
|
|
687
|
+
totalFrames: zod.z.number()
|
|
688
|
+
});
|
|
689
|
+
/**
|
|
690
|
+
* 进契约的用户动作白名单(ADR-075 决策③)。
|
|
691
|
+
*
|
|
692
|
+
* ─── 判据是 ADR-039 的「零消费」那一条 ───
|
|
693
|
+
*
|
|
694
|
+
* **消费方在 SDK 外面自己拿不到的才进。** 按这条:
|
|
695
|
+
*
|
|
696
|
+
* - **进**:发生在 SDK 自己控件上的动作。契约里虽然有 `play` / `pause` /
|
|
697
|
+
* `volumechange` / `seeking` / `qualitychange`,但它们**回答不了「是谁发起的」**
|
|
698
|
+
* —— 用户点的还是代码调的,而上报侧要区分的正是这个。
|
|
699
|
+
* - **不进**:`click` / `dragstart` / `dragend` / `fragment_focus` —— 这些是进度条上的
|
|
700
|
+
* **指针级交互**,消费方在自己的容器上监听就有;而且拖动本身还会再发一条 `seek`,
|
|
701
|
+
* 收进来等于同一个动作记两遍。
|
|
702
|
+
*
|
|
703
|
+
* ⚠️ **`switch_cssfullscreen` 和 `switch_css_fullscreen` 两个都要。** 上游自己就有两种拼法
|
|
704
|
+
* (`cssFullScreen` 插件用前者,`keyboard` 插件用后者),**这不是笔误** ——
|
|
705
|
+
* 漏掉一个的表现是「用快捷键切网页全屏收不到事件,点按钮能收到」,而那种差异没人会去查。
|
|
706
|
+
*
|
|
707
|
+
* ⚠️ **护栏保证不了「该进的都进了」。** `check:inventory` 能验「名单里的在上游真实存在」
|
|
708
|
+
* 和「上游删了会红」,但上游**新增**一个内部动作时,这张名单不会自己长出来 ——
|
|
709
|
+
* 那是静默的,已记进 ADR-075 的代价栏。
|
|
710
|
+
*/
|
|
711
|
+
const USER_ACTION_ALLOWLIST = [
|
|
712
|
+
"switch_play_pause",
|
|
713
|
+
"switch_fullscreen",
|
|
714
|
+
"switch_cssfullscreen",
|
|
715
|
+
"switch_css_fullscreen",
|
|
716
|
+
"change_definition",
|
|
717
|
+
"change_rate",
|
|
718
|
+
"change_volume",
|
|
719
|
+
"change_muted",
|
|
720
|
+
"change_pip",
|
|
721
|
+
"seek",
|
|
722
|
+
"rotate",
|
|
723
|
+
"shot",
|
|
724
|
+
"download",
|
|
725
|
+
"switch_danmu",
|
|
726
|
+
"error_retry"
|
|
727
|
+
];
|
|
728
|
+
/**
|
|
729
|
+
* 用户动作(ADR-075 决策③)。来自 `Stats` 收的 `USER_ACTION`,经白名单过滤 + 归一。
|
|
730
|
+
*
|
|
731
|
+
* ⚠️ **可见性依赖 `controls`。** 主要发出方是 xgplayer 自己的控件插件,而本仓
|
|
732
|
+
* `controls: config.controls !== false`(`create-player.ts`)。消费方传
|
|
733
|
+
* `controls: false` 时绝大多数动作不再发出 —— 这是**配置相关的差异**,不是幻影。
|
|
734
|
+
*/
|
|
735
|
+
const UserActionPayloadSchema = zod.z.object({
|
|
736
|
+
/** 白名单里的动作名。见 {@link USER_ACTION_ALLOWLIST} */
|
|
737
|
+
action: zod.z.enum(USER_ACTION_ALLOWLIST),
|
|
738
|
+
/** 哪个插件发起的(上游 `pluginName`);取不到时为 `'player'` */
|
|
739
|
+
source: zod.z.string(),
|
|
740
|
+
/** 变化前的值。**只保留原始类型** —— 见 {@link normalizeUserAction} */
|
|
741
|
+
from: zod.z.union([
|
|
742
|
+
zod.z.string(),
|
|
743
|
+
zod.z.number(),
|
|
744
|
+
zod.z.boolean()
|
|
745
|
+
]).nullable(),
|
|
746
|
+
/** 变化后的值。同 `from` */
|
|
747
|
+
to: zod.z.union([
|
|
748
|
+
zod.z.string(),
|
|
749
|
+
zod.z.number(),
|
|
750
|
+
zod.z.boolean()
|
|
751
|
+
]).nullable()
|
|
752
|
+
});
|
|
753
|
+
const ALLOWED = new Set(USER_ACTION_ALLOWLIST);
|
|
754
|
+
/** 只让原始类型过去。**其它一律 `null`** —— 挡的就是 DOM 对象 */
|
|
755
|
+
function primitive(v) {
|
|
756
|
+
const t = typeof v;
|
|
757
|
+
return t === "string" || t === "number" || t === "boolean" ? v : null;
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* 把上游 `USER_ACTION` 的原始 payload 归一成契约 payload。
|
|
761
|
+
* **不在白名单里的返回 `null`**(调用方据此不发事件)。
|
|
762
|
+
*
|
|
763
|
+
* ─── 这个函数存在的全部理由 ───
|
|
764
|
+
*
|
|
765
|
+
* 上游发出来的东西长这样(`es/player.js:1449`):
|
|
766
|
+
*
|
|
767
|
+
* ```js
|
|
768
|
+
* this.emit(USER_ACTION, { eventType, action, currentTime, duration, ended, event, ...params })
|
|
769
|
+
* ```
|
|
770
|
+
*
|
|
771
|
+
* 那个 `event` 是**原始 DOM 事件对象**。它:
|
|
772
|
+
* - 过不了 iframe 的 `structuredClone`(postMessage 直接抛 DataCloneError)
|
|
773
|
+
* - 过不了 Zod
|
|
774
|
+
* - 而 **inline 两条路不过这两关**,所以照搬的表现是「iframe 炸、inline 不炸」
|
|
775
|
+
*
|
|
776
|
+
* 所以本函数是**白名单式**的:只挑四个字段出来,其余一律丢掉。
|
|
777
|
+
* 反过来写(黑名单式地 `delete raw.event`)在上游哪天多塞一个对象时会静默漏过去。
|
|
778
|
+
*
|
|
779
|
+
* `from` / `to` 也**不用 `z.unknown()`** —— 那等于给 DOM 对象留了一条后门。
|
|
780
|
+
* 限死原始类型,拿不准的一律 `null`。
|
|
781
|
+
*/
|
|
782
|
+
function normalizeUserAction(raw) {
|
|
783
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
784
|
+
const r = raw;
|
|
785
|
+
const action = r.action;
|
|
786
|
+
if (typeof action !== "string" || !ALLOWED.has(action)) return null;
|
|
787
|
+
const first = (Array.isArray(r.props) ? r.props : [])[0] ?? null;
|
|
788
|
+
return {
|
|
789
|
+
action,
|
|
790
|
+
source: typeof r.pluginName === "string" ? r.pluginName : "player",
|
|
791
|
+
from: primitive(first?.from),
|
|
792
|
+
to: primitive(first?.to)
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* 把上游 `FPS_STUCK` 的原始 payload(**一个采样数组**)归一成契约 payload。
|
|
797
|
+
* 空数组返回 `null`。
|
|
798
|
+
*
|
|
799
|
+
* 上游每个采样长这样(`es/plugins/fpsDetect/index.js`):
|
|
800
|
+
* `{ currentTime, buffers, curDecodedFrames, totalVideoFrames, droppedVideoFrames, checkInterval }`。
|
|
801
|
+
*
|
|
802
|
+
* `buffers` 是缓冲区间明细,**不进契约** —— 那是 `bufferhealth`(ADR-071)的地盘,
|
|
803
|
+
* 在这里再报一份就是第二个描述缓冲的入口。
|
|
804
|
+
*/
|
|
805
|
+
function normalizeFrameFreeze(raw) {
|
|
806
|
+
if (!Array.isArray(raw) || raw.length === 0) return null;
|
|
807
|
+
const samples = raw;
|
|
808
|
+
const last = samples[samples.length - 1];
|
|
809
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
810
|
+
return {
|
|
811
|
+
durationMs: samples.reduce((sum, s) => sum + num(s.checkInterval), 0),
|
|
812
|
+
droppedFrames: num(last.droppedVideoFrames),
|
|
813
|
+
totalFrames: num(last.totalVideoFrames)
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
//#endregion
|
|
817
|
+
//#region src/playback-context.ts
|
|
818
|
+
/**
|
|
819
|
+
* 播放内核 —— **实际选中的那个**,不是消费方声明的。
|
|
820
|
+
*
|
|
821
|
+
* 消费方传 `type: 'auto'` 时压根不知道结果:ADR-056 之后 HLS 按**能力**选
|
|
822
|
+
* (有 MSE 或 MMS 就走 hls.js,两者都没有才回原生),而那台设备的 UA 和别的没区别。
|
|
823
|
+
*
|
|
824
|
+
* **住在契约层,由 player-core 的 `Kernel` 类型引用它**,不是各写一份 ——
|
|
825
|
+
* 两份枚举描述同一件事,漂了没人查得出来(同 ADR-062 ③ 拒绝为 `kernelhealth`
|
|
826
|
+
* 另起一套「重连原因」枚举的理由)。
|
|
827
|
+
*/
|
|
828
|
+
const PlaybackKernelSchema = zod.z.enum([
|
|
829
|
+
"native",
|
|
830
|
+
"hls.js",
|
|
831
|
+
"flv.js"
|
|
832
|
+
]);
|
|
833
|
+
/** 实际路由的直播语义。它来自归一化后的 source,而不是宿主上报时附带的 tag。 */
|
|
834
|
+
const StreamKindSchema = zod.z.enum(["live", "vod"]);
|
|
835
|
+
/** 支持矩阵使用的最小运行环境分桶;禁止加入 UA、版本或任意业务字段。 */
|
|
836
|
+
const PlaybackRuntimeSchema = zod.z.object({
|
|
837
|
+
platform: zod.z.enum([
|
|
838
|
+
"ios",
|
|
839
|
+
"android",
|
|
840
|
+
"desktop",
|
|
841
|
+
"unknown"
|
|
842
|
+
]),
|
|
843
|
+
browser: zod.z.enum([
|
|
844
|
+
"webkit",
|
|
845
|
+
"gecko",
|
|
846
|
+
"chromium",
|
|
847
|
+
"embedded",
|
|
848
|
+
"unknown"
|
|
849
|
+
]),
|
|
850
|
+
mse: zod.z.enum([
|
|
851
|
+
"standard",
|
|
852
|
+
"managed",
|
|
853
|
+
"unavailable"
|
|
854
|
+
])
|
|
855
|
+
});
|
|
856
|
+
const SourceRouteBaseSchema = zod.z.object({
|
|
857
|
+
/** 与成功 route 后续 PlaybackContext 相同的 SDK 会话标识。 */
|
|
858
|
+
sessionId: zod.z.string(),
|
|
859
|
+
/** 本次供给的候选类型集合;不含地址。 */
|
|
860
|
+
candidateTypes: zod.z.array(SourceEntryTypeSchema).min(1),
|
|
861
|
+
streamKind: StreamKindSchema,
|
|
862
|
+
runtime: PlaybackRuntimeSchema
|
|
863
|
+
});
|
|
864
|
+
/** 构造前选源的事实。它与 QoE / 首帧是不同的证据链。 */
|
|
865
|
+
const SourceRoutePayloadSchema = zod.z.discriminatedUnion("outcome", [SourceRouteBaseSchema.extend({
|
|
866
|
+
outcome: zod.z.literal("selected"),
|
|
867
|
+
mediaType: SourceEntryTypeSchema,
|
|
868
|
+
kernel: PlaybackKernelSchema,
|
|
869
|
+
reason: zod.z.enum([
|
|
870
|
+
"required_hls",
|
|
871
|
+
"live_preference",
|
|
872
|
+
"vod_preference",
|
|
873
|
+
"native_mp4_fallback"
|
|
874
|
+
])
|
|
875
|
+
}).strict(), SourceRouteBaseSchema.extend({
|
|
876
|
+
outcome: zod.z.literal("unsupported"),
|
|
877
|
+
mediaType: zod.z.null(),
|
|
878
|
+
kernel: zod.z.null(),
|
|
879
|
+
reason: zod.z.literal("no_supported_candidate"),
|
|
880
|
+
errorCode: zod.z.literal("E_MEDIA_NOT_SUPPORTED")
|
|
881
|
+
}).strict()]);
|
|
882
|
+
/**
|
|
883
|
+
* 播放上下文(ADR-074 · #480)。**消费方写上报适配器时,自己在 SDK 外面拿不到的那几格。**
|
|
884
|
+
*
|
|
885
|
+
* ─── 它补的是哪一句话 ─────────────────────────────
|
|
886
|
+
*
|
|
887
|
+
* `error` 的 payload 是 `{ code, message, retryable, category, cause? }` ——
|
|
888
|
+
* **没有一个字段回答「出错时播到哪了 / 用的哪个内核 / 这是哪一次播放」**。
|
|
889
|
+
* 消费方要补齐只有一条路:自己监听 `timeupdate` / `qualitychange` / `ready`
|
|
890
|
+
* 维护一份镜像,在 `error` 到达时读出来。**那正是 ADR-043 花一整条 ADR 消灭掉的形态。**
|
|
891
|
+
*
|
|
892
|
+
* ─── 字段判据:消费方能不能在 SDK 外面自己拿到 ───
|
|
893
|
+
*
|
|
894
|
+
* 拿得到的一律不进。按这条,三个候选**当场出局**:`mode`(消费方自己 import 了哪个包)、
|
|
895
|
+
* `contractVersion`(直接 `import { CONTRACT_VERSION }`)、`live`(**是消费方自己传进来的**)。
|
|
896
|
+
*
|
|
897
|
+
* ⚠️ **`userId` / `tags` 永久不进,且不得引用本类型当加回它们的依据。** 它们逐字命中
|
|
898
|
+
* ADR-037 的判据(「纯粹的透传字段:SDK 收下来什么也不做」)。本类型和 ADR-037 删掉的
|
|
899
|
+
* `telemetry` **方向相反** —— 那个是输入侧透传,这个是输出侧、只有 SDK 知道的。
|
|
900
|
+
*
|
|
901
|
+
* ⚠️ **不要叫它 envelope。** `envelope.ts` 是 host ↔ iframe 的消息外壳,
|
|
902
|
+
* 两者在 wire 上是**里外两层**关系。
|
|
903
|
+
*/
|
|
904
|
+
const PlaybackContextSchema = zod.z.object({
|
|
905
|
+
/**
|
|
906
|
+
* 本次播放的 id。**由 SDK 生成,消费方不可写** —— 一旦可写,它就退回成 ADR-037
|
|
907
|
+
* 杀掉的那个纯透传字段,而这条边界是整个 ADR-074 成立的前提。
|
|
908
|
+
*
|
|
909
|
+
* `load()` 换源 = **新会话**(ADR-074 明确不决定的事之一,按此推进):CMCD 的 `cid`
|
|
910
|
+
* 跟着内容走,换了内容还共用一个 sid 会让 CDN 侧的聚合失真。
|
|
911
|
+
*/
|
|
912
|
+
sessionId: zod.z.string(),
|
|
913
|
+
/** 实际选中的内核。见 {@link PlaybackKernelSchema} */
|
|
914
|
+
kernel: PlaybackKernelSchema,
|
|
915
|
+
/**
|
|
916
|
+
* SourceRouter 实际选中的媒体类型,不从 URL pathname 推断。
|
|
917
|
+
*
|
|
918
|
+
* optional 是跨 minor 的 wire 兼容边界:旧 iframe producer 不会携带它;当前
|
|
919
|
+
* player-core 始终携带。消费方缺失时只能按 unknown 处理,不能从 URL 猜测。
|
|
920
|
+
*/
|
|
921
|
+
mediaType: SourceEntryTypeSchema.optional(),
|
|
922
|
+
/** 实际参与路由的直播/点播语义;旧 producer 缺失时按 unknown 处理。 */
|
|
923
|
+
streamKind: StreamKindSchema.optional(),
|
|
924
|
+
/** 闭集运行环境;旧 producer 缺失时按 unknown 处理。 */
|
|
925
|
+
runtime: PlaybackRuntimeSchema.optional(),
|
|
926
|
+
/**
|
|
927
|
+
* 取这个快照那一刻的播放位置(秒)。
|
|
928
|
+
*
|
|
929
|
+
* ⚠️ **它不参与 `contextchange` 的触发判据** —— 每 250ms 都在变,按值变化发
|
|
930
|
+
* 就等于复制一条 `timeupdate`。要「出错那一刻」的位置请在 `error` 回调里
|
|
931
|
+
* 同步调 `getPlaybackContext()`,不要读 `contextchange` 缓存下来的值。
|
|
932
|
+
*/
|
|
933
|
+
position: zod.z.number(),
|
|
934
|
+
/**
|
|
935
|
+
* 当前清晰度档位(= `ready.quality[].level` 的索引);单码率源或档位未知时为 `null`。
|
|
936
|
+
*
|
|
937
|
+
* `null` 不是错误态 —— MP4 / 单档 HLS 结构上就没有多个 level。
|
|
938
|
+
*/
|
|
939
|
+
qualityLevel: zod.z.number().nullable(),
|
|
940
|
+
/**
|
|
941
|
+
* 实际在播的那个候选源的 `origin`(如 `https://cdn.example.com`)。
|
|
942
|
+
*
|
|
943
|
+
* 多源时消费方**结构性拿不到**这个信息(选取规则在 SDK 内,ADR-017)。
|
|
944
|
+
*/
|
|
945
|
+
srcOrigin: zod.z.string(),
|
|
946
|
+
/**
|
|
947
|
+
* 实际在播的那个候选源的 `pathname`(如 `/live/room-42/master.m3u8`)。
|
|
948
|
+
*
|
|
949
|
+
* ⚠️ **和 `srcOrigin` 刻意分成两个字段,不合成一个字符串**:拼回去的第一件事
|
|
950
|
+
* 就是有人拿它当 URL 用,然后发现少了 query 于是「顺手补上」——
|
|
951
|
+
* 而 query 里装的正是签名凭据。分开之后,拼接这个动作必须由消费方显式做一次。
|
|
952
|
+
*/
|
|
953
|
+
srcPath: zod.z.string()
|
|
954
|
+
});
|
|
955
|
+
/**
|
|
956
|
+
* 把源地址脱敏成 `origin` + `pathname` 两段。**query string 一个字符都不带出去。**
|
|
957
|
+
*
|
|
958
|
+
* 签名参数**全在 query 里**(ADR-022 认证仅用签名 URL),原样上报等于把凭据交给
|
|
959
|
+
* 第三方 SaaS —— 而「短期有效」不等于「可以外发」。
|
|
960
|
+
*
|
|
961
|
+
* ⚠️ **脱敏发生在 SDK 内,不是「建议消费方自己脱敏」。** 后者等于先把凭据交出去
|
|
962
|
+
* 再请人删掉,而且五个消费面各写一遍 = 五份会漂的副本。
|
|
963
|
+
*
|
|
964
|
+
* ⚠️ **不做正则。** 用 `URL` 的两个属性 —— 正则会漏 `;` 参数、`#` 片段这类形态,
|
|
965
|
+
* 而漏掉的那部分正好是要挡的东西。
|
|
966
|
+
*
|
|
967
|
+
* 解析不了的地址(相对路径 / blob: / 空串)返回两个空串,**不抛** ——
|
|
968
|
+
* 上下文是旁路信息,不该让一个奇怪的 URL 把播放搞崩。
|
|
969
|
+
*/
|
|
970
|
+
function redactSourceUrl(url) {
|
|
971
|
+
try {
|
|
972
|
+
const u = new URL(url);
|
|
973
|
+
return {
|
|
974
|
+
srcOrigin: u.origin,
|
|
975
|
+
srcPath: u.pathname
|
|
976
|
+
};
|
|
977
|
+
} catch {
|
|
978
|
+
return {
|
|
979
|
+
srcOrigin: "",
|
|
980
|
+
srcPath: ""
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
//#endregion
|
|
985
|
+
//#region src/events.ts
|
|
986
|
+
/**
|
|
987
|
+
* 清晰度档位。`level` 是索引,传给 `setQuality` 命令用。
|
|
988
|
+
*/
|
|
989
|
+
const QualityLevelSchema = zod.z.object({
|
|
990
|
+
level: zod.z.number(),
|
|
991
|
+
label: zod.z.string().optional(),
|
|
992
|
+
height: zod.z.number().optional(),
|
|
993
|
+
bitrate: zod.z.number().optional()
|
|
994
|
+
});
|
|
995
|
+
/**
|
|
996
|
+
* 可用字幕轨(player 加载后暴露给消费方)。`id` 是索引,传给 `setSubtitle` 命令用。
|
|
997
|
+
*
|
|
998
|
+
* 和输入侧的 {@link SubtitleTrack}(url/content 两种 `mode`)分开:那个是**消费方喂进来**的
|
|
999
|
+
* 原始字幕描述,这个是 player **加载后回报**的、可切换的轨道清单(同 QualityLevel 之于 quality)。
|
|
1000
|
+
*/
|
|
1001
|
+
const SubtitleTrackInfoSchema = zod.z.object({
|
|
1002
|
+
/** 索引(= source.subtitles 数组下标),`setSubtitle({ id })` 用它引用轨道 */
|
|
1003
|
+
id: zod.z.number(),
|
|
1004
|
+
/** BCP-47,如 `'en'` / `'zh-CN'` / `'th'` */
|
|
1005
|
+
locale: zod.z.string(),
|
|
1006
|
+
/** 展示名,如 `'English'` / `'中文'` */
|
|
1007
|
+
label: zod.z.string()
|
|
1008
|
+
});
|
|
1009
|
+
/**
|
|
1010
|
+
* 「现在能不能正常出画面」的原因枚举({@link PlayerEventSchema} 的 `playablechange`)。
|
|
1011
|
+
*
|
|
1012
|
+
* 优先级(高的压低的,同时命中时报最严重的那个):
|
|
1013
|
+
* `error` > `frame_disconnected` > `autoplay_blocked` > `reconnecting` > `stalled`
|
|
1014
|
+
* > `buffering` > `initializing` > `degraded` > `ok`
|
|
1015
|
+
*
|
|
1016
|
+
* **命名**:枚举值用 `snake_case`,与事件名(全小写连写)是两套命名空间 ——
|
|
1017
|
+
* 事件名对齐 HTML5 媒体事件,枚举值是 payload 里的数据,`autoplay_blocked`
|
|
1018
|
+
* 比 `autoplayblocked` 好读。既有的 `phase: 'start' | 'end'` 是单词故看不出区别。
|
|
1019
|
+
*/
|
|
1020
|
+
const PlayableReasonSchema = zod.z.enum([
|
|
1021
|
+
"ok",
|
|
1022
|
+
"initializing",
|
|
1023
|
+
"buffering",
|
|
1024
|
+
"stalled",
|
|
1025
|
+
"reconnecting",
|
|
1026
|
+
"autoplay_blocked",
|
|
1027
|
+
"error",
|
|
1028
|
+
"frame_disconnected",
|
|
1029
|
+
"degraded"
|
|
1030
|
+
]);
|
|
1031
|
+
zod.z.enum([
|
|
1032
|
+
"detected",
|
|
1033
|
+
"attempting",
|
|
1034
|
+
"validating",
|
|
1035
|
+
"recovered",
|
|
1036
|
+
"failed",
|
|
1037
|
+
"cancelled"
|
|
1038
|
+
]);
|
|
1039
|
+
/** SDK 实际执行的恢复策略(ADR-079)。 */
|
|
1040
|
+
const RecoveryStrategySchema = zod.z.enum([
|
|
1041
|
+
"reconnect",
|
|
1042
|
+
"media_recovery",
|
|
1043
|
+
"visibility_reload"
|
|
1044
|
+
]);
|
|
1045
|
+
/** 恢复由错误、用户操作还是可见性变化触发。 */
|
|
1046
|
+
const RecoveryTriggerSchema = zod.z.enum([
|
|
1047
|
+
"error",
|
|
1048
|
+
"manual",
|
|
1049
|
+
"visibility"
|
|
1050
|
+
]);
|
|
1051
|
+
const RecoveryPayloadBase = {
|
|
1052
|
+
/** 同一播放器实例内递增的恢复关联号。 */
|
|
1053
|
+
recoveryId: zod.z.number().int().positive(),
|
|
1054
|
+
strategy: RecoveryStrategySchema,
|
|
1055
|
+
trigger: RecoveryTriggerSchema,
|
|
1056
|
+
/** 当前实际恢复轮次,从 1 开始。 */
|
|
1057
|
+
attempt: zod.z.number().int().positive(),
|
|
1058
|
+
/** 该策略允许的最大恢复轮次。 */
|
|
1059
|
+
maxAttempts: zod.z.number().int().positive(),
|
|
1060
|
+
/** 有错误起因时复用既有契约错误码;手动恢复可省略。 */
|
|
1061
|
+
reason: ErrorCodeSchema.optional()
|
|
1062
|
+
};
|
|
1063
|
+
/**
|
|
1064
|
+
* 可验证恢复的单条生命周期记录(ADR-079)。
|
|
1065
|
+
*
|
|
1066
|
+
* 终态字段按 `phase` 严格区分:`recovered` 只能声明位置推进这一种验证证据;
|
|
1067
|
+
* `failed` / `cancelled` 只能携带各自登记的结束原因。`strict()` 刻意拒绝把终态字段
|
|
1068
|
+
* 混入检测或验证阶段,避免上报端把“正在验证”误读成“已经恢复”。
|
|
1069
|
+
*/
|
|
1070
|
+
const RecoveryPayloadSchema = zod.z.discriminatedUnion("phase", [
|
|
1071
|
+
zod.z.object({
|
|
1072
|
+
...RecoveryPayloadBase,
|
|
1073
|
+
phase: zod.z.literal("detected")
|
|
1074
|
+
}).strict(),
|
|
1075
|
+
zod.z.object({
|
|
1076
|
+
...RecoveryPayloadBase,
|
|
1077
|
+
phase: zod.z.literal("attempting")
|
|
1078
|
+
}).strict(),
|
|
1079
|
+
zod.z.object({
|
|
1080
|
+
...RecoveryPayloadBase,
|
|
1081
|
+
phase: zod.z.literal("validating")
|
|
1082
|
+
}).strict(),
|
|
1083
|
+
zod.z.object({
|
|
1084
|
+
...RecoveryPayloadBase,
|
|
1085
|
+
phase: zod.z.literal("recovered"),
|
|
1086
|
+
validatedBy: zod.z.literal("playing_position_advance")
|
|
1087
|
+
}).strict(),
|
|
1088
|
+
zod.z.object({
|
|
1089
|
+
...RecoveryPayloadBase,
|
|
1090
|
+
phase: zod.z.literal("failed"),
|
|
1091
|
+
outcome: zod.z.enum(["timeout", "attempts_exhausted"])
|
|
1092
|
+
}).strict(),
|
|
1093
|
+
zod.z.object({
|
|
1094
|
+
...RecoveryPayloadBase,
|
|
1095
|
+
phase: zod.z.literal("cancelled"),
|
|
1096
|
+
outcome: zod.z.enum([
|
|
1097
|
+
"source_changed",
|
|
1098
|
+
"destroyed",
|
|
1099
|
+
"superseded"
|
|
1100
|
+
])
|
|
1101
|
+
}).strict()
|
|
1102
|
+
]);
|
|
1103
|
+
/**
|
|
1104
|
+
* 事件。player → host(iframe 模式),或 player-core → 消费方(inline 模式)。
|
|
1105
|
+
*
|
|
1106
|
+
* **命名:全小写连写**(`timeupdate` / `autoplayblocked`),对齐 HTML5 媒体事件。
|
|
1107
|
+
* 这是 wire 上的名字;消费面各自映射——Vue emit `time-update`,React prop `onTimeUpdate`。
|
|
1108
|
+
* (团队约定里的 `snake.case` 指的是埋点事件名如 `playback.start`,那是另一套命名空间。)
|
|
1109
|
+
*
|
|
1110
|
+
* 权威来源:docs/protocol/events.md + ARCHITECTURE.md § 8.4。
|
|
1111
|
+
*/
|
|
1112
|
+
const PlayerEventSchema = zod.z.discriminatedUnion("event", [
|
|
1113
|
+
zod.z.object({
|
|
1114
|
+
event: zod.z.literal("sourceroute"),
|
|
1115
|
+
payload: SourceRoutePayloadSchema
|
|
1116
|
+
}),
|
|
1117
|
+
zod.z.object({
|
|
1118
|
+
event: zod.z.literal("ready"),
|
|
1119
|
+
payload: zod.z.object({
|
|
1120
|
+
duration: zod.z.number(),
|
|
1121
|
+
/** 可用清晰度档位;单档源(如 MP4)是空数组 */
|
|
1122
|
+
quality: zod.z.array(QualityLevelSchema),
|
|
1123
|
+
/**
|
|
1124
|
+
* 可用字幕轨;无字幕源是空数组。
|
|
1125
|
+
*
|
|
1126
|
+
* **optional**:这是契约冻结(1.0.0)后给已有 `ready` payload 新增的字段(1.2.0,见 ADR-027)。
|
|
1127
|
+
* 设为可选,保证 1.x 跨版本兼容——旧 producer(≤1.1.0)不发这个字段,新 consumer 当作 `[]`。
|
|
1128
|
+
*/
|
|
1129
|
+
subtitles: zod.z.array(SubtitleTrackInfoSchema).optional()
|
|
1130
|
+
})
|
|
1131
|
+
}),
|
|
1132
|
+
zod.z.object({
|
|
1133
|
+
event: zod.z.literal("play"),
|
|
1134
|
+
payload: zod.z.object({})
|
|
1135
|
+
}),
|
|
1136
|
+
zod.z.object({
|
|
1137
|
+
event: zod.z.literal("pause"),
|
|
1138
|
+
payload: zod.z.object({})
|
|
1139
|
+
}),
|
|
1140
|
+
zod.z.object({
|
|
1141
|
+
event: zod.z.literal("ended"),
|
|
1142
|
+
payload: zod.z.object({})
|
|
1143
|
+
}),
|
|
1144
|
+
zod.z.object({
|
|
1145
|
+
event: zod.z.literal("timeupdate"),
|
|
1146
|
+
/**
|
|
1147
|
+
* ~250ms 一次。
|
|
1148
|
+
*
|
|
1149
|
+
* **直播场景 `duration` 是 0**,不是 Infinity —— Infinity 过不了 JSON 序列化
|
|
1150
|
+
* (会变成 null),而契约事件要跨 iframe 传,所以 player-core 在源头就归一成 0。
|
|
1151
|
+
* 直播场景本来也不该读 duration。
|
|
1152
|
+
*/
|
|
1153
|
+
payload: zod.z.object({
|
|
1154
|
+
time: zod.z.number(),
|
|
1155
|
+
duration: zod.z.number()
|
|
1156
|
+
})
|
|
1157
|
+
}),
|
|
1158
|
+
zod.z.object({
|
|
1159
|
+
event: zod.z.literal("volumechange"),
|
|
1160
|
+
payload: zod.z.object({
|
|
1161
|
+
volume: zod.z.number(),
|
|
1162
|
+
muted: zod.z.boolean()
|
|
1163
|
+
})
|
|
1164
|
+
}),
|
|
1165
|
+
zod.z.object({
|
|
1166
|
+
event: zod.z.literal("seeking"),
|
|
1167
|
+
payload: zod.z.object({ time: zod.z.number() })
|
|
1168
|
+
}),
|
|
1169
|
+
zod.z.object({
|
|
1170
|
+
event: zod.z.literal("seeked"),
|
|
1171
|
+
payload: zod.z.object({ time: zod.z.number() })
|
|
1172
|
+
}),
|
|
1173
|
+
zod.z.object({
|
|
1174
|
+
event: zod.z.literal("waiting"),
|
|
1175
|
+
payload: zod.z.object({})
|
|
1176
|
+
}),
|
|
1177
|
+
zod.z.object({
|
|
1178
|
+
event: zod.z.literal("playing"),
|
|
1179
|
+
payload: zod.z.object({})
|
|
1180
|
+
}),
|
|
1181
|
+
zod.z.object({
|
|
1182
|
+
event: zod.z.literal("qualitychange"),
|
|
1183
|
+
payload: zod.z.object({
|
|
1184
|
+
level: zod.z.number(),
|
|
1185
|
+
/** true = ABR 自动切的,false = 用户手动切的 */
|
|
1186
|
+
auto: zod.z.boolean()
|
|
1187
|
+
})
|
|
1188
|
+
}),
|
|
1189
|
+
zod.z.object({
|
|
1190
|
+
event: zod.z.literal("subtitlechange"),
|
|
1191
|
+
payload: zod.z.object({
|
|
1192
|
+
/** 当前激活字幕轨 id(= {@link SubtitleTrackInfo} 的 `id`);`null` = 字幕已关闭 */
|
|
1193
|
+
id: zod.z.number().nullable() })
|
|
1194
|
+
}),
|
|
1195
|
+
zod.z.object({
|
|
1196
|
+
event: zod.z.literal("error"),
|
|
1197
|
+
payload: PlayerErrorSchema
|
|
1198
|
+
}),
|
|
1199
|
+
zod.z.object({
|
|
1200
|
+
event: zod.z.literal("autoplayblocked"),
|
|
1201
|
+
payload: zod.z.object({})
|
|
1202
|
+
}),
|
|
1203
|
+
zod.z.object({
|
|
1204
|
+
event: zod.z.literal("reconnectstart"),
|
|
1205
|
+
payload: zod.z.object({
|
|
1206
|
+
attempt: zod.z.number(),
|
|
1207
|
+
maxAttempts: zod.z.number(),
|
|
1208
|
+
/**
|
|
1209
|
+
* 触发这一轮重连的**契约错误码**;手动 `reconnect()` 时缺省(没有触发它的错误)。
|
|
1210
|
+
*
|
|
1211
|
+
* ⚠️ **这里曾经是 `z.string()`,而那让它在服务端聚合不了**(ADR-069)——
|
|
1212
|
+
* 消费方拿到的类型是 `string`,`switch` 不了、也没有任何东西挡住将来漂成别的写法。
|
|
1213
|
+
* **而源头从来就是一个契约错误码**:`plugins/reconnect.ts` 传的是
|
|
1214
|
+
* `mapXgplayerError(err).code`,没有第二个发射点。
|
|
1215
|
+
*
|
|
1216
|
+
* **复用 `ErrorCodeSchema`,不新起一套「重连原因」枚举** —— 理由同 ADR-062 ③:
|
|
1217
|
+
* 两套表会让同一条内核错误在 `error` 和 `reconnectstart` 两条通道上给出**互相矛盾**的分类,
|
|
1218
|
+
* 而那种漂移没人查得出来。
|
|
1219
|
+
*
|
|
1220
|
+
* ⚠️ **不要照着「今天实际只会出现哪几个码」去收窄。** 那要复刻
|
|
1221
|
+
* `ReconnectPlugin` 的两道过滤(`retryable === true` 且 `category !== 'media'`),
|
|
1222
|
+
* 而那两道闸是**实现细节**,改一行就和契约对不上了 —— 那正是第二份会漂的副本。
|
|
1223
|
+
*/
|
|
1224
|
+
reason: ErrorCodeSchema.optional(),
|
|
1225
|
+
nextDelayMs: zod.z.number().optional()
|
|
1226
|
+
})
|
|
1227
|
+
}),
|
|
1228
|
+
zod.z.object({
|
|
1229
|
+
event: zod.z.literal("reconnectsuccess"),
|
|
1230
|
+
payload: zod.z.object({ attempts: zod.z.number() })
|
|
1231
|
+
}),
|
|
1232
|
+
zod.z.object({
|
|
1233
|
+
event: zod.z.literal("reconnectfailed"),
|
|
1234
|
+
payload: zod.z.object({
|
|
1235
|
+
attempts: zod.z.number(),
|
|
1236
|
+
/** 最后一轮的触发错误码。语义与取值同 `reconnectstart.reason`(ADR-069) */
|
|
1237
|
+
reason: ErrorCodeSchema.optional()
|
|
1238
|
+
})
|
|
1239
|
+
}),
|
|
1240
|
+
zod.z.object({
|
|
1241
|
+
event: zod.z.literal("recovery"),
|
|
1242
|
+
payload: RecoveryPayloadSchema
|
|
1243
|
+
}),
|
|
1244
|
+
zod.z.object({
|
|
1245
|
+
event: zod.z.literal("compatwarning"),
|
|
1246
|
+
payload: zod.z.object({
|
|
1247
|
+
code: WarningCodeSchema,
|
|
1248
|
+
message: zod.z.string(),
|
|
1249
|
+
/**
|
|
1250
|
+
* ⚠️ 必填,**对 `W_VERSION_MISMATCH` 而言是无关信息**(host 填自己的
|
|
1251
|
+
* `navigator.userAgent`)。留着必填是刻意的:改成可选对已有的 TS 消费方是
|
|
1252
|
+
* breaking(`string` → `string | undefined`),不值得为此升 major。
|
|
1253
|
+
* 版本不匹配真正要被聚合的两个值走下面两个专属字段。
|
|
1254
|
+
* 下次契约 major 时值得把本 payload 拆成判别联合,记在 ADR-070 的开放问题里。
|
|
1255
|
+
*/
|
|
1256
|
+
ua: zod.z.string(),
|
|
1257
|
+
/** 宿主侧契约版本。**仅 `W_VERSION_MISMATCH` 携带**(ADR-070) */
|
|
1258
|
+
hostVersion: zod.z.string().optional(),
|
|
1259
|
+
/** iframe 侧契约版本。**仅 `W_VERSION_MISMATCH` 携带**(ADR-070) */
|
|
1260
|
+
iframeVersion: zod.z.string().optional(),
|
|
1261
|
+
/**
|
|
1262
|
+
* 被丢弃那条事件的名字。**仅 `W_EVENT_REJECTED` 携带**(ADR-087)。
|
|
1263
|
+
*
|
|
1264
|
+
* ⚠️ **类型是 `string` 而不是 `EventName`,这是刻意的** —— 它装的恰恰是
|
|
1265
|
+
* **本地契约不认识的名字**(最常见的成因是 iframe 比 host 新)。
|
|
1266
|
+
* 收窄成 `EventName` 就等于说「只会是我认识的那些」,而那正好是它不成立的场合。
|
|
1267
|
+
* 取不到名字(连 `event` 字段都没有)时缺省。
|
|
1268
|
+
*
|
|
1269
|
+
* 冻结后新增字段(minor,向后兼容:旧消费方读不到它即无影响)。
|
|
1270
|
+
*/
|
|
1271
|
+
rejectedEvent: zod.z.string().optional()
|
|
1272
|
+
})
|
|
1273
|
+
}),
|
|
1274
|
+
zod.z.object({
|
|
1275
|
+
event: zod.z.literal("stalled"),
|
|
1276
|
+
payload: zod.z.object({
|
|
1277
|
+
phase: zod.z.enum(["start", "end"]),
|
|
1278
|
+
/** 卡在哪个播放位置(秒) */
|
|
1279
|
+
position: zod.z.number(),
|
|
1280
|
+
/** phase='end' 时带上:本次卡顿时长(毫秒)。卡顿率 KPI 的核心指标 */
|
|
1281
|
+
durationMs: zod.z.number().optional(),
|
|
1282
|
+
/**
|
|
1283
|
+
* 这次等待属于哪一类(ADR-075 决策④)。**`start` 与 `end` 带同一个值。**
|
|
1284
|
+
*
|
|
1285
|
+
* ⚠️ **只有 `playback` 该进卡顿率。** `firstframe` 是起播等待(它的 KPI 是首帧
|
|
1286
|
+
* 时延,`firstframe` 事件在管),`seek` 是**用户自己拖进度条造成的** ——
|
|
1287
|
+
* 把它算进卡顿率等于把用户的操作记成播放器的故障。三类混在一起正是 #391 说的
|
|
1288
|
+
* 「只有『发生了』,没有分布」里缺的那一刀。
|
|
1289
|
+
*
|
|
1290
|
+
* **可选字段**:老消费方不改代码也不炸。
|
|
1291
|
+
*/
|
|
1292
|
+
kind: zod.z.enum([
|
|
1293
|
+
"playback",
|
|
1294
|
+
"firstframe",
|
|
1295
|
+
"seek"
|
|
1296
|
+
]).optional()
|
|
1297
|
+
})
|
|
1298
|
+
}),
|
|
1299
|
+
zod.z.object({
|
|
1300
|
+
event: zod.z.literal("playablechange"),
|
|
1301
|
+
payload: zod.z.object({
|
|
1302
|
+
/** 能不能正常出画面。消费方只读这一个字段就够 */
|
|
1303
|
+
playable: zod.z.boolean(),
|
|
1304
|
+
/** 为什么。`playable: true` 时为 `'ok'` 或 `'degraded'` */
|
|
1305
|
+
reason: PlayableReasonSchema,
|
|
1306
|
+
/**
|
|
1307
|
+
* 是否可自愈 —— 决定消费方盖 loading(等)还是露重试入口(不等)。
|
|
1308
|
+
*
|
|
1309
|
+
* `buffering` / `stalled` / `reconnecting` / `initializing` 为 `true`;
|
|
1310
|
+
* `error` / `autoplay_blocked` 为 `false`(要用户点一下,自己不会好)。
|
|
1311
|
+
*
|
|
1312
|
+
* **`frame_disconnected` 是唯一随阶段变化的一支**:iframe 正在握手时为 `true`
|
|
1313
|
+
*(正常启动,该等),握手失败降级后为 `false`(连不上了,该露重试入口)。
|
|
1314
|
+
* 两个阶段共用一个 reason,靠这个字段区分 —— 这正是它存在的意义。
|
|
1315
|
+
*/
|
|
1316
|
+
recoverable: zod.z.boolean()
|
|
1317
|
+
})
|
|
1318
|
+
}),
|
|
1319
|
+
zod.z.object({
|
|
1320
|
+
event: zod.z.literal("kernelhealth"),
|
|
1321
|
+
payload: zod.z.object({
|
|
1322
|
+
/**
|
|
1323
|
+
* 内核是不是在挣扎。
|
|
1324
|
+
*
|
|
1325
|
+
* `true` = 本窗口内有诊断;`false` = 安静了一整个窗口(**收尾那一条**)。
|
|
1326
|
+
*
|
|
1327
|
+
* 收尾那一条是硬要求:没有它,消费方要判断「内核安静了」只能自己兜一个超时,
|
|
1328
|
+
* 而「每个消费方各自兜一个超时」正是 ADR-043 花一整条 ADR 消灭的东西。
|
|
1329
|
+
*/
|
|
1330
|
+
degraded: zod.z.boolean(),
|
|
1331
|
+
/**
|
|
1332
|
+
* 成因大类。取本窗口内出现最多的那一类,并列取先出现的。
|
|
1333
|
+
*
|
|
1334
|
+
* 三档的划分判据是「**会不会让消费方做不同的事**」(ADR-061 ②):
|
|
1335
|
+
* `network` 提示检查网络 / 降码率(能自愈的一档)、`media` 换清晰度或换设备
|
|
1336
|
+
* 才可能好、`other` 只能上报。
|
|
1337
|
+
*
|
|
1338
|
+
* 归一复用 `error-mapping.ts` 那份类型表 —— 同一条内核错误在 `error` 和
|
|
1339
|
+
* `kernelhealth` 里给出互相矛盾的分类是没人查得出来的漂移。
|
|
1340
|
+
*/
|
|
1341
|
+
reason: zod.z.enum([
|
|
1342
|
+
"network",
|
|
1343
|
+
"media",
|
|
1344
|
+
"other"
|
|
1345
|
+
]),
|
|
1346
|
+
/** 本窗口内内核报的非致命诊断条数(`degraded: false` 时恒为 0) */
|
|
1347
|
+
count: zod.z.number(),
|
|
1348
|
+
/** 从本段故障的**首条**诊断到现在持续了多久(毫秒)。配合 `count` 得出速率 */
|
|
1349
|
+
durationMs: zod.z.number(),
|
|
1350
|
+
/**
|
|
1351
|
+
* 内核原文(如 `networkError / fragLoadError`),取本窗口最近一条。
|
|
1352
|
+
*
|
|
1353
|
+
* ⚠️ **只给日志看,不许 `switch`。** 它跟着 hls.js 版本走,不是契约的一部分;
|
|
1354
|
+
* 要分支请用 {@link reason}。
|
|
1355
|
+
*/
|
|
1356
|
+
detail: zod.z.string()
|
|
1357
|
+
})
|
|
1358
|
+
}),
|
|
1359
|
+
zod.z.object({
|
|
1360
|
+
event: zod.z.literal("audiohealth"),
|
|
1361
|
+
payload: zod.z.object({
|
|
1362
|
+
degraded: zod.z.boolean(),
|
|
1363
|
+
reason: zod.z.literal("audio_data_gap"),
|
|
1364
|
+
durationMs: zod.z.number()
|
|
1365
|
+
})
|
|
1366
|
+
}),
|
|
1367
|
+
zod.z.object({
|
|
1368
|
+
event: zod.z.literal("bufferhealth"),
|
|
1369
|
+
payload: zod.z.object({
|
|
1370
|
+
/**
|
|
1371
|
+
* 余量是不是在净流失。
|
|
1372
|
+
*
|
|
1373
|
+
* `true` = 连续 3 次采样(1s 间隔)余量严格下降,即「每秒播掉一秒、一秒也没补上」;
|
|
1374
|
+
* `false` = **收尾那一条**,余量重新涨回来了。
|
|
1375
|
+
*
|
|
1376
|
+
* 收尾那一条是硬要求,理由与 `kernelhealth.degraded` 逐字相同:
|
|
1377
|
+
* 没有它,消费方判断「缓冲回来了」只能自己兜一个超时。
|
|
1378
|
+
*/
|
|
1379
|
+
draining: zod.z.boolean(),
|
|
1380
|
+
/**
|
|
1381
|
+
* 当前余量(秒)= `buffered.end(最后一段) - currentTime`。
|
|
1382
|
+
*
|
|
1383
|
+
* ⚠️ **可以是负数,那不是非法值** —— 直播里 hls.js 会把播放头往直播边缘推,
|
|
1384
|
+
* 而缓冲追不上。**负号是「在追但追不上」(带宽不够)与「源头挂了」(余量停在 ~0)
|
|
1385
|
+
* 之间唯一的分叉点**(ADR-068 ②)。任何把负值过滤掉的实现都会删掉这条信息。
|
|
1386
|
+
*
|
|
1387
|
+
* ⚠️ 不要改用「包含播放头的那一段」那个更严谨的公式 —— 实测 100 个采样点
|
|
1388
|
+
* `buffered.length` 恒为 1,它没有对象;而它在播放头跑出缓冲时返回空,
|
|
1389
|
+
* **正好抹掉上面那个分叉点**(ADR-071 ③)。
|
|
1390
|
+
*
|
|
1391
|
+
* SDK **不替消费方判定成因** —— 报符号,归因归团队层(同 ADR-062 ③)。
|
|
1392
|
+
*/
|
|
1393
|
+
marginSec: zod.z.number()
|
|
1394
|
+
})
|
|
1395
|
+
}),
|
|
1396
|
+
zod.z.object({
|
|
1397
|
+
event: zod.z.literal("contextchange"),
|
|
1398
|
+
payload: PlaybackContextSchema
|
|
1399
|
+
}),
|
|
1400
|
+
zod.z.object({
|
|
1401
|
+
event: zod.z.literal("firstframe"),
|
|
1402
|
+
payload: FirstFramePayloadSchema
|
|
1403
|
+
}),
|
|
1404
|
+
zod.z.object({
|
|
1405
|
+
event: zod.z.literal("framefreeze"),
|
|
1406
|
+
payload: FrameFreezePayloadSchema
|
|
1407
|
+
}),
|
|
1408
|
+
zod.z.object({
|
|
1409
|
+
event: zod.z.literal("useraction"),
|
|
1410
|
+
payload: UserActionPayloadSchema
|
|
1411
|
+
})
|
|
1412
|
+
]);
|
|
1413
|
+
/**
|
|
1414
|
+
* 把 `createPlayer` 同步抛出的错误(选源失败等)转成 error 事件。
|
|
1415
|
+
*
|
|
1416
|
+
* `SentinelError` 带 `.playerError`;其它未知错误兜底成 `E_INTERNAL`。
|
|
1417
|
+
*
|
|
1418
|
+
* **住在 protocol 而不是各消费面**:inline 和 iframe 内部都要在 `createPlayer` 的
|
|
1419
|
+
* try/catch 里做同一件事,此前 `packages/react/src/overlay.ts` 与
|
|
1420
|
+
* `apps/embed-app/src/bridge.ts` 各有一份逐字相同的副本 —— 同 `resolveLocaleMessages`
|
|
1421
|
+
* 当初被收回来的理由(#120 · PR C)。
|
|
1422
|
+
*
|
|
1423
|
+
* 这不是 schema 变更:没有新增 / 修改任何 Zod schema,只是把一个纯函数收到 `makePlayerError`
|
|
1424
|
+
* 旁边。
|
|
1425
|
+
*/
|
|
1426
|
+
/**
|
|
1427
|
+
* 构造一条 `W_EVENT_REJECTED` 警告(ADR-087 · #728)。
|
|
1428
|
+
*
|
|
1429
|
+
* **住在 protocol 而不是各传输层**:`frame-core`(react-frame / vue-frame 两面)和
|
|
1430
|
+
* `embed-helper`(静态 iframe)都要在入站事件被契约拒绝时发同一条警告,
|
|
1431
|
+
* 各写一份就是两份会漂的副本 —— 同 {@link toErrorEvent} 和 `PLAYER_DESTROYED_MESSAGE`
|
|
1432
|
+
* 当初被收回来的理由。
|
|
1433
|
+
*
|
|
1434
|
+
* 还有一条更硬的理由:`embed-helper` 里**不许出现任何硬编码事件名**
|
|
1435
|
+
*(`demo-matrix` 的「静态 iframe 那一列」护栏钉着)—— 那一列「事件全绿」正建立在
|
|
1436
|
+
* 它靠 `on<K extends EventName>` 泛型覆盖契约全集、没有任何过滤上。
|
|
1437
|
+
* 警告构造放这里,那边就一个事件名字面量都不需要。
|
|
1438
|
+
*
|
|
1439
|
+
* @param rejected 被丢那条事件的名字;连 `event` 字段都取不到时传 `undefined`
|
|
1440
|
+
* @param ua 宿主 UA。`ua` 对本警告是无关信息,但契约里它必填(ADR-070 记过这个取舍)
|
|
1441
|
+
*/
|
|
1442
|
+
function makeEventRejectedWarning(rejected, ua) {
|
|
1443
|
+
return {
|
|
1444
|
+
event: "compatwarning",
|
|
1445
|
+
payload: {
|
|
1446
|
+
code: "W_EVENT_REJECTED",
|
|
1447
|
+
message: `收到一条过不了契约校验的事件(${rejected ?? "(未知)"}),已丢弃。最常见的成因是 iframe 比宿主新 —— 先看两端契约版本差多少,再怀疑 iframe 有 bug。`,
|
|
1448
|
+
ua,
|
|
1449
|
+
...rejected === void 0 ? {} : { rejectedEvent: rejected }
|
|
1450
|
+
}
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
function toErrorEvent(err) {
|
|
1454
|
+
return {
|
|
1455
|
+
event: "error",
|
|
1456
|
+
payload: typeof err === "object" && err !== null && "playerError" in err ? err.playerError : makePlayerError("E_INTERNAL", err instanceof Error ? err.message : "内部错误", err)
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
//#endregion
|
|
1460
|
+
//#region src/methods.ts
|
|
1461
|
+
/**
|
|
1462
|
+
* 命令。host → iframe(iframe 模式),或消费方 → player-core(inline 模式)。
|
|
1463
|
+
*
|
|
1464
|
+
* 权威来源:docs/protocol/methods.md + ARCHITECTURE.md § 8.5。
|
|
1465
|
+
* 每条命令的响应都是 `void`——结果通过事件回来,不通过 response payload。
|
|
1466
|
+
*/
|
|
1467
|
+
const CommandSchema = zod.z.discriminatedUnion("method", [
|
|
1468
|
+
zod.z.object({
|
|
1469
|
+
method: zod.z.literal("play"),
|
|
1470
|
+
params: zod.z.object({}).optional()
|
|
1471
|
+
}),
|
|
1472
|
+
zod.z.object({
|
|
1473
|
+
method: zod.z.literal("pause"),
|
|
1474
|
+
params: zod.z.object({}).optional()
|
|
1475
|
+
}),
|
|
1476
|
+
zod.z.object({
|
|
1477
|
+
method: zod.z.literal("seek"),
|
|
1478
|
+
params: zod.z.object({
|
|
1479
|
+
/** 目标时间(秒)。负数和超过 duration 的值由 player-core 钳制,不报错 */
|
|
1480
|
+
time: zod.z.number(),
|
|
1481
|
+
/** `keyframe` 更快但不精确;默认 `exact` */
|
|
1482
|
+
type: zod.z.enum(["exact", "keyframe"]).optional()
|
|
1483
|
+
})
|
|
1484
|
+
}),
|
|
1485
|
+
zod.z.object({
|
|
1486
|
+
method: zod.z.literal("setVolume"),
|
|
1487
|
+
params: zod.z.object({ volume: zod.z.number().min(0).max(1) })
|
|
1488
|
+
}),
|
|
1489
|
+
zod.z.object({
|
|
1490
|
+
method: zod.z.literal("setMuted"),
|
|
1491
|
+
params: zod.z.object({ muted: zod.z.boolean() })
|
|
1492
|
+
}),
|
|
1493
|
+
zod.z.object({
|
|
1494
|
+
method: zod.z.literal("setPlaybackRate"),
|
|
1495
|
+
params: zod.z.object({ rate: zod.z.number().min(.25).max(4) })
|
|
1496
|
+
}),
|
|
1497
|
+
zod.z.object({
|
|
1498
|
+
method: zod.z.literal("setQuality"),
|
|
1499
|
+
/** `'auto'` = 交给 ABR 自适应 */
|
|
1500
|
+
params: zod.z.object({ level: zod.z.union([zod.z.number(), zod.z.literal("auto")]) })
|
|
1501
|
+
}),
|
|
1502
|
+
zod.z.object({
|
|
1503
|
+
method: zod.z.literal("setSubtitle"),
|
|
1504
|
+
/**
|
|
1505
|
+
* `'off'` = 关闭字幕;数字 = 切到该 id 轨(见 `ready` payload 的 `subtitles[].id`)。见 ADR-027。
|
|
1506
|
+
*
|
|
1507
|
+
* 切换结果通过 `subtitlechange` 事件回来(同 setQuality → qualitychange)。
|
|
1508
|
+
*/
|
|
1509
|
+
params: zod.z.object({ id: zod.z.union([zod.z.number(), zod.z.literal("off")]) })
|
|
1510
|
+
}),
|
|
1511
|
+
zod.z.object({
|
|
1512
|
+
method: zod.z.literal("setLocale"),
|
|
1513
|
+
/**
|
|
1514
|
+
* 运行时切换语言,**不重建播放器、不丢播放进度**。
|
|
1515
|
+
*
|
|
1516
|
+
* 消费方通常不直接调它 —— 各消费面 watch `locale` prop 后自动下发,
|
|
1517
|
+
* 改 prop 即可切换(见 ADR-035)。
|
|
1518
|
+
*
|
|
1519
|
+
* 作用于两处:①播放内核自带控件的文案(**仅中 / 英**,`zh-*` → 中文,其余 → 英文);
|
|
1520
|
+
* ②player-ui 覆盖层文案(读 `messages`,语言不限)。
|
|
1521
|
+
* 传对象形式可同时换掉两者;传字符串只切控件语言、沿用原有 messages。
|
|
1522
|
+
*/
|
|
1523
|
+
params: zod.z.object({ locale: LocaleConfigSchema })
|
|
1524
|
+
}),
|
|
1525
|
+
zod.z.object({
|
|
1526
|
+
method: zod.z.literal("load"),
|
|
1527
|
+
params: zod.z.object({ source: MediaSourceSchema })
|
|
1528
|
+
}),
|
|
1529
|
+
zod.z.object({
|
|
1530
|
+
method: zod.z.literal("reconnect"),
|
|
1531
|
+
params: zod.z.object({
|
|
1532
|
+
/** true = 把重连计数清零,重新获得完整的 maxRetries 次机会 */
|
|
1533
|
+
resetCounter: zod.z.boolean().optional() }).optional()
|
|
1534
|
+
}),
|
|
1535
|
+
zod.z.object({
|
|
1536
|
+
method: zod.z.literal("pushDanmaku"),
|
|
1537
|
+
/** 逐条推送(直播)。渲染归 SDK,数据源归团队层 */
|
|
1538
|
+
params: zod.z.object({ item: DanmakuItemSchema })
|
|
1539
|
+
}),
|
|
1540
|
+
zod.z.object({
|
|
1541
|
+
method: zod.z.literal("setDanmakuEnabled"),
|
|
1542
|
+
/** 开关弹幕渲染(true=start / false=stop) */
|
|
1543
|
+
params: zod.z.object({ enabled: zod.z.boolean() })
|
|
1544
|
+
}),
|
|
1545
|
+
zod.z.object({
|
|
1546
|
+
method: zod.z.literal("clearDanmaku"),
|
|
1547
|
+
params: zod.z.object({}).optional()
|
|
1548
|
+
}),
|
|
1549
|
+
zod.z.object({
|
|
1550
|
+
method: zod.z.literal("destroy"),
|
|
1551
|
+
params: zod.z.object({}).optional()
|
|
1552
|
+
}),
|
|
1553
|
+
zod.z.object({
|
|
1554
|
+
method: zod.z.literal("enterFullscreen"),
|
|
1555
|
+
params: zod.z.object({}).optional()
|
|
1556
|
+
}),
|
|
1557
|
+
zod.z.object({
|
|
1558
|
+
method: zod.z.literal("exitFullscreen"),
|
|
1559
|
+
params: zod.z.object({}).optional()
|
|
1560
|
+
})
|
|
1561
|
+
]);
|
|
1562
|
+
//#endregion
|
|
1563
|
+
//#region src/version.ts
|
|
1564
|
+
/**
|
|
1565
|
+
* 契约版本。
|
|
1566
|
+
*
|
|
1567
|
+
* 注意:这和 iframe URL 里的 `version`(如 `'v1'`,见 ADR-023)不是一回事——
|
|
1568
|
+
* 那个是 CDN 路径版本,这个是 host ↔ iframe 的通信契约版本。
|
|
1569
|
+
*
|
|
1570
|
+
* 冻结策略见 `packages/protocol/CLAUDE.md`:审查清单全过之后才升 1.0.0。
|
|
1571
|
+
*
|
|
1572
|
+
* **首发即 1.1.0**(2026-07-19):契约从未对外发布,故把首发前迭代出的全部能力面
|
|
1573
|
+
* **折叠进首发契约**——与 8 个包的 npm 版本对齐,避免"包版本 1.1.0 却携带 1.0.0
|
|
1574
|
+
* 契约常量"的双轴割裂(`tests/index.contract.test.ts` 有断言锁死这一致性)。
|
|
1575
|
+
* 首发契约面**已包含**:
|
|
1576
|
+
* - `stalled` 事件(HealthMonitor 卡顿测量,设计见 ADR-026)
|
|
1577
|
+
* - 字幕控制侧:`setSubtitle` 命令 + `subtitlechange` 事件 + `ready` 的**可选** `subtitles` 字段(ADR-027)
|
|
1578
|
+
* - 弹幕 streaming:`pushDanmaku` / `setDanmakuEnabled` / `clearDanmaku` 命令 + `PlayerConfig.danmaku`(可选)(ADR-028)
|
|
1579
|
+
* - 场景预设收敛为唯一 `homepage-preview`(ADR-032)
|
|
1580
|
+
*
|
|
1581
|
+
* 上面几个 ADR 记录的是这些能力的**设计出处**,不是"冻结后独立发布的 minor"——它们在首发前
|
|
1582
|
+
* 就已落地,故都是首发契约的一部分。**首发之后**再新增 method/event/字段才走 minor
|
|
1583
|
+
* (1.x 向后兼容),破坏性变更须走 ADR + major。
|
|
1584
|
+
*
|
|
1585
|
+
* 为什么是 1.1.0 而不是 1.0.0:原计划锁 1.0.0(ADR-025),但首发前两项变更
|
|
1586
|
+
* (全屏命令补完、preset 收敛)各带一条 minor changeset,changesets 的 `fixed` 组
|
|
1587
|
+
* 把 8 个包统一推到 1.1.0。契约面确有变化(删了 4 个 preset),升 minor 名实相符;
|
|
1588
|
+
* 且 {@link isContractCompatible} 在 `>=1.0.0` 时只比 major,1.0.0 ↔ 1.1.0 握手仍兼容。
|
|
1589
|
+
*
|
|
1590
|
+
* 值**从 package.json 派生**,不要改回硬编码 —— 契约常量必须与 npm 包版本
|
|
1591
|
+
* 严格相等,而 changesets 只改 package.json。理由与实测数据见
|
|
1592
|
+
* `docs/adr/ADR-036-contract-version-derived.md`。
|
|
1593
|
+
*/
|
|
1594
|
+
const CONTRACT_VERSION = "1.0.0";
|
|
1595
|
+
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
1596
|
+
/**
|
|
1597
|
+
* 解析 semver 字符串。只接受严格的 `major.minor.patch`,
|
|
1598
|
+
* 不接受 prerelease / build metadata —— 契约版本不需要它们。
|
|
1599
|
+
*
|
|
1600
|
+
* @returns 解析结果;格式非法时返回 `null`(不 throw,调用方决定怎么降级)
|
|
1601
|
+
*
|
|
1602
|
+
* @example
|
|
1603
|
+
* parseVersion('1.2.3') // { major: 1, minor: 2, patch: 3 }
|
|
1604
|
+
* parseVersion('v1') // null
|
|
1605
|
+
*/
|
|
1606
|
+
function parseVersion(version) {
|
|
1607
|
+
const m = SEMVER_RE.exec(version);
|
|
1608
|
+
if (!m) return null;
|
|
1609
|
+
return {
|
|
1610
|
+
major: Number(m[1]),
|
|
1611
|
+
minor: Number(m[2]),
|
|
1612
|
+
patch: Number(m[3])
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* 判断两侧契约版本是否兼容。host 和 iframe 必须用**同一个**规则,
|
|
1617
|
+
* 所以它定义在 protocol 里,而不是各自实现一遍。
|
|
1618
|
+
*
|
|
1619
|
+
* 规则(按 semver 语义):
|
|
1620
|
+
* - `0.x` 阶段:契约未冻结,minor 变更即为破坏性 → **minor 必须相同**
|
|
1621
|
+
* - `>=1.0.0`:minor 是向后兼容的新增 → **major 相同即兼容**
|
|
1622
|
+
* - 任一侧版本号格式非法 → 不兼容
|
|
1623
|
+
*
|
|
1624
|
+
* patch 差异永远兼容。
|
|
1625
|
+
*
|
|
1626
|
+
* @param hostVersion 宿主侧 CONTRACT_VERSION
|
|
1627
|
+
* @param iframeVersion iframe 侧 CONTRACT_VERSION
|
|
1628
|
+
*
|
|
1629
|
+
* @example
|
|
1630
|
+
* isContractCompatible('0.1.0', '0.1.3') // true · patch 差异
|
|
1631
|
+
* isContractCompatible('0.1.0', '0.2.0') // false · 0.x 的 minor 是破坏性的
|
|
1632
|
+
* isContractCompatible('1.1.0', '1.4.0') // true · 1.x 的 minor 向后兼容
|
|
1633
|
+
* isContractCompatible('1.0.0', '2.0.0') // false · major 不同
|
|
1634
|
+
*/
|
|
1635
|
+
function isContractCompatible(hostVersion, iframeVersion) {
|
|
1636
|
+
const host = parseVersion(hostVersion);
|
|
1637
|
+
const iframe = parseVersion(iframeVersion);
|
|
1638
|
+
if (!host || !iframe) return false;
|
|
1639
|
+
if (host.major !== iframe.major) return false;
|
|
1640
|
+
if (host.major === 0) return host.minor === iframe.minor;
|
|
1641
|
+
return true;
|
|
1642
|
+
}
|
|
1643
|
+
//#endregion
|
|
1644
|
+
//#region src/envelope.ts
|
|
1645
|
+
/** 包裹类型 */
|
|
1646
|
+
const EnvelopeTypeSchema = zod.z.enum([
|
|
1647
|
+
"command",
|
|
1648
|
+
"event",
|
|
1649
|
+
"response",
|
|
1650
|
+
"error"
|
|
1651
|
+
]);
|
|
1652
|
+
/**
|
|
1653
|
+
* 所有 host ↔ iframe 消息的公共外壳。
|
|
1654
|
+
*
|
|
1655
|
+
* `id` 用于 request-response 关联:`response` / `error` 包裹**复用**对应
|
|
1656
|
+
* `command` 的 id,调用方靠它把响应对上是哪条命令。`event` 是主动上抛的,
|
|
1657
|
+
* 它的 id 不对应任何 command。
|
|
1658
|
+
*/
|
|
1659
|
+
const envelopeShape = {
|
|
1660
|
+
/** 发送方的 CONTRACT_VERSION */
|
|
1661
|
+
version: zod.z.string(),
|
|
1662
|
+
id: zod.z.string(),
|
|
1663
|
+
/** Unix ms */
|
|
1664
|
+
timestamp: zod.z.number()
|
|
1665
|
+
};
|
|
1666
|
+
/**
|
|
1667
|
+
* 用任意 payload schema 组一个 Envelope schema。
|
|
1668
|
+
*
|
|
1669
|
+
* `TType` 必须是**具体的**字面量类型(`'command'` 而不是 `EnvelopeType`),
|
|
1670
|
+
* 否则 {@link EnvelopeSchema} 在 TS 层就没法按 `type` 收窄——
|
|
1671
|
+
* 运行时 zod 照样能判别,但消费方写 `if (env.type === 'event')` 拿不到窄化后的 payload。
|
|
1672
|
+
*
|
|
1673
|
+
* @example
|
|
1674
|
+
* const MyEnvelope = createEnvelopeSchema('event', PlayerEventSchema)
|
|
1675
|
+
*/
|
|
1676
|
+
function createEnvelopeSchema(type, payload) {
|
|
1677
|
+
return zod.z.object({
|
|
1678
|
+
...envelopeShape,
|
|
1679
|
+
type: zod.z.literal(type),
|
|
1680
|
+
payload
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
/** 命令包裹(host → iframe) */
|
|
1684
|
+
const CommandEnvelopeSchema = createEnvelopeSchema("command", CommandSchema);
|
|
1685
|
+
/** 事件包裹(iframe → host) */
|
|
1686
|
+
const EventEnvelopeSchema = createEnvelopeSchema("event", PlayerEventSchema);
|
|
1687
|
+
/**
|
|
1688
|
+
* 响应包裹(iframe → host)。命令的结果一律是 void——
|
|
1689
|
+
* 播放状态通过事件回来,不塞在 response 里。
|
|
1690
|
+
*/
|
|
1691
|
+
const ResponseEnvelopeSchema = createEnvelopeSchema("response", zod.z.object({}));
|
|
1692
|
+
/** 错误包裹(iframe → host)。命令执行失败时替代 response 返回 */
|
|
1693
|
+
const ErrorEnvelopeSchema = createEnvelopeSchema("error", PlayerErrorSchema);
|
|
1694
|
+
/**
|
|
1695
|
+
* 任意包裹。收到消息时先用它 parse,再按 `type` 分支。
|
|
1696
|
+
*
|
|
1697
|
+
* 用 discriminatedUnion 而不是 union:错误信息能精确到具体分支,
|
|
1698
|
+
* 而不是把四个分支的失败原因全列一遍。
|
|
1699
|
+
*/
|
|
1700
|
+
const EnvelopeSchema = zod.z.discriminatedUnion("type", [
|
|
1701
|
+
CommandEnvelopeSchema,
|
|
1702
|
+
EventEnvelopeSchema,
|
|
1703
|
+
ResponseEnvelopeSchema,
|
|
1704
|
+
ErrorEnvelopeSchema
|
|
1705
|
+
]);
|
|
1706
|
+
/**
|
|
1707
|
+
* id 和 timestamp 由调用方传入,不在这里 `crypto.randomUUID()` / `Date.now()`——
|
|
1708
|
+
* protocol 是纯契约层,不产生副作用(见 packages/protocol/CLAUDE.md § 严禁做的)。
|
|
1709
|
+
* 好处是这几个函数完全可测,不用 mock 时间。
|
|
1710
|
+
*/
|
|
1711
|
+
function envelopeBase(meta) {
|
|
1712
|
+
return {
|
|
1713
|
+
version: meta.version ?? CONTRACT_VERSION,
|
|
1714
|
+
id: meta.id,
|
|
1715
|
+
timestamp: meta.timestamp
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
/** 构造命令包裹(host → iframe) */
|
|
1719
|
+
function commandEnvelope(payload, meta) {
|
|
1720
|
+
return {
|
|
1721
|
+
...envelopeBase(meta),
|
|
1722
|
+
type: "command",
|
|
1723
|
+
payload
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
/** 构造事件包裹(iframe → host) */
|
|
1727
|
+
function eventEnvelope(payload, meta) {
|
|
1728
|
+
return {
|
|
1729
|
+
...envelopeBase(meta),
|
|
1730
|
+
type: "event",
|
|
1731
|
+
payload
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
/** 构造响应包裹。`meta.id` 必须是对应 command 的 id */
|
|
1735
|
+
function responseEnvelope(meta) {
|
|
1736
|
+
return {
|
|
1737
|
+
...envelopeBase(meta),
|
|
1738
|
+
type: "response",
|
|
1739
|
+
payload: {}
|
|
1740
|
+
};
|
|
1741
|
+
}
|
|
1742
|
+
/** 构造错误包裹。`meta.id` 必须是对应 command 的 id */
|
|
1743
|
+
function errorEnvelope(payload, meta) {
|
|
1744
|
+
return {
|
|
1745
|
+
...envelopeBase(meta),
|
|
1746
|
+
type: "error",
|
|
1747
|
+
payload
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
//#endregion
|
|
1751
|
+
//#region src/locale.ts
|
|
1752
|
+
/**
|
|
1753
|
+
* 把 `LocaleConfig` 解析成"当前 locale + 一张扁平翻译表",供覆盖层的 `useT` 直接查。
|
|
1754
|
+
*
|
|
1755
|
+
* **为什么住在 protocol**:inline 面(`@video-lab/react`)和 iframe 面
|
|
1756
|
+
* (`apps/embed-app`)都要做这件事,而它俩没有别的公共依赖。此前各写了一份
|
|
1757
|
+
* `readLocale`,两份逐字相同 —— **四种接入方式行为漂移的经典来源**。
|
|
1758
|
+
* 先例:`resolvePreset` 同样是住在 protocol 的纯函数。
|
|
1759
|
+
*
|
|
1760
|
+
* **回退链是逐 key 合并,不是整表取第一个存在的。** 后者会让主语种缺一个 key 就整表
|
|
1761
|
+
* 退化成回退语种;而漏翻译通常是零星几条,不是整表缺失。
|
|
1762
|
+
*
|
|
1763
|
+
* **缺 key 时不做任何兜底** —— 链上都没有就让**渲染侧**原样返回 key。
|
|
1764
|
+
*
|
|
1765
|
+
* ⚠️ 这里曾经写着「见 `use-t.ts`」,而那个文件在 #120 · PR D 就随
|
|
1766
|
+
* `I18nProvider` / `useT()` 一起删掉了(player-ui 去 React 那次)。
|
|
1767
|
+
* 今天做这件事的是三个消费面各自的 `resolveText`
|
|
1768
|
+
*(`react/src/overlay-elements.tsx` / `vue/src/overlay-elements.ts` / `embed-app/src/app.ts`),
|
|
1769
|
+
* 实现都是 `messages[key] ?? key`。
|
|
1770
|
+
* 那是刻意的设计:漏翻译时界面上明晃晃出现 `error.network`,
|
|
1771
|
+
* 一眼可见;而静默替换成别的语种只会把问题藏起来。
|
|
1772
|
+
*
|
|
1773
|
+
* @example
|
|
1774
|
+
* resolveLocaleMessages({
|
|
1775
|
+
* locale: 'th-TH',
|
|
1776
|
+
* fallbackLocales: ['en-US'],
|
|
1777
|
+
* messages: { 'th-TH': { retry: 'ลองใหม่' }, 'en-US': { retry: 'Retry', close: 'Close' } },
|
|
1778
|
+
* })
|
|
1779
|
+
* // → { locale: 'th-TH', messages: { retry: 'ลองใหม่', close: 'Close' } }
|
|
1780
|
+
* // ↑ 主语种赢 ↑ 主语种没有,从回退链取
|
|
1781
|
+
*/
|
|
1782
|
+
function resolveLocaleMessages(locale) {
|
|
1783
|
+
if (!locale) return {
|
|
1784
|
+
locale: "en-US",
|
|
1785
|
+
messages: {}
|
|
1786
|
+
};
|
|
1787
|
+
if (typeof locale === "string") return {
|
|
1788
|
+
locale,
|
|
1789
|
+
messages: {}
|
|
1790
|
+
};
|
|
1791
|
+
const table = locale.messages;
|
|
1792
|
+
if (!table) return {
|
|
1793
|
+
locale: locale.locale,
|
|
1794
|
+
messages: {}
|
|
1795
|
+
};
|
|
1796
|
+
const chain = [...locale.fallbackLocales ?? []].reverse();
|
|
1797
|
+
chain.push(locale.locale);
|
|
1798
|
+
const messages = {};
|
|
1799
|
+
for (const tag of chain) Object.assign(messages, table[tag]);
|
|
1800
|
+
return {
|
|
1801
|
+
locale: locale.locale,
|
|
1802
|
+
messages
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
//#endregion
|
|
1806
|
+
//#region src/presets.ts
|
|
1807
|
+
/**
|
|
1808
|
+
* 场景预设表。权威来源:ARCHITECTURE.md § 8.7.3。
|
|
1809
|
+
*
|
|
1810
|
+
* **只保留一个预设 `homepage-preview`(ADR-032)**:预设的价值在于打包一组"不平凡"的字段组合,
|
|
1811
|
+
* 而首页/直播预览卡片正是这样的组合(静音循环自动播 + 无控件 + 不响应点击)——高频复用、
|
|
1812
|
+
* 手写六个字段易错。其余场景(如房内"自动播 + 静音")字段太少,直接写 prop 即可,不配拥有预设。
|
|
1813
|
+
* 曾经的 `sea-mobile` / `sea-tv` / `desktop` / `internal-admin` 已删(首发前收敛,见 ADR-032)。
|
|
1814
|
+
*
|
|
1815
|
+
* 注意:§ 8.7.3 里预览预设还写了 `danmaku` / `wakeLock` / `visibility` 字段,但**刻意不进 preset 默认**
|
|
1816
|
+
* ——弹幕是直播按需能力,由消费方显式开(M1.1 D1 决策:档 C);`wakeLock` / `visibility` 是 P0/P1
|
|
1817
|
+
* 插件的内部行为,不走契约配置。故此表只保留通用播放字段。
|
|
1818
|
+
*/
|
|
1819
|
+
const PRESETS = {
|
|
1820
|
+
/** 首页/直播预览卡片:静音循环自动播,无控件,不响应点击(透传给外层卡片) */
|
|
1821
|
+
"homepage-preview": {
|
|
1822
|
+
autoplay: true,
|
|
1823
|
+
muted: true,
|
|
1824
|
+
loop: true,
|
|
1825
|
+
controls: false,
|
|
1826
|
+
interactive: false,
|
|
1827
|
+
playsinline: true
|
|
1828
|
+
} };
|
|
1829
|
+
/**
|
|
1830
|
+
* 应用预设:预设提供默认值,消费方显式传的字段永远覆盖它。
|
|
1831
|
+
*
|
|
1832
|
+
* 只有 `undefined` 才算"没传"——`false` / `0` 都是有效的显式值,会覆盖预设。
|
|
1833
|
+
*
|
|
1834
|
+
* @param preset 预设名;`undefined` 时原样返回 config
|
|
1835
|
+
* @param config 消费方传入的配置(含 source)
|
|
1836
|
+
*
|
|
1837
|
+
* @example
|
|
1838
|
+
* resolvePreset('homepage-preview', { source: 'a.m3u8', autoplay: false })
|
|
1839
|
+
* // → autoplay: false(显式值赢),muted: true(来自预设)
|
|
1840
|
+
*/
|
|
1841
|
+
function resolvePreset(preset, config) {
|
|
1842
|
+
if (!preset) return config;
|
|
1843
|
+
const merged = { ...PRESETS[preset] };
|
|
1844
|
+
for (const [key, value] of Object.entries(config)) if (value !== void 0) merged[key] = value;
|
|
1845
|
+
return merged;
|
|
1846
|
+
}
|
|
1847
|
+
//#endregion
|
|
1848
|
+
exports.CONTRACT_VERSION = CONTRACT_VERSION;
|
|
1849
|
+
exports.CommandEnvelopeSchema = CommandEnvelopeSchema;
|
|
1850
|
+
exports.CommandSchema = CommandSchema;
|
|
1851
|
+
exports.DanmakuConfigSchema = DanmakuConfigSchema;
|
|
1852
|
+
exports.DanmakuItemSchema = DanmakuItemSchema;
|
|
1853
|
+
exports.ERROR_META = ERROR_META;
|
|
1854
|
+
exports.EnvelopeSchema = EnvelopeSchema;
|
|
1855
|
+
exports.EnvelopeTypeSchema = EnvelopeTypeSchema;
|
|
1856
|
+
exports.ErrorCategorySchema = ErrorCategorySchema;
|
|
1857
|
+
exports.ErrorCauseSchema = ErrorCauseSchema;
|
|
1858
|
+
exports.ErrorCodeSchema = ErrorCodeSchema;
|
|
1859
|
+
exports.ErrorEnvelopeSchema = ErrorEnvelopeSchema;
|
|
1860
|
+
exports.EventEnvelopeSchema = EventEnvelopeSchema;
|
|
1861
|
+
exports.FirstFramePayloadSchema = FirstFramePayloadSchema;
|
|
1862
|
+
exports.FrameFreezePayloadSchema = FrameFreezePayloadSchema;
|
|
1863
|
+
exports.HlsConfigSchema = HlsConfigSchema;
|
|
1864
|
+
exports.LocaleConfigSchema = LocaleConfigSchema;
|
|
1865
|
+
exports.MediaMetadataSchema = MediaMetadataSchema;
|
|
1866
|
+
exports.MediaSourceSchema = MediaSourceSchema;
|
|
1867
|
+
exports.MediaTypeSchema = MediaTypeSchema;
|
|
1868
|
+
exports.MultiSourceObjectSchema = MultiSourceObjectSchema;
|
|
1869
|
+
exports.PLAYER_DESTROYED_MESSAGE = PLAYER_DESTROYED_MESSAGE;
|
|
1870
|
+
exports.PRESETS = PRESETS;
|
|
1871
|
+
exports.PauseImageConfigSchema = PauseImageConfigSchema;
|
|
1872
|
+
exports.PlayableReasonSchema = PlayableReasonSchema;
|
|
1873
|
+
exports.PlaybackContextSchema = PlaybackContextSchema;
|
|
1874
|
+
exports.PlaybackKernelSchema = PlaybackKernelSchema;
|
|
1875
|
+
exports.PlaybackRuntimeSchema = PlaybackRuntimeSchema;
|
|
1876
|
+
exports.PlayerConfigSchema = PlayerConfigSchema;
|
|
1877
|
+
exports.PlayerErrorSchema = PlayerErrorSchema;
|
|
1878
|
+
exports.PlayerEventSchema = PlayerEventSchema;
|
|
1879
|
+
exports.PosterConfigSchema = PosterConfigSchema;
|
|
1880
|
+
exports.PresetNameSchema = PresetNameSchema;
|
|
1881
|
+
exports.QualityLevelSchema = QualityLevelSchema;
|
|
1882
|
+
exports.RecoveryPayloadSchema = RecoveryPayloadSchema;
|
|
1883
|
+
exports.ResponseEnvelopeSchema = ResponseEnvelopeSchema;
|
|
1884
|
+
exports.SingleSourceObjectSchema = SingleSourceObjectSchema;
|
|
1885
|
+
exports.SourceEntrySchema = SourceEntrySchema;
|
|
1886
|
+
exports.SourceEntryTypeSchema = SourceEntryTypeSchema;
|
|
1887
|
+
exports.SourceRoutePayloadSchema = SourceRoutePayloadSchema;
|
|
1888
|
+
exports.StreamKindSchema = StreamKindSchema;
|
|
1889
|
+
exports.SubtitleTrackInfoSchema = SubtitleTrackInfoSchema;
|
|
1890
|
+
exports.SubtitleTrackSchema = SubtitleTrackSchema;
|
|
1891
|
+
exports.USER_ACTION_ALLOWLIST = USER_ACTION_ALLOWLIST;
|
|
1892
|
+
exports.UserActionPayloadSchema = UserActionPayloadSchema;
|
|
1893
|
+
exports.WarningCodeSchema = WarningCodeSchema;
|
|
1894
|
+
exports.commandEnvelope = commandEnvelope;
|
|
1895
|
+
exports.createEnvelopeSchema = createEnvelopeSchema;
|
|
1896
|
+
exports.errorEnvelope = errorEnvelope;
|
|
1897
|
+
exports.eventEnvelope = eventEnvelope;
|
|
1898
|
+
exports.isContractCompatible = isContractCompatible;
|
|
1899
|
+
exports.makeEventRejectedWarning = makeEventRejectedWarning;
|
|
1900
|
+
exports.makePlayerError = makePlayerError;
|
|
1901
|
+
exports.normalizeFrameFreeze = normalizeFrameFreeze;
|
|
1902
|
+
exports.normalizeUserAction = normalizeUserAction;
|
|
1903
|
+
exports.parseVersion = parseVersion;
|
|
1904
|
+
exports.redactSourceUrl = redactSourceUrl;
|
|
1905
|
+
exports.resolveLocaleMessages = resolveLocaleMessages;
|
|
1906
|
+
exports.resolvePreset = resolvePreset;
|
|
1907
|
+
exports.responseEnvelope = responseEnvelope;
|
|
1908
|
+
exports.serializeCause = serializeCause;
|
|
1909
|
+
exports.toErrorEvent = toErrorEvent;
|