@video-lab/protocol 3.1.0 → 4.0.1

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/dist/index.cjs CHANGED
@@ -25,18 +25,17 @@ const PosterConfigSchema = zod.z.union([zod.z.string(), zod.z.object({
25
25
  ]).default("cover").optional()
26
26
  })]);
27
27
  /**
28
- * 暂停时盖在画面中央的静态图(ADR-043 / issue #121)。
28
+ * 暂停时盖在画面中央的静态图(ADR-043 / ADR-126)。
29
29
  *
30
30
  * 形状**刻意与 {@link PosterConfigSchema} 对齐**(同为 `string | { url, fit, … }`),
31
31
  * 消费方不用学两套。
32
32
  *
33
- * **渲染在 iframe 内,与封面相反** —— 封面为了抢首帧挪到了宿主侧,暂停图不抢首帧
34
- *(暂停发生时 iframe 早已 ready),放宿主侧反而让静态 iframe 那条路拿不到,撞 cross-mode-parity。
33
+ * 默认图片在 iframe 内渲染;静态 iframe 由此也能使用。框架组件传入自定义
34
+ * render / slot 时由宿主 DOM 渲染,iframe 配置省略本字段(ADR-127)。
35
35
  *
36
- * **边界(ADR-025 已判的那条线)**:静态图 + 可选关闭按钮进 SDK —— 可序列化、无调度逻辑,
36
+ * **边界(ADR-025 已判的那条线)**:静态图与继续播放按钮进 SDK —— 可序列化、无调度逻辑,
37
37
  * 与 `poster` 同性质;**带倒计时 / 跳转 / 推荐列表的不进** —— 那是业务调度,归团队层。
38
- * inline 模式另有 `<sentinel-pause>` 的默认 slot(能塞任意节点),表达力更强但
39
- * 过不了 postMessage,所以三条 iframe 路只有本字段有效。
38
+ * 四个框架组件另有具名自定义视觉入口;函数和节点不进入本协议。
40
39
  */
41
40
  const PauseImageConfigSchema = zod.z.union([zod.z.string(), zod.z.object({
42
41
  url: zod.z.string(),
@@ -52,10 +51,13 @@ const PauseImageConfigSchema = zod.z.union([zod.z.string(), zod.z.object({
52
51
  "cover",
53
52
  "contain",
54
53
  "fill"
55
- ]).optional(),
56
- /** 给图加一个关闭按钮,用户点了本次播放不再显示。默认 `false` */
57
- closable: zod.z.boolean().optional()
58
- })]);
54
+ ]).optional()
55
+ }).strict()]);
56
+ /** 默认错误层动作按钮的配色;跨 iframe 边界传递字面 CSS 颜色值。 */
57
+ const ErrorButtonColorsSchema = zod.z.object({
58
+ background: zod.z.string().min(1).optional(),
59
+ foreground: zod.z.string().min(1).optional()
60
+ });
59
61
  /** 字幕轨 */
