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