60
62
  const SubtitleTrackSchema = zod.z.discriminatedUnion("mode", [zod.z.object({
61
63
  mode: zod.z.literal("url"),
@@ -158,7 +160,7 @@ const SingleSourceObjectSchema = zod.z.object({
158
160
  /**
159
161
  * 多源对象。直播 FLV + HLS 双路兜底的标准形态。
160
162
  *
161
- * ⚠️ FLV 单源在 iOS / 微信 / UC / 夸克 下必然抛 `E_MEDIA_NOT_SUPPORTED`,
163
+ * ⚠️ 当前 SDK 收到 FLV 单源且运行在 iOS / 微信 / UC / 夸克时会抛 `E_MEDIA_NOT_SUPPORTED`,
162
164
  * **FLV 永远和 HLS 一起放进 sources 数组**。
163
165
  */
164
166
  const MultiSourceObjectSchema = zod.z.object({
@@ -169,9 +171,9 @@ const MultiSourceObjectSchema = zod.z.object({
169
171
  * 媒体源。三种写法:URL 字符串(最简)、单源对象、多源对象。
170
172
  *
171
173
  * **这是 wire schema——只含可 JSON 序列化的字段**。
172
- * ⛔ **`source.onBeforeRequest` 已废弃(ADR-057),下个 major 删除** ——
173
- * 它不在这里、也不在任何 Schema 里,而且 player-core 里从来没有消费点(#332)。
174
- * 认证走签名 URL + 长有效期(ADR-022)。见 {@link SourceRequestHook}。
174
+ * ⛔ **`source.onBeforeRequest` 与其类型已在 4.0.0 移除(ADR-057)** ——
175
+ * 它从来不在任何 Schema 里,player-core 里也没有消费点(#332)。
176
+ * 认证走签名 URL + 长有效期(ADR-022)。
175
177
  */
176
178
  const MediaSourceSchema = zod.z.union([
177
179
  zod.z.string(),
@@ -183,18 +185,18 @@ const LocaleConfigSchema = zod.z.union([zod.z.string(), zod.z.object({
183
185
  locale: zod.z.string(),
184
186
  fallbackLocales: zod.z.array(zod.z.string()).optional(),
185
187
  /**
186
- * 翻译表。**SDK 不内置任何翻译,只提供注入机制** —— 覆盖层文案
187
- *(loading / error.* / retry / close)全部由消费方从这里传入。
188
+ * 翻译表。覆盖层内置中 / 英默认文案,消费方可从这里逐 key 覆盖
189
+ *(loading / loading.<reason> / play / error.* / retry / reconnect)。
188
190
  * 消费面用 `resolveLocaleMessages` 把它解析成扁平表,再把解析好的**字符串**
189
- * 传给覆盖层元素;缺 key 时原样返回 key(可见失败,不抛错)。
191
+ * 传给覆盖层元素;标准覆盖层缺 key 时使用中 / 英默认值。
190
192
  *
191
193
  * **两个命名空间,同一张表**:
192
- * - 覆盖层:`loading` / `retry` / `close` / `error.<CODE>` —— **无内置翻译**
194
+ * - 覆盖层:`loading.*` / `play` / `retry` / `reconnect` / `error.<CODE>` —— 内置中 / 英
193
195
  * - 播放内核自带控件:`controls.*` —— 内置中 / 英,你传的会**覆盖**(#208 / ADR-051)
194
196
  *
195
- * 语言标签本身仍只有中 / 英两档(`zh-*` → 中文,其余 → 英文,见 player-core 的 `toXgLang`),
196
- * 但**那不再限制能翻成什么语言**:传 `locale:'vi-VN'` + `messages['vi-VN']['controls.play']`,
197
- * 控件就是越南语 —— 标签落在 `en` 那一档,而那一档的文案被换掉了。
197
+ * xgplayer 的内部槽位只有中 / 英两档(`zh-CN` → 中文,其他 → 英文,见 player-core 的 `toXgLang`),
198
+ * 但显示语言不限于这两种:`vi-VN` 可使用官方语言包,第三语言的控件文案写入
199
+ * 当前实例的英文槽位,不污染其他播放器。
198
200
  * `controls.*` 的 key 全集见 USER-GUIDE § 9.4;**表外的静默忽略**。
199
201
  *
200
202
  * ⚠️ 这段从前写着「控件不读 messages」—— 那是 #208 修掉的现状,别照着旧描述下结论。
@@ -202,7 +204,7 @@ const LocaleConfigSchema = zod.z.union([zod.z.string(), zod.z.object({
202
204
  messages: zod.z.record(zod.z.string(), zod.z.record(zod.z.string(), zod.z.string())).optional()
203
205
  })]);
204
206
  /**
205
- * 弹幕单条(streaming push 用)。见 ADR-028 / ARCHITECTURE § 8.3.7。
207
+ * 弹幕单条(streaming push 用)。长期取舍见 ADR-028。
206
208
  *
207
209
  * 直播场景:业务从自己的数据源(WebSocket / 轮询,SDK 不参与)拿到一条,
208
210
  * 经 `pushDanmaku` 命令喂进来,到点即显(不绑视频时间)。
@@ -239,7 +241,7 @@ const DanmakuItemSchema = zod.z.object({
239
241
  start: zod.z.number().nonnegative().optional()
240
242
  });
241
243
  /**
242
- * 弹幕配置(PlayerConfig.danmaku,默认关闭)。见 ADR-028 / ARCHITECTURE § 8.3.7。
244
+ * 弹幕配置(PlayerConfig.danmaku,默认关闭)。长期取舍见 ADR-028。
243
245
  *
244
246
  * **两种模式**:`streaming`(直播,`pushDanmaku` 逐条)/ `preload`(点播,挂载时整包给,
245
247
  * 引擎按每条的 `start` 排期,见 #275)。ADR-028 当初只做 streaming,preload 是它说的「后续 minor」。
@@ -301,7 +303,7 @@ const DanmakuConfigSchema = zod.z.object({
301
303
  *
302
304
  * 定义在这里而不是 presets.ts,是为了让 PlayerConfig 能引用它而不产生循环依赖。
303
305
  */
304
- const PresetNameSchema = zod.z.enum(["homepage-preview"]);
306
+ const PresetNameSchema = zod.z.enum(["ambient-preview"]);
305
307
  /** 内置控制栏的初始化显示策略。false 隐藏入口;true/省略仍遵守平台能力。 */
306
308
  const ControlVisibilityConfigSchema = zod.z.object({
307
309
  play: zod.z.boolean().optional(),
@@ -370,8 +372,12 @@ const PlayerConfigSchema = zod.z.object({
370
372
  controls: zod.z.boolean().optional(),
371
373
  /** 初始化时是否显示默认错误覆盖层。默认开启;关闭不影响错误事件和恢复。 */
372
374
  showErrorOverlay: zod.z.boolean().optional(),
373
- /** 运行时 Loading 展示归属;false 仅隐藏视觉/ARIA,恢复仍由 SDK 执行。 */
374
- showLoadingOverlay: zod.z.boolean().optional(),
375
+ /** 是否显示 SDK 默认运行时 Loading;false 仅隐藏内置视觉/ARIA,不影响状态事件和恢复。 */
376
+ showDefaultLoadingOverlay: zod.z.boolean().optional(),
377
+ /** 在 SDK 默认 Loading 转圈下显示当前状态文案;默认 false,随默认 Loading 一起隐藏。 */
378
+ showLoadingText: zod.z.boolean().optional(),
379
+ /** 默认错误动作按钮配色;不改变状态判断或动作。 */
380
+ errorButtonColors: ErrorButtonColorsSchema.optional(),
375
381
  /** 独立隐藏内置控件;只在创建时生效,修改后由宿主显式重建。 */
376
382
  controlVisibility: ControlVisibilityConfigSchema.optional(),
377
383
  /** 是否响应用户交互。首页预览卡片用 false,点击透传给外层卡片 */
@@ -399,6 +405,26 @@ const PlayerConfigSchema = zod.z.object({
399
405
  debug: zod.z.boolean().optional()
400
406
  });
401
407
  //#endregion
408
+ //#region src/delivery.ts
409
+ /**
410
+ * 一次播放器事件的生产端投递证据。
411
+ *
412
+ * `sequence` 只在同一个 `producerSessionId` 内严格递增;`deliveryId` 也只承诺在
413
+ * 该会话内唯一。`occurredAtMs` 是生产端的墙钟,不是宿主接收时间,不能跨设备相减。
414
+ * 消费者可用 `(producerSessionId, sequence)` 排序、用
415
+ * `(producerSessionId, deliveryId)` 去重,并拒绝旧会话的迟到事件。
416
+ */
417
+ const PlayerEventDeliverySchema = zod.z.object({
418
+ /** 每次 player/frame producer 生命周期唯一的会话标识。 */
419
+ producerSessionId: zod.z.string().min(1),
420
+ /** 在该 producer session 内唯一且重传时保持稳定的投递标识。 */
421
+ deliveryId: zod.z.string().min(1),
422
+ /** 生产端创建该事件时读取的 Unix ms 墙钟。 */
423
+ occurredAtMs: zod.z.number().finite(),
424
+ /** 在该 producer session 内从 1 开始严格递增的序号。 */
425
+ sequence: zod.z.number().int().positive()
426
+ });
427
+ //#endregion
402
428
  //#region src/errors.ts
403
429
  /**
404
430
  * 错误分类。player-core 把 xgplayer 的内部错误映射到这几类,
@@ -414,7 +440,7 @@ const ErrorCategorySchema = zod.z.enum([
414
440
  ]);
415
441
  /**
416
442
  * 全部错误码。命名规则 `E_<CATEGORY>_<SPECIFIC>`。
417
- * 权威来源:ARCHITECTURE.md § 8.6。
443
+ * 权威来源:本文件;人类可读说明见 docs/protocol/errors.md。
418
444
  *
419
445
  * **每一个码都必须有真实发射点**(ADR-034)。`player-core/tests/contract-coverage.test.ts`
420
446
  * 静态扫描全仓库生产代码强制这条 —— 加码不加实现,测试直接红。
@@ -566,6 +592,19 @@ const ErrorCauseSchema = zod.z.object({
566
592
  ]));
567
593
  /** `String(x)` 的截断上限 —— cause 是旁路信息,不该把 envelope 撑大 */
568
594
  const CAUSE_MESSAGE_MAX = 500;
595
+ /** 匹配字符串里出现的 http(s) URL,用于把 query/fragment 剥掉,只留 origin+pathname */
596
+ const URL_PATTERN = /https?:\/\/\S+/g;
597
+ /** 剥掉字符串里每个 URL 的 query 串和 fragment —— 签名 token 常年住在 query 里 */
598
+ function stripUrlQuery(value) {
599
+ return value.replace(URL_PATTERN, (match) => {
600
+ const cut = match.search(/[?#]/);
601
+ return cut === -1 ? match : match.slice(0, cut);
602
+ });
603
+ }
604
+ /** 对可能非字符串的值套用 {@link stripUrlQuery};非字符串原样返回 */
605
+ function stripUrlQueryIfString(value) {
606
+ return typeof value === "string" ? stripUrlQuery(value) : value;
607
+ }
569
608
  function readNumber(source, key) {
570
609
  const v = source[key];
571
610
  return typeof v === "number" && Number.isFinite(v) ? v : void 0;
@@ -587,20 +626,21 @@ function readNumber(source, key) {
587
626
  function serializeCause(cause) {
588
627
  if (cause === null || typeof cause !== "object") return {
589
628
  name: "Unknown",
590
- message: String(cause).slice(0, CAUSE_MESSAGE_MAX)
629
+ message: stripUrlQuery(String(cause)).slice(0, CAUSE_MESSAGE_MAX)
591
630
  };
592
631
  const obj = cause;
593
632
  const media = obj.mediaError ?? void 0;
594
- const name = typeof obj.name === "string" ? obj.name : media ? "MediaError" : typeof obj.errorType === "string" ? obj.errorType : "Unknown";
633
+ const name = stripUrlQueryIfString(typeof obj.name === "string" ? obj.name : media ? "MediaError" : typeof obj.errorType === "string" ? obj.errorType : "Unknown");
595
634
  const rawMessage = typeof obj.message === "string" ? obj.message : typeof media?.message === "string" ? media.message : String(cause);
596
635
  const code = readNumber(obj, "code") ?? (media ? readNumber(media, "code") : void 0);
597
636
  const status = readNumber(obj, "status") ?? readNumber(obj, "httpCode");
598
637
  const extras = {};
599
- for (const [k, v] of Object.entries(obj)) if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") extras[k] = v;
638
+ for (const [k, v] of Object.entries(obj)) if (typeof v === "string") extras[k] = stripUrlQuery(v);
639
+ else if (typeof v === "number" || typeof v === "boolean") extras[k] = v;
600
640
  return {
601
641
  ...extras,
602
642
  name,
603
- message: rawMessage.slice(0, CAUSE_MESSAGE_MAX),
643
+ message: stripUrlQuery(rawMessage).slice(0, CAUSE_MESSAGE_MAX),
604
644
  ...code === void 0 ? {} : { code },
605
645
  ...status === void 0 ? {} : { status }
606
646
  };
@@ -638,7 +678,7 @@ function makePlayerError(code, message, cause) {
638
678
  const meta = ERROR_META[code];
639
679
  return {
640
680
  code,
641
- message,
681
+ message: stripUrlQuery(message),
642
682
  category: meta.category,
643
683
  retryable: meta.retryable,
644
684
  ...cause === void 0 ? {} : { cause: serializeCause(cause) }
@@ -801,8 +841,9 @@ function normalizeUserAction(raw) {
801
841
  const r = raw;
802
842
  const action = r.action;
803
843
  if (typeof action !== "string" || !ALLOWED.has(action)) return null;
804
- const first = (Array.isArray(r.props) ? r.props : [])[0];
805
- const change = typeof first === "string" ? r : typeof first === "object" && first !== null ? first : null;
844
+ const rawProps = r.props;
845
+ const first = (Array.isArray(rawProps) ? rawProps : [])[0];
846
+ const change = typeof first === "string" ? r : typeof first === "object" && first !== null ? first : rawProps === void 0 && typeof r.prop === "string" ? r : null;
806
847
  return {
807
848
  action,
808
849
  source: typeof r.pluginName === "string" ? r.pluginName : "player",
@@ -1059,7 +1100,11 @@ zod.z.enum([
1059
1100
  "failed",
1060
1101
  "cancelled"
1061
1102
  ]);
1062
- /** 策略变化共享 episode,不创建新的预算。 */
1103
+ /**
1104
+ * 策略变化共享 episode,不创建新的预算。
1105
+ * 报的是该次动作**实际执行**的动作:请求时的意图若这一次做不到(如非 hls.js 内核无法做
1106
+ * 媒体修复),按实际动作上报(ADR-109)。
1107
+ */
1063
1108
  const RecoveryStrategySchema = zod.z.enum([
1064
1109
  "reconnect",
1065
1110
  "media_recovery",
@@ -1071,7 +1116,8 @@ const RecoveryTriggerSchema = zod.z.enum([
1071
1116
  "manual",
1072
1117
  "visibility",
1073
1118
  "stall",
1074
- "startup_timeout"
1119
+ "startup_timeout",
1120
+ "ended"
1075
1121
  ]);
1076
1122
  const base = {
1077
1123
  sessionId: zod.z.string().min(1),
@@ -1132,6 +1178,50 @@ const RecoveryPayloadSchema = zod.z.discriminatedUnion("phase", [
1132
1178
  path: ["attempt"]
1133
1179
  });
1134
1180
  //#endregion
1181
+ //#region src/source-switch.ts
1182
+ /**
1183
+ * 自动授权换源的低频事务事实;只放可安全聚合的闭集枚举与数值。
1184
+ *
1185
+ * @platform static-iframe unsupported:静态 URL 无法传入 `ResolveSource` 函数,
1186
+ * 因而不产生本事务;宿主应在 `error(E_AUTH_EXPIRED)` 后替换整个 iframe。
1187
+ */
1188
+ const SourceSwitchPayloadSchema = zod.z.discriminatedUnion("phase", [
1189
+ zod.z.object({
1190
+ phase: zod.z.literal("started"),
1191
+ switchId: zod.z.number().int().positive(),
1192
+ trigger: zod.z.literal("auth_expired"),
1193
+ httpStatus: zod.z.union([zod.z.literal(401), zod.z.literal(403)]).optional()
1194
+ }).strict(),
1195
+ zod.z.object({
1196
+ phase: zod.z.literal("succeeded"),
1197
+ switchId: zod.z.number().int().positive(),
1198
+ strategy: zod.z.enum(["hot_load", "rebuild"]),
1199
+ elapsedMs: zod.z.number().nonnegative()
1200
+ }).strict(),
1201
+ zod.z.object({
1202
+ phase: zod.z.literal("failed"),
1203
+ switchId: zod.z.number().int().positive(),
1204
+ reason: zod.z.enum([
1205
+ "resolver_null",
1206
+ "resolver_rejected",
1207
+ "load_rejected",
1208
+ "replacement_error"
1209
+ ]),
1210
+ elapsedMs: zod.z.number().nonnegative(),
1211
+ errorCode: ErrorCodeSchema.optional()
1212
+ }).strict(),
1213
+ zod.z.object({
1214
+ phase: zod.z.literal("cancelled"),
1215
+ switchId: zod.z.number().int().positive(),
1216
+ reason: zod.z.enum([
1217
+ "external_source",
1218
+ "destroyed",
1219
+ "unmounted"
1220
+ ]),
1221
+ elapsedMs: zod.z.number().nonnegative()
1222
+ }).strict()
1223
+ ]);
1224
+ //#endregion
1135
1225
  //#region src/events.ts
1136
1226
  /**
1137
1227
  * 清晰度档位。`level` 是索引,传给 `setQuality` 命令用。
@@ -1160,7 +1250,7 @@ const SubtitleTrackInfoSchema = zod.z.object({
1160
1250
  * 「现在能不能正常出画面」的原因枚举({@link PlayerEventSchema} 的 `playablechange`)。
1161
1251
  *
1162
1252
  * 优先级(高的压低的,同时命中时报最严重的那个):
1163
- * `error` > `frame_disconnected` > `autoplay_blocked` > `reconnecting` > `stalled`
1253
+ * `error` > `frame_disconnected` > `autoplay_blocked` > `source_switching` > `reconnecting` > `stalled`
1164
1254
  * > `buffering` > `initializing` > `degraded` > `ok`
1165
1255
  *
1166
1256
  * **命名**:枚举值用 `snake_case`,与事件名(全小写连写)是两套命名空间 ——
@@ -1173,6 +1263,7 @@ const PlayableReasonSchema = zod.z.enum([
1173
1263
  "buffering",
1174
1264
  "stalled",
1175
1265
  "reconnecting",
1266
+ "source_switching",
1176
1267
  "autoplay_blocked",
1177
1268
  "error",
1178
1269
  "frame_disconnected",
@@ -1185,18 +1276,22 @@ const PlayableReasonSchema = zod.z.enum([
1185
1276
  * 这是 wire 上的名字;消费面各自映射——Vue emit `time-update`,React prop `onTimeUpdate`。
1186
1277
  * (团队约定里的 `snake.case` 指的是埋点事件名如 `playback.start`,那是另一套命名空间。)
1187
1278
  *
1188
- * 权威来源:docs/protocol/events.md + ARCHITECTURE.md § 8.4。
1279
+ * 权威来源:本 Zod schema;docs/protocol/events.md 是人类可读说明。
1189
1280
  */
1281
+ const eventObject = (shape) => zod.z.object({
1282
+ ...shape,
1283
+ delivery: PlayerEventDeliverySchema.optional()
1284
+ });
1190
1285
  const PlayerEventSchema = zod.z.discriminatedUnion("event", [
1191
- zod.z.object({
1286
+ eventObject({
1192
1287
  event: zod.z.literal("pagefullscreenchange"),
1193
1288
  payload: PageFullscreenStateSchema
1194
1289
  }),
1195
- zod.z.object({
1290
+ eventObject({
1196
1291
  event: zod.z.literal("sourceroute"),
1197
1292
  payload: SourceRoutePayloadSchema
1198
1293
  }),
1199
- zod.z.object({
1294
+ eventObject({
1200
1295
  event: zod.z.literal("ready"),
1201
1296
  payload: zod.z.object({
1202
1297
  duration: zod.z.number(),
@@ -1211,19 +1306,19 @@ const PlayerEventSchema = zod.z.discriminatedUnion("event", [
1211
1306
  subtitles: zod.z.array(SubtitleTrackInfoSchema).optional()
1212
1307
  })
1213
1308
  }),
1214
- zod.z.object({
1309
+ eventObject({
1215
1310
  event: zod.z.literal("play"),
1216
1311
  payload: zod.z.object({})
1217
1312
  }),
1218
- zod.z.object({
1313
+ eventObject({
1219
1314
  event: zod.z.literal("pause"),
1220
1315
  payload: zod.z.object({})
1221
1316
  }),
1222
- zod.z.object({
1317
+ eventObject({
1223
1318
  event: zod.z.literal("ended"),
1224
1319
  payload: zod.z.object({})
1225
1320
  }),
1226
- zod.z.object({
1321
+ eventObject({
1227
1322
  event: zod.z.literal("timeupdate"),
1228
1323
  /**
1229
1324
  * ~250ms 一次。
@@ -1237,30 +1332,30 @@ const PlayerEventSchema = zod.z.discriminatedUnion("event", [
1237
1332
  duration: zod.z.number()
1238
1333
  })
1239
1334
  }),
1240
- zod.z.object({
1335
+ eventObject({
1241
1336
  event: zod.z.literal("volumechange"),
1242
1337
  payload: zod.z.object({
1243
1338
  volume: zod.z.number(),
1244
1339
  muted: zod.z.boolean()
1245
1340
  })
1246
1341
  }),
1247
- zod.z.object({
1342
+ eventObject({
1248
1343
  event: zod.z.literal("seeking"),
1249
1344
  payload: zod.z.object({ time: zod.z.number() })
1250
1345
  }),
1251
- zod.z.object({
1346
+ eventObject({
1252
1347
  event: zod.z.literal("seeked"),
1253
1348
  payload: zod.z.object({ time: zod.z.number() })
1254
1349
  }),
1255
- zod.z.object({
1350
+ eventObject({
1256
1351
  event: zod.z.literal("waiting"),
1257
1352
  payload: zod.z.object({})
1258
1353
  }),
1259
- zod.z.object({
1354
+ eventObject({
1260
1355
  event: zod.z.literal("playing"),
1261
1356
  payload: zod.z.object({})
1262
1357
  }),
1263
- zod.z.object({
1358
+ eventObject({
1264
1359
  event: zod.z.literal("qualitychange"),
1265
1360
  payload: zod.z.object({
1266
1361
  level: zod.z.number(),
@@ -1268,25 +1363,29 @@ const PlayerEventSchema = zod.z.discriminatedUnion("event", [
1268
1363
  auto: zod.z.boolean()
1269
1364
  })
1270
1365
  }),
1271
- zod.z.object({
1366
+ eventObject({
1272
1367
  event: zod.z.literal("subtitlechange"),
1273
1368
  payload: zod.z.object({
1274
1369
  /** 当前激活字幕轨 id(= {@link SubtitleTrackInfo} 的 `id`);`null` = 字幕已关闭 */
1275
1370
  id: zod.z.number().nullable() })
1276
1371
  }),
1277
- zod.z.object({
1372
+ eventObject({
1278
1373
  event: zod.z.literal("error"),
1279
1374
  payload: PlayerErrorSchema
1280
1375
  }),
1281
- zod.z.object({
1376
+ eventObject({
1282
1377
  event: zod.z.literal("autoplayblocked"),
1283
1378
  payload: zod.z.object({})
1284
1379
  }),
1285
- zod.z.object({
1380
+ eventObject({
1286
1381
  event: zod.z.literal("recovery"),
1287
1382
  payload: RecoveryPayloadSchema
1288
1383
  }),
1289
- zod.z.object({
1384
+ eventObject({
1385
+ event: zod.z.literal("sourceswitch"),
1386
+ payload: SourceSwitchPayloadSchema
1387
+ }),
1388
+ eventObject({
1290
1389
  event: zod.z.literal("compatwarning"),
1291
1390
  payload: zod.z.object({
1292
1391
  code: WarningCodeSchema,
@@ -1316,7 +1415,7 @@ id: zod.z.number().nullable() })
1316
1415
  rejectedEvent: zod.z.string().optional()
1317
1416
  })
1318
1417
  }),
1319
- zod.z.object({
1418
+ eventObject({
1320
1419
  event: zod.z.literal("stalled"),
1321
1420
  payload: zod.z.object({
1322
1421
  phase: zod.z.enum(["start", "end"]),
@@ -1341,7 +1440,7 @@ id: zod.z.number().nullable() })
1341
1440
  ]).optional()
1342
1441
  })
1343
1442
  }),
1344
- zod.z.object({
1443
+ eventObject({
1345
1444
  event: zod.z.literal("playablechange"),
1346
1445
  payload: zod.z.object({
1347
1446
  /** 能不能正常出画面。消费方只读这一个字段就够 */
@@ -1369,7 +1468,7 @@ id: zod.z.number().nullable() })
1369
1468
  ]).optional()
1370
1469
  })
1371
1470
  }),
1372
- zod.z.object({
1471
+ eventObject({
1373
1472
  event: zod.z.literal("kernelhealth"),
1374
1473
  payload: zod.z.object({
1375
1474
  /**
@@ -1409,7 +1508,7 @@ id: zod.z.number().nullable() })
1409
1508
  detail: zod.z.string()
1410
1509
  })
1411
1510
  }),
1412
- zod.z.object({
1511
+ eventObject({
1413
1512
  event: zod.z.literal("audiohealth"),
1414
1513
  payload: zod.z.object({
1415
1514
  degraded: zod.z.boolean(),
@@ -1417,7 +1516,7 @@ id: zod.z.number().nullable() })
1417
1516
  durationMs: zod.z.number()
1418
1517
  })
1419
1518
  }),
1420
- zod.z.object({
1519
+ eventObject({
1421
1520
  event: zod.z.literal("bufferhealth"),
1422
1521
  payload: zod.z.object({
1423
1522
  /**
@@ -1446,19 +1545,19 @@ id: zod.z.number().nullable() })
1446
1545
  marginSec: zod.z.number()
1447
1546
  })
1448
1547
  }),
1449
- zod.z.object({
1548
+ eventObject({
1450
1549
  event: zod.z.literal("contextchange"),
1451
1550
  payload: PlaybackContextSchema
1452
1551
  }),
1453
- zod.z.object({
1552
+ eventObject({
1454
1553
  event: zod.z.literal("firstframe"),
1455
1554
  payload: FirstFramePayloadSchema
1456
1555
  }),
1457
- zod.z.object({
1556
+ eventObject({
1458
1557
  event: zod.z.literal("framefreeze"),
1459
1558
  payload: FrameFreezePayloadSchema
1460
1559
  }),
1461
- zod.z.object({
1560
+ eventObject({
1462
1561
  event: zod.z.literal("useraction"),
1463
1562
  payload: UserActionPayloadSchema
1464
1563
  })
@@ -1510,33 +1609,11 @@ function toErrorEvent(err) {
1510
1609
  };
1511
1610
  }
1512
1611
  //#endregion
1513
- //#region src/delivery.ts
1514
- /**
1515
- * 一次已送达播放器事件的生产端证据。
1516
- *
1517
- * `sequence` 只在同一个 `producerSessionId` 内严格递增;`deliveryId` 也只承诺在
1518
- * 该会话内唯一。`occurredAtMs` 是生产端的墙钟,不是宿主接收时间,不能跨设备相减。
1519
- * 消费者可用 `(producerSessionId, sequence)` 排序、用
1520
- * `(producerSessionId, deliveryId)` 去重,并拒绝旧会话的迟到事件。
1521
- */
1522
- const DeliveredPlayerEventSchema = zod.z.object({
1523
- /** 未变形的公开业务事件;旧 raw 出口继续单独交付它。 */
1524
- event: PlayerEventSchema,
1525
- /** 每次 player/frame producer 生命周期唯一的会话标识。 */
1526
- producerSessionId: zod.z.string().min(1),
1527
- /** 在该 producer session 内唯一且重传时保持稳定的投递标识。 */
1528
- deliveryId: zod.z.string().min(1),
1529
- /** 生产端创建该事件时读取的 Unix ms 墙钟。 */
1530
- occurredAtMs: zod.z.number().finite(),
1531
- /** 在该 producer session 内从 1 开始严格递增的序号。 */
1532
- sequence: zod.z.number().int().positive()
1533
- });
1534
- //#endregion
1535
1612
  //#region src/methods.ts
1536
1613
  /**
1537
1614
  * 命令。host → iframe(iframe 模式),或消费方 → player-core(inline 模式)。
1538
1615
  *
1539
- * 权威来源:docs/protocol/methods.md + ARCHITECTURE.md § 8.5。
1616
+ * 权威来源:本 Zod schema;docs/protocol/methods.md 是人类可读说明。
1540
1617
  * 每条命令的响应都是 `void`——结果通过事件回来,不通过 response payload。
1541
1618
  */
1542
1619
  const CommandSchema = zod.z.discriminatedUnion("method", [
@@ -1591,9 +1668,9 @@ const CommandSchema = zod.z.discriminatedUnion("method", [
1591
1668
  * 消费方通常不直接调它 —— 各消费面 watch `locale` prop 后自动下发,
1592
1669
  * 改 prop 即可切换(见 ADR-035)。
1593
1670
  *
1594
- * 作用于两处:①播放内核自带控件的文案(**仅中 / 英**,`zh-*` → 中文,其余 → 英文);
1595
- * ②player-ui 覆盖层文案(读 `messages`,语言不限)。
1596
- * 传对象形式可同时换掉两者;传字符串只切控件语言、沿用原有 messages。
1671
+ * 作用于两处:①播放内核自带控件的文案(官方中 / 英或注入的第三语言);
1672
+ * ②player-ui 覆盖层文案(解析官方资源及 `messages`)。
1673
+ * 传字符串可选内置语言;第三语言需传带资源表的对象。
1597
1674
  */
1598
1675
  params: zod.z.object({ locale: LocaleConfigSchema })
1599
1676
  }),
@@ -1639,7 +1716,7 @@ const CommandSchema = zod.z.discriminatedUnion("method", [
1639
1716
  * 唯一来源是 contract-version.json(ADR-094);只在真实协议变化时升级。
1640
1717
  * npm 包版本同步不能改变握手与 envelope 的版本。
1641
1718
  */
1642
- const CONTRACT_VERSION = "2.0.0";
1719
+ const CONTRACT_VERSION = "4.0.0";
1643
1720
  const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
1644
1721
  /**
1645
1722
  * 解析 semver 字符串。只接受严格的 `major.minor.patch`,
@@ -1813,77 +1890,249 @@ function errorEnvelope(payload, meta) {
1813
1890
  };
1814
1891
  }
1815
1892
  //#endregion
1816
- //#region src/locale.ts
1893
+ //#region src/error-action.ts
1894
+ /** 只接受终态、错误码与组件能力都支持的动作;旧生产者缺 action 时不猜测。 */
1895
+ function effectiveErrorAction(input) {
1896
+ const { reason, recoverable, action, errorCode } = input;
1897
+ if (recoverable || !action || action === "none") return "none";
1898
+ if (reason === "autoplay_blocked") return action === "play" && errorCode === "E_AUTOPLAY_BLOCKED" ? "play" : "none";
1899
+ if (reason === "frame_disconnected") return action === "recreate-frame" && input.canRecreateFrame && (errorCode === "E_LOAD_FAILED" || errorCode === "E_HANDSHAKE_TIMEOUT" || errorCode === "E_FRAME_CRASHED") ? "recreate-frame" : "none";
1900
+ if (reason !== "error") return "none";
1901
+ if (action === "replace-source") return errorCode === "E_AUTH_EXPIRED" && input.canResolveSource ? action : "none";
1902
+ if (action === "retry") return errorCode === "E_NETWORK" || errorCode === "E_NETWORK_TIMEOUT" || errorCode === "E_MEDIA_DECODE" || errorCode === "E_MEDIA_ABORTED" ? action : "none";
1903
+ return "none";
1904
+ }
1905
+ /** 有效动作对应的官方文案 key;网络重连与媒体重试保留不同动词。 */
1906
+ function errorActionMessageKey(action, errorCode) {
1907
+ switch (action) {
1908
+ case "play": return "play";
1909
+ case "retry": return errorCode === "E_NETWORK" || errorCode === "E_NETWORK_TIMEOUT" ? "reconnect" : "retry";
1910
+ case "replace-source": return "refreshSource";
1911
+ case "recreate-frame": return "recreatePlayer";
1912
+ default: return null;
1913
+ }
1914
+ }
1915
+ //#endregion
1916
+ //#region src/error-copy.ts
1817
1917
  /**
1818
- * 把 `LocaleConfig` 解析成"当前 locale + 一张扁平翻译表",供覆盖层的 `useT` 直接查。
1918
+ * 错误码 → 面向终端用户的英文兜底文案(#116)。另有简体中文表。
1819
1919
  *
1820
- * **为什么住在 protocol**:inline 面(`@video-lab/react`)和 iframe 面
1821
- * (`apps/embed-app`)都要做这件事,而它俩没有别的公共依赖。此前各写了一份
1822
- * `readLocale`,两份逐字相同 —— **四种接入方式行为漂移的经典来源**。
1823
- * 先例:`resolvePreset` 同样是住在 protocol 的纯函数。
1920
+ * ## 为什么要有它——这是对「SDK 不自带文案」的一处例外
1824
1921
  *
1825
- * **回退链是逐 key 合并,不是整表取第一个存在的。** 后者会让主语种缺一个 key 就整表
1826
- * 退化成回退语种;而漏翻译通常是零星几条,不是整表缺失。
1922
+ * `locale.ts` 立的规矩是「缺 key 就原样露出 key」,理由是漏翻译要一眼可见。那条规矩对
1923
+ * 普通 UI 文案成立:露出 `retry` 只是难看。**错误文案不一样**:覆盖层原本回退到
1924
+ * `PlayerError.message`,而那个 message 是内核串——实测 403 鉴权失败时,屏幕上显示的是
1925
+ * `内核错误:networkError / levelLoadError`:错误码明明是 `E_AUTH_EXPIRED`、category 是
1926
+ * `auth`,给用户看的字却在说「网络错误」,还把 hls.js 的内部 `ErrorDetails` 抛给了终端用户。
1927
+ * 露 key 只是难看,**露内核串是误导**——用户照着它去查网络,而正确动作是重新鉴权。
1827
1928
  *
1828
- * **缺 key 时不做任何兜底** —— 链上都没有就让**渲染侧**原样返回 key。
1929
+ * ## 语言范围
1829
1930
  *
1830
- * ⚠️ 这里曾经写着「见 `use-t.ts`」,而那个文件在 #120 · PR D 就随
1831
- * `I18nProvider` / `useT()` 一起删掉了(player-ui 去 React 那次)。
1832
- * 今天做这件事的是三个消费面各自的 `resolveText`
1833
- *(`react/src/overlay-elements.tsx` / `vue/src/overlay-elements.ts` / `embed-app/src/app.ts`),
1834
- * 实现都是 `messages[key] ?? key`。
1835
- * 那是刻意的设计:漏翻译时界面上明晃晃出现 `error.network`,
1836
- * 一眼可见;而静默替换成别的语种只会把问题藏起来。
1931
+ * SDK 内置简体中文和英文,其他语言以及品牌语气由宿主通过 `locale.messages` 提供。
1837
1932
  *
1838
- * @example
1839
- * resolveLocaleMessages({
1840
- * locale: 'th-TH',
1841
- * fallbackLocales: ['en-US'],
1842
- * messages: { 'th-TH': { retry: 'ลองใหม่' }, 'en-US': { retry: 'Retry', close: 'Close' } },
1843
- * })
1844
- * // → { locale: 'th-TH', messages: { retry: 'ลองใหม่', close: 'Close' } }
1845
- * // ↑ 主语种赢 ↑ 主语种没有,从回退链取
1933
+ * ## 宿主怎么覆盖
1934
+ *
1935
+ * 在 `locale.messages` 里提供 `error.<CODE>`(如 `error.E_AUTH_EXPIRED`)即可,优先级高于
1936
+ * 本表。四个消费包的 README 有完整 key 清单。
1937
+ *
1938
+ * ## 写作口径(改文案时照这三条审)
1939
+ *
1940
+ * 1. **不出现内核术语**(`networkError` / `levelLoadError` / `MediaError` 这类);
1941
+ * 2. **不误导用户去做错的事**——鉴权失效不能说成网络问题;
1942
+ * 3. **给得出下一步**,或至少说明这次能不能重试(与 `ERROR_META.retryable` 一致)。
1943
+ */
1944
+ const DEFAULT_ERROR_MESSAGES_EN = {
1945
+ E_NETWORK: "Network problem. Check your connection and try again.",
1946
+ E_NETWORK_TIMEOUT: "The connection timed out. Check your network and try again.",
1947
+ E_LOAD_FAILED: "Could not load the video. Check your connection and try again.",
1948
+ E_HANDSHAKE_TIMEOUT: "The player took too long to start. Try again.",
1949
+ E_AUTH_EXPIRED: "Your access to this video has expired. Reload the page to continue.",
1950
+ E_MEDIA_DECODE: "The video could not be decoded. Try again.",
1951
+ E_MEDIA_ABORTED: "Playback was interrupted. Try again.",
1952
+ E_MEDIA_NOT_SUPPORTED: "This video format cannot be played here.",
1953
+ E_MANIFEST_PARSE: "The video could not be read. Try again later.",
1954
+ E_SUBTITLE_LOAD_FAILED: "Subtitles could not be loaded. Playback continues without them.",
1955
+ E_AUTOPLAY_BLOCKED: "Tap play to start the video.",
1956
+ E_ENV_CSP_BLOCKED: "This page blocked the player from loading.",
1957
+ E_METHOD_NOT_SUPPORTED: "This action is not available here.",
1958
+ E_DANMAKU_SEND_FAILED: "Your message could not be sent. Try again.",
1959
+ E_PLAYER_DESTROYED: "The player is no longer available. Reload the page.",
1960
+ E_FRAME_CRASHED: "The player stopped unexpectedly. Reload the page.",
1961
+ E_HANDSHAKE_VERSION_MISMATCH: "The player version does not match. Reload the page.",
1962
+ E_INTERNAL: "Something went wrong in the player. Reload the page.",
1963
+ E_UNKNOWN: "Playback ran into a problem. Try again later."
1964
+ };
1965
+ /** 简体中文默认错误提示;其他语言可通过 `locale.messages` 覆盖。 */
1966
+ const DEFAULT_ERROR_MESSAGES_ZH = {
1967
+ E_NETWORK: "网络连接异常,请检查网络后重试。",
1968
+ E_NETWORK_TIMEOUT: "连接超时,请检查网络后重试。",
1969
+ E_LOAD_FAILED: "视频加载失败,请检查网络后重试。",
1970
+ E_HANDSHAKE_TIMEOUT: "播放器连接超时,请重试。",
1971
+ E_AUTH_EXPIRED: "播放地址已失效,请获取新的播放地址。",
1972
+ E_MEDIA_DECODE: "视频解码失败,请重试播放。",
1973
+ E_MEDIA_ABORTED: "播放已中断,请重试。",
1974
+ E_MEDIA_NOT_SUPPORTED: "当前设备无法播放此视频格式。",
1975
+ E_MANIFEST_PARSE: "视频内容无法读取,请稍后再试或更换播放源。",
1976
+ E_SUBTITLE_LOAD_FAILED: "字幕加载失败,视频仍可继续播放。",
1977
+ E_AUTOPLAY_BLOCKED: "请点击播放以继续。",
1978
+ E_ENV_CSP_BLOCKED: "当前页面阻止了播放器加载。",
1979
+ E_METHOD_NOT_SUPPORTED: "当前无法执行此操作。",
1980
+ E_DANMAKU_SEND_FAILED: "弹幕发送失败,请重试。",
1981
+ E_PLAYER_DESTROYED: "播放器已关闭,请重新打开。",
1982
+ E_FRAME_CRASHED: "播放器连接已断开,请重新打开播放器。",
1983
+ E_HANDSHAKE_VERSION_MISMATCH: "播放器版本不匹配,请刷新页面。",
1984
+ E_INTERNAL: "播放器发生异常,请刷新页面。",
1985
+ E_UNKNOWN: "播放遇到问题,请稍后再试。"
1986
+ };
1987
+ /**
1988
+ * 解析错误文案:宿主翻译 → 当前语言的中 / 英兜底。
1989
+ *
1990
+ * **不再回退到 `PlayerError.message`** —— 那是内核串,给用户看会误导(见本文件头注释)。
1991
+ * 排查需要的原始信息仍在 `PlayerError.cause` 里(`errorType` / `errorDetails` / `status`)。
1992
+ *
1993
+ * `onFallback` 供消费面在开发构建里提示宿主「这个码你没配翻译」,保住 `locale.ts`
1994
+ * 「漏翻译要一眼可见」的原意,又不让线上用户吃这个亏。
1846
1995
  */
1996
+ function resolveErrorMessage(messages, code, onFallback, locale = "zh-CN") {
1997
+ const key = `error.${code}`;
1998
+ const translated = messages[key];
1999
+ if (typeof translated === "string" && translated.length > 0) return translated;
2000
+ onFallback?.(key);
2001
+ const defaults = /^en(?:[-_]|$)/i.test(locale) ? DEFAULT_ERROR_MESSAGES_EN : DEFAULT_ERROR_MESSAGES_ZH;
2002
+ return defaults[code] ?? defaults[fallbackCodeFor(code)];
2003
+ }
2004
+ /** 未知码按 `E_UNKNOWN` 兜底;已知码但没写文案(理论上不会发生)按同类兜底 */
2005
+ function fallbackCodeFor(code) {
2006
+ const meta = ERROR_META[code];
2007
+ if (meta === void 0) return "E_UNKNOWN";
2008
+ return meta.category === "network" ? "E_NETWORK" : "E_UNKNOWN";
2009
+ }
2010
+ //#endregion
2011
+ //#region src/official-messages.ts
2012
+ const enOverlay = {
2013
+ loading: "Loading…",
2014
+ "loading.initializing": "Starting player…",
2015
+ "loading.buffering": "Buffering…",
2016
+ "loading.stalled": "Restoring playback…",
2017
+ "loading.reconnecting": "Reconnecting…",
2018
+ "loading.source_switching": "Switching video source…",
2019
+ "loading.frame_disconnected": "Connecting to player…",
2020
+ play: "Play video",
2021
+ retry: "Retry playback",
2022
+ reconnect: "Reconnect",
2023
+ refreshSource: "Get a new playback URL",
2024
+ recreatePlayer: "Reconnect player"
2025
+ };
2026
+ const zhOverlay = {
2027
+ loading: "加载中…",
2028
+ "loading.initializing": "正在初始化播放器…",
2029
+ "loading.buffering": "正在缓冲…",
2030
+ "loading.stalled": "正在恢复播放…",
2031
+ "loading.reconnecting": "正在重新连接…",
2032
+ "loading.source_switching": "正在换源…",
2033
+ "loading.frame_disconnected": "正在连接播放器…",
2034
+ play: "播放视频",
2035
+ retry: "重试播放",
2036
+ reconnect: "重新连接",
2037
+ refreshSource: "重新获取播放地址",
2038
+ recreatePlayer: "重新连接播放器"
2039
+ };
2040
+ const enControls = {
2041
+ "controls.play": "Play",
2042
+ "controls.pause": "Pause",
2043
+ "controls.replay": "Replay",
2044
+ "controls.fullscreen.enter": "Fullscreen",
2045
+ "controls.fullscreen.exit": "Exit fullscreen",
2046
+ "controls.pageFullscreen.enter": "CSS fullscreen",
2047
+ "controls.pageFullscreen.exit": "Exit CSS fullscreen",
2048
+ "controls.pip": "Picture in picture",
2049
+ "controls.subtitle": "Captions",
2050
+ "controls.subtitle.off": "Off",
2051
+ "controls.live": "Live",
2052
+ "controls.rotate": "Rotate"
2053
+ };
2054
+ const zhControls = {
2055
+ "controls.play": "播放",
2056
+ "controls.pause": "暂停",
2057
+ "controls.replay": "重播",
2058
+ "controls.fullscreen.enter": "进入全屏",
2059
+ "controls.fullscreen.exit": "退出全屏",
2060
+ "controls.pageFullscreen.enter": "进入样式全屏",
2061
+ "controls.pageFullscreen.exit": "退出样式全屏",
2062
+ "controls.pip": "画中画",
2063
+ "controls.subtitle": "字幕",
2064
+ "controls.subtitle.off": "关闭",
2065
+ "controls.live": "直播",
2066
+ "controls.rotate": "旋转"
2067
+ };
2068
+ const errorEntries = (table) => Object.fromEntries(Object.entries(table).map(([code, message]) => [`error.${code}`, message]));
2069
+ /** 播放器内置的完整简体中文文案,也是官方资源 key 清单的来源。 */
2070
+ const zhCN = {
2071
+ ...zhOverlay,
2072
+ ...errorEntries(DEFAULT_ERROR_MESSAGES_ZH),
2073
+ ...zhControls
2074
+ };
2075
+ /** 播放器内置的完整英文文案。 */
2076
+ const enUS = {
2077
+ ...enOverlay,
2078
+ ...errorEntries(DEFAULT_ERROR_MESSAGES_EN),
2079
+ ...enControls
2080
+ };
2081
+ /** 官方资源必须覆盖的播放器自有文案 key。 */
2082
+ const PLAYER_MESSAGE_KEYS = Object.keys(zhCN);
2083
+ /** 只归一官方标签及其别名;未知地区变体保留原样。 */
2084
+ function normalizeLocaleTag(tag) {
2085
+ const normalized = tag.replaceAll("_", "-").toLowerCase();
2086
+ if (normalized === "zh" || normalized === "zh-cn") return "zh-CN";
2087
+ if (normalized === "en" || normalized === "en-us") return "en-US";
2088
+ if (normalized === "vi" || normalized === "vi-vn") return "vi-VN";
2089
+ return tag;
2090
+ }
2091
+ /** protocol 不依赖按需语言包;越南语由宿主注入。 */
2092
+ function builtInMessages(tag) {
2093
+ const normalized = normalizeLocaleTag(tag);
2094
+ if (normalized === "zh-CN") return zhCN;
2095
+ if (normalized === "en-US") return enUS;
2096
+ }
2097
+ //#endregion
2098
+ //#region src/locale.ts
2099
+ /** 覆盖层文案:有效资源表 → 旧版通用 loading → 官方中文。 */
2100
+ function resolveOverlayMessage(messages, key, locale = "zh-CN") {
2101
+ if (messages[key]) return messages[key];
2102
+ if (key.startsWith("loading.") && messages.loading) return messages.loading;
2103
+ return builtInMessages(locale)?.[key] ?? zhCN[key] ?? key;
2104
+ }
2105
+ /** 按当前语言、显式回退链、官方中文的优先级逐 key 合成实例快照。 */
1847
2106
  function resolveLocaleMessages(locale) {
1848
- if (!locale) return {
1849
- locale: "en-US",
1850
- messages: {}
1851
- };
1852
- if (typeof locale === "string") return {
1853
- locale,
1854
- messages: {}
1855
- };
1856
- const table = locale.messages;
1857
- if (!table) return {
1858
- locale: locale.locale,
1859
- messages: {}
1860
- };
1861
- const chain = [...locale.fallbackLocales ?? []].reverse();
1862
- chain.push(locale.locale);
1863
- const messages = {};
1864
- for (const tag of chain) Object.assign(messages, table[tag]);
2107
+ const tag = normalizeLocaleTag((typeof locale === "string" ? locale : locale?.locale) || "zh-CN");
2108
+ const fallbackLocales = typeof locale === "object" ? locale.fallbackLocales ?? [] : [];
2109
+ const custom = typeof locale === "object" ? locale.messages : void 0;
2110
+ const tables = /* @__PURE__ */ new Map();
2111
+ for (const [key, value] of Object.entries(custom ?? {})) tables.set(normalizeLocaleTag(key), value);
2112
+ const messages = Object.create(null);
2113
+ const chain = [
2114
+ "zh-CN",
2115
+ ...fallbackLocales.map(normalizeLocaleTag).reverse(),
2116
+ tag
2117
+ ].filter((value, index, entries) => entries.lastIndexOf(value) === index);
2118
+ for (const current of chain) {
2119
+ Object.assign(messages, builtInMessages(current));
2120
+ const entries = tables.get(current);
2121
+ if (!entries) continue;
2122
+ const genericLoading = entries.loading;
2123
+ if (genericLoading) {
2124
+ for (const key of Object.keys(zhCN)) if (key.startsWith("loading.") && !entries[key]) messages[key] = genericLoading;
2125
+ }
2126
+ for (const [key, value] of Object.entries(entries)) if (value) messages[key] = value;
2127
+ }
1865
2128
  return {
1866
- locale: locale.locale,
2129
+ locale: tag,
1867
2130
  messages
1868
2131
  };
1869
2132
  }
1870
- //#endregion
1871
- //#region src/presets.ts
1872
- /**
1873
- * 场景预设表。权威来源:ARCHITECTURE.md § 8.7.3。
1874
- *
1875
- * **只保留一个预设 `homepage-preview`(ADR-032)**:预设的价值在于打包一组"不平凡"的字段组合,
1876
- * 而首页/直播预览卡片正是这样的组合(静音循环自动播 + 无控件 + 不响应点击)——高频复用、
1877
- * 手写六个字段易错。其余场景(如房内"自动播 + 静音")字段太少,直接写 prop 即可,不配拥有预设。
1878
- * 曾经的 `sea-mobile` / `sea-tv` / `desktop` / `internal-admin` 已删(首发前收敛,见 ADR-032)。
1879
- *
1880
- * 注意:§ 8.7.3 里预览预设还写了 `danmaku` / `wakeLock` / `visibility` 字段,但**刻意不进 preset 默认**
1881
- * ——弹幕是直播按需能力,由消费方显式开(M1.1 D1 决策:档 C);`wakeLock` / `visibility` 是 P0/P1
1882
- * 插件的内部行为,不走契约配置。故此表只保留通用播放字段。
1883
- */
1884
2133
  const PRESETS = {
1885
- /** 首页/直播预览卡片:静音循环自动播,无控件,不响应点击(透传给外层卡片) */
1886
- "homepage-preview": {
2134
+ /** 环境式预览:静音循环自动播,无控件,不响应点击(透传给外层容器) */
2135
+ "ambient-preview": {
1887
2136
  autoplay: true,
1888
2137
  muted: true,
1889
2138
  loop: true,
@@ -1900,7 +2149,7 @@ const PRESETS = {
1900
2149
  * @param config 消费方传入的配置(含 source)
1901
2150
  *
1902
2151
  * @example
1903
- * resolvePreset('homepage-preview', { source: 'a.m3u8', autoplay: false })
2152
+ * resolvePreset('ambient-preview', { source: 'a.m3u8', autoplay: false })
1904
2153
  * // → autoplay: false(显式值赢),muted: true(来自预设)
1905
2154
  */
1906
2155
  function resolvePreset(preset, config) {
@@ -1910,16 +2159,76 @@ function resolvePreset(preset, config) {
1910
2159
  return merged;
1911
2160
  }
1912
2161
  //#endregion
2162
+ //#region src/provider-config.ts
2163
+ function mergeFrameDefaults(parent = {}, child = {}) {
2164
+ const merged = { ...parent };
2165
+ for (const [key, value] of Object.entries(child)) if (value !== void 0) merged[key] = value;
2166
+ return merged;
2167
+ }
2168
+ const OBJECT_FIELDS = ["errorButtonColors", "controlVisibility"];
2169
+ /** 父子 Provider 合成;只有两种配置对象按子字段继承。 */
2170
+ function mergePlayerDefaults(parent = {}, child = {}) {
2171
+ const merged = { ...parent };
2172
+ for (const [key, value] of Object.entries(child)) {
2173
+ if (value === void 0) continue;
2174
+ if (OBJECT_FIELDS.includes(key)) merged[key] = {
2175
+ ...parent[key],
2176
+ ...value
2177
+ };
2178
+ else merged[key] = value;
2179
+ }
2180
+ return merged;
2181
+ }
2182
+ /** 实例显式值 > 实例 preset > Provider;保留 null 供组件隐藏图片。 */
2183
+ function applyPlayerDefaults(instance, defaults = {}) {
2184
+ const preset = instance.preset ? PRESETS[instance.preset] : void 0;
2185
+ const result = { ...defaults };
2186
+ for (const [key, value] of Object.entries(preset ?? {})) if (value !== void 0) result[key] = value;
2187
+ for (const [key, value] of Object.entries(instance)) {
2188
+ if (value === void 0) continue;
2189
+ if (OBJECT_FIELDS.includes(key)) result[key] = {
2190
+ ...defaults[key],
2191
+ ...value
2192
+ };
2193
+ else result[key] = value;
2194
+ }
2195
+ return result;
2196
+ }
2197
+ /** 资源按语言和 key 合并,避免内层只覆写一条时丢掉外层其余翻译。 */
2198
+ function mergeMessageResources(parent = {}, child = {}) {
2199
+ const merged = Object.create(null);
2200
+ for (const [tag, messages] of [...Object.entries(parent), ...Object.entries(child)]) {
2201
+ const normalized = normalizeLocaleTag(tag);
2202
+ merged[normalized] = {
2203
+ ...merged[normalized],
2204
+ ...messages
2205
+ };
2206
+ }
2207
+ return merged;
2208
+ }
2209
+ /** 按 #125 的优先级把 Provider 与单实例资源交给现有 locale 解析器。 */
2210
+ function applyProviderLocale(instance, providerLocale, resources = {}, overrides = {}, fallbackLocales = []) {
2211
+ const instanceConfig = typeof instance === "string" ? { locale: instance } : instance;
2212
+ const messages = mergeMessageResources(mergeMessageResources(resources, overrides), instanceConfig?.messages);
2213
+ return {
2214
+ locale: instanceConfig?.locale ?? providerLocale,
2215
+ fallbackLocales: instanceConfig?.fallbackLocales ?? fallbackLocales,
2216
+ messages
2217
+ };
2218
+ }
2219
+ //#endregion
1913
2220
  exports.CONTRACT_VERSION = CONTRACT_VERSION;
1914
2221
  exports.CommandEnvelopeSchema = CommandEnvelopeSchema;
1915
2222
  exports.CommandSchema = CommandSchema;
1916
2223
  exports.ControlVisibilityConfigSchema = ControlVisibilityConfigSchema;
2224
+ exports.DEFAULT_ERROR_MESSAGES_EN = DEFAULT_ERROR_MESSAGES_EN;
2225
+ exports.DEFAULT_ERROR_MESSAGES_ZH = DEFAULT_ERROR_MESSAGES_ZH;
1917
2226
  exports.DanmakuConfigSchema = DanmakuConfigSchema;
1918
2227
  exports.DanmakuItemSchema = DanmakuItemSchema;
1919
- exports.DeliveredPlayerEventSchema = DeliveredPlayerEventSchema;
1920
2228
  exports.ERROR_META = ERROR_META;
1921
2229
  exports.EnvelopeSchema = EnvelopeSchema;
1922
2230
  exports.EnvelopeTypeSchema = EnvelopeTypeSchema;
2231
+ exports.ErrorButtonColorsSchema = ErrorButtonColorsSchema;
1923
2232
  exports.ErrorCategorySchema = ErrorCategorySchema;
1924
2233
  exports.ErrorCauseSchema = ErrorCauseSchema;
1925
2234
  exports.ErrorCodeSchema = ErrorCodeSchema;
@@ -1934,6 +2243,7 @@ exports.MediaSourceSchema = MediaSourceSchema;
1934
2243
  exports.MediaTypeSchema = MediaTypeSchema;
1935
2244
  exports.MultiSourceObjectSchema = MultiSourceObjectSchema;
1936
2245
  exports.PLAYER_DESTROYED_MESSAGE = PLAYER_DESTROYED_MESSAGE;
2246
+ exports.PLAYER_MESSAGE_KEYS = PLAYER_MESSAGE_KEYS;
1937
2247
  exports.PRESETS = PRESETS;
1938
2248
  exports.PageFullscreenControlSchema = PageFullscreenControlSchema;
1939
2249
  exports.PageFullscreenMessageSchema = PageFullscreenMessageSchema;
@@ -1945,6 +2255,7 @@ exports.PlaybackKernelSchema = PlaybackKernelSchema;
1945
2255
  exports.PlaybackRuntimeSchema = PlaybackRuntimeSchema;
1946
2256
  exports.PlayerConfigSchema = PlayerConfigSchema;
1947
2257
  exports.PlayerErrorSchema = PlayerErrorSchema;
2258
+ exports.PlayerEventDeliverySchema = PlayerEventDeliverySchema;
1948
2259
  exports.PlayerEventSchema = PlayerEventSchema;
1949
2260
  exports.PosterConfigSchema = PosterConfigSchema;
1950
2261
  exports.PresetNameSchema = PresetNameSchema;
@@ -1955,25 +2266,38 @@ exports.SingleSourceObjectSchema = SingleSourceObjectSchema;
1955
2266
  exports.SourceEntrySchema = SourceEntrySchema;
1956
2267
  exports.SourceEntryTypeSchema = SourceEntryTypeSchema;
1957
2268
  exports.SourceRoutePayloadSchema = SourceRoutePayloadSchema;
2269
+ exports.SourceSwitchPayloadSchema = SourceSwitchPayloadSchema;
1958
2270
  exports.StreamKindSchema = StreamKindSchema;
1959
2271
  exports.SubtitleTrackInfoSchema = SubtitleTrackInfoSchema;
1960
2272
  exports.SubtitleTrackSchema = SubtitleTrackSchema;
1961
2273
  exports.USER_ACTION_ALLOWLIST = USER_ACTION_ALLOWLIST;
1962
2274
  exports.UserActionPayloadSchema = UserActionPayloadSchema;
1963
2275
  exports.WarningCodeSchema = WarningCodeSchema;
2276
+ exports.applyPlayerDefaults = applyPlayerDefaults;
2277
+ exports.applyProviderLocale = applyProviderLocale;
1964
2278
  exports.commandEnvelope = commandEnvelope;
1965
2279
  exports.createEnvelopeSchema = createEnvelopeSchema;
2280
+ exports.effectiveErrorAction = effectiveErrorAction;
2281
+ exports.enUS = enUS;
2282
+ exports.errorActionMessageKey = errorActionMessageKey;
1966
2283
  exports.errorEnvelope = errorEnvelope;
1967
2284
  exports.eventEnvelope = eventEnvelope;
1968
2285
  exports.isContractCompatible = isContractCompatible;
1969
2286
  exports.makeEventRejectedWarning = makeEventRejectedWarning;
1970
2287
  exports.makePlayerError = makePlayerError;
2288
+ exports.mergeFrameDefaults = mergeFrameDefaults;
2289
+ exports.mergeMessageResources = mergeMessageResources;
2290
+ exports.mergePlayerDefaults = mergePlayerDefaults;
1971
2291
  exports.normalizeFrameFreeze = normalizeFrameFreeze;
2292
+ exports.normalizeLocaleTag = normalizeLocaleTag;
1972
2293
  exports.normalizeUserAction = normalizeUserAction;
1973
2294
  exports.parseVersion = parseVersion;
1974
2295
  exports.redactSourceUrl = redactSourceUrl;
2296
+ exports.resolveErrorMessage = resolveErrorMessage;
1975
2297
  exports.resolveLocaleMessages = resolveLocaleMessages;
2298
+ exports.resolveOverlayMessage = resolveOverlayMessage;
1976
2299
  exports.resolvePreset = resolvePreset;
1977
2300
  exports.responseEnvelope = responseEnvelope;
1978
2301
  exports.serializeCause = serializeCause;
1979
2302
  exports.toErrorEvent = toErrorEvent;
2303
+ exports.zhCN = zhCN;