@video-lab/protocol 1.0.1 → 3.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @video-lab/protocol
2
2
 
3
- Video Lab Player 的契约层(L4)。定义 host ↔ iframe 的通信契约,**所有其他包依赖它,它不依赖任何内部包**。
3
+ Video Lab Player 的通信契约层。它定义 host 与 iframe 的消息格式,其他 Video Lab 包依赖它,而它不依赖其他 Video Lab 包。
4
4
 
5
5
  唯一外部依赖:`zod`。
6
6
 
@@ -10,11 +10,12 @@ Video Lab Player 的契约层(L4)。定义 host ↔ iframe 的通信契约,**所
10
10
  |:---|:---|
11
11
  | `version` | `CONTRACT_VERSION` + 版本兼容判定 |
12
12
  | `envelope` | 通信包裹:`command` / `event` / `response` / `error` |
13
- | `methods` | 16 条命令(play / pause / seek / load / setSubtitle / pushDanmaku / …) |
14
- | `events` | 29 个事件(sourceroute / ready / timeupdate / error / reconnectstart / subtitlechange / stalled / …) |
13
+ | `methods` | 17 条命令(play / pause / seek / load / setSubtitle / pushDanmaku / …) |
14
+ | `events` | 27 个事件(sourceroute / ready / timeupdate / error / recovery / subtitlechange / stalled / …) |
15
+ | `delivery` | `DeliveredPlayerEvent`:带生产端 session、稳定 ID、发生时间与序号的完整事件投递证据 |
15
16
  | `errors` | 29 个错误码 + 2 个警告码 + `PlayerError` |
16
17
  | `configs` | `MediaSource`、`PlayerConfig`、poster / locale / danmaku / controls |
17
- | `presets` | 1 个场景预设(`homepage-preview`,ADR-032)+ `resolvePreset` |
18
+ | `presets` | 场景预设(如 `homepage-preview`)与 `resolvePreset` |
18
19
 
19
20
  类型一律从 Zod schema 用 `z.infer` 推导,不手写 interface——避免 schema 和类型变成两套真相。
20
21
 
@@ -49,19 +50,21 @@ const config = resolvePreset('homepage-preview', { source: 'a.m3u8', autoplay: f
49
50
 
50
51
  ## 几个容易踩的点
51
52
 
52
- **事件名是全小写连写**(`timeupdate` / `autoplayblocked`),对齐 HTML5 媒体事件。这是 wire 上的名字;消费面各自映射——Vue emit `time-update`,React prop `onTimeUpdate`。(团队约定里的 `snake.case` 指埋点事件名如 `playback.start`,是另一套命名空间。)
53
+ **事件名是全小写连写**(`timeupdate` / `autoplayblocked`),对齐 HTML5 媒体事件。这是通信层名称;Vue 映射为 `time-update`,React 映射为 `onTimeUpdate`。
53
54
 
54
- **`retryable` 是给自动重连看的信号**,不是"用户能不能点重试按钮"。判断标准是:同样的请求原样再发一次,有没有可能得到不同结果。所以 `E_AUTH_EXPIRED` 是 `false`——SDK 不做签名刷新(ADR-022),原样重试必然再次失败。
55
+ **完整事件流优先使用 `DeliveredPlayerEvent`**。它在兼容的新增 outlet 上提供 `producerSessionId`、`deliveryId`、`occurredAtMs`、`sequence`,适合跨重连排序与去重;旧 `PlayerEvent` 回调保持原样。旧版 iframe 可以省略该证据,宿主仍会收到 raw 事件。
56
+
57
+ **`retryable` 是给自动重连看的信号**,不是“用户能不能点重试按钮”。判断标准是同一请求原样重发是否可能成功。因此 `E_AUTH_EXPIRED` 为 `false`:SDK 不刷新签名,原样重试必然再次失败。
55
58
 
56
59
  **`source.onBeforeRequest` 不在 wire 契约里**。函数不可序列化,过不了 iframe 边界;它是宿主侧的 hook,由 inline 模式的 player-core 直接消费。且它在 MP4 和 iOS Safari 播 HLS 时**不生效**。
57
60
 
58
61
  **带 query 的 URL 推断不出类型**。`video.m3u8?token=xxx` 必须显式传 `type: 'hls'`,否则会走 MP4 路径。签名 URL 场景尤其注意。
59
62
 
60
- **`type DrmConfig = never`**(ADR-014)。v1.0 不做 DRM,消费方传 drm 字段会直接编译报错,而不是运行时才发现没生效。
63
+ **`type DrmConfig = never`。** v1 不提供 DRM;传入 `drm` 字段会直接产生编译错误,而不是运行时静默失效。
61
64
 
62
65
  ## 版本与冻结
63
66
 
64
- 当前 `CONTRACT_VERSION = '1.0.0'` —— **契约已冻结**(2026-07-15,见 ADR-025)。现有 schema 不再破坏兼容。
67
+ 当前 `CONTRACT_VERSION = '1.0.0'`。现有 schema 不再进行破坏性兼容变更。
65
68
 
66
69
  兼容规则(host 和 iframe 用同一个 `isContractCompatible`):
67
70
 
@@ -69,7 +72,7 @@ const config = resolvePreset('homepage-preview', { source: 'a.m3u8', autoplay: f
69
72
  - `>= 1.0.0`(当前):minor 是向后兼容的新增 → **major 相同即兼容**
70
73
  - patch 差异永远兼容
71
74
 
72
- 冻结后:新增 method / event / 可选字段走 **minor** bump(1.x 向后兼容);破坏兼容必须走 **ADR + major** bump。
75
+ 后续新增 method、event 或可选字段走 **minor** 版本;破坏兼容的变更必须走 **major** 版本。
73
76
 
74
77
  ## 测试
75
78
 
@@ -77,4 +80,8 @@ const config = resolvePreset('homepage-preview', { source: 'a.m3u8', autoplay: f
77
80
  pnpm --filter @video-lab/protocol test
78
81
  ```
79
82
 
80
- 契约测试在 `tests/`(和 `src/` 分开,方便冻结后独立管理)。其中 `consumer-parity.contract.test.ts` 拿 `examples/team-video-vue/` 实际用到的事件 / 命令 / 错误码逐个校验——契约一旦漂移到验收基准用不了,它会先红。
83
+ 契约测试覆盖消费包实际使用的事件、命令与错误码,防止通信契约与消费包行为漂移。
84
+
85
+ ## 宿主网页全屏
86
+
87
+ 导出 `PageFullscreenAdapter` / `PageFullscreenState` 类型、实际状态事件 `pagefullscreenchange`,以及专用通道的 `PageFullscreenMessageSchema` / `PageFullscreenControlSchema` / `PageFullscreenStateSchema`。宿主函数与 DOM 不进入 PlayerConfig。消费面调用和升级边界见[网页全屏接入](../../docs/guides/PAGE-FULLSCREEN.md)。
package/dist/index.cjs CHANGED
@@ -302,6 +302,17 @@ const DanmakuConfigSchema = zod.z.object({
302
302
  * 定义在这里而不是 presets.ts,是为了让 PlayerConfig 能引用它而不产生循环依赖。
303
303
  */
304
304
  const PresetNameSchema = zod.z.enum(["homepage-preview"]);
305
+ /** 内置控制栏的初始化显示策略。false 隐藏入口;true/省略仍遵守平台能力。 */
306
+ const ControlVisibilityConfigSchema = zod.z.object({
307
+ play: zod.z.boolean().optional(),
308
+ progress: zod.z.boolean().optional(),
309
+ time: zod.z.boolean().optional(),
310
+ volume: zod.z.boolean().optional(),
311
+ playbackRate: zod.z.boolean().optional(),
312
+ fullscreen: zod.z.boolean().optional(),
313
+ cssFullscreen: zod.z.boolean().optional(),
314
+ pip: zod.z.boolean().optional()
315
+ }).strict();
305
316
  /**
306
317
  * 播放器初始化配置。iframe 模式下握手成功后作为第一条 command 下发,
307
318
  * 所以必须整体可 JSON 序列化。
@@ -357,6 +368,12 @@ const PlayerConfigSchema = zod.z.object({
357
368
  * `boolean | { minimal?: boolean }` 是 **minor 不是 breaking**,不是单向门。
358
369
  */
359
370
  controls: zod.z.boolean().optional(),
371
+ /** 初始化时是否显示默认错误覆盖层。默认开启;关闭不影响错误事件和恢复。 */
372
+ showErrorOverlay: zod.z.boolean().optional(),
373
+ /** 运行时 Loading 展示归属;false 仅隐藏视觉/ARIA,恢复仍由 SDK 执行。 */
374
+ showLoadingOverlay: zod.z.boolean().optional(),
375
+ /** 独立隐藏内置控件;只在创建时生效,修改后由宿主显式重建。 */
376
+ controlVisibility: ControlVisibilityConfigSchema.optional(),
360
377
  /** 是否响应用户交互。首页预览卡片用 false,点击透传给外层卡片 */
361
378
  interactive: zod.z.boolean().optional(),
362
379
  poster: PosterConfigSchema.optional(),
@@ -784,12 +801,13 @@ function normalizeUserAction(raw) {
784
801
  const r = raw;
785
802
  const action = r.action;
786
803
  if (typeof action !== "string" || !ALLOWED.has(action)) return null;
787
- const first = (Array.isArray(r.props) ? r.props : [])[0] ?? 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;
788
806
  return {
789
807
  action,
790
808
  source: typeof r.pluginName === "string" ? r.pluginName : "player",
791
- from: primitive(first?.from),
792
- to: primitive(first?.to)
809
+ from: primitive(change?.from),
810
+ to: primitive(change?.to)
793
811
  };
794
812
  }
795
813
  /**
@@ -814,6 +832,57 @@ function normalizeFrameFreeze(raw) {
814
832
  };
815
833
  }
816
834
  //#endregion
835
+ //#region src/page-fullscreen.ts
836
+ /** 专用网页全屏传输,不接收普通播放器命令;版本独立协商,旧端不会假定支持。 */
837
+ const base$1 = {
838
+ channel: zod.z.literal("video-lab:page-fullscreen"),
839
+ version: zod.z.literal(1),
840
+ session: zod.z.string().min(1)
841
+ };
842
+ /** 静态 iframe 的受限双向消息,接收方还必须校验 origin 与 source。 */
843
+ const PageFullscreenMessageSchema = zod.z.discriminatedUnion("type", [
844
+ zod.z.object({
845
+ ...base$1,
846
+ type: zod.z.literal("hello")
847
+ }),
848
+ zod.z.object({
849
+ ...base$1,
850
+ type: zod.z.literal("ready"),
851
+ instance: zod.z.string().min(1)
852
+ }),
853
+ zod.z.object({
854
+ ...base$1,
855
+ type: zod.z.literal("request"),
856
+ instance: zod.z.string().min(1),
857
+ id: zod.z.number().int().positive(),
858
+ active: zod.z.boolean()
859
+ }),
860
+ zod.z.object({
861
+ ...base$1,
862
+ type: zod.z.literal("state"),
863
+ instance: zod.z.string().min(1),
864
+ revision: zod.z.number().int().nonnegative(),
865
+ active: zod.z.boolean()
866
+ }),
867
+ zod.z.object({
868
+ ...base$1,
869
+ type: zod.z.literal("ack"),
870
+ instance: zod.z.string().min(1),
871
+ revision: zod.z.number().int().nonnegative()
872
+ }),
873
+ zod.z.object({
874
+ ...base$1,
875
+ type: zod.z.literal("disconnect")
876
+ })
877
+ ]);
878
+ /** Penpal 控制面确认;仅在独立网页全屏能力协商后调用。 */
879
+ const PageFullscreenControlSchema = zod.z.object({
880
+ available: zod.z.boolean(),
881
+ active: zod.z.boolean()
882
+ });
883
+ /** 宿主布局确认后的实际网页全屏状态。 */
884
+ const PageFullscreenStateSchema = zod.z.object({ active: zod.z.boolean() });
885
+ //#endregion
817
886
  //#region src/playback-context.ts
818
887
  /**
819
888
  * 播放内核 —— **实际选中的那个**,不是消费方声明的。
@@ -981,124 +1050,133 @@ function redactSourceUrl(url) {
981
1050
  };
982
1051
  }
983
1052
  }
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
1053
  zod.z.enum([
1032
1054
  "detected",
1055
+ "backoff",
1033
1056
  "attempting",
1034
1057
  "validating",
1035
1058
  "recovered",
1036
1059
  "failed",
1037
1060
  "cancelled"
1038
1061
  ]);
1039
- /** SDK 实际执行的恢复策略(ADR-079)。 */
1062
+ /** 策略变化共享 episode,不创建新的预算。 */
1040
1063
  const RecoveryStrategySchema = zod.z.enum([
1041
1064
  "reconnect",
1042
1065
  "media_recovery",
1043
1066
  "visibility_reload"
1044
1067
  ]);
1045
- /** 恢复由错误、用户操作还是可见性变化触发。 */
1068
+ /** 触发原因与实际执行策略分别记录。 */
1046
1069
  const RecoveryTriggerSchema = zod.z.enum([
1047
1070
  "error",
1048
1071
  "manual",
1049
- "visibility"
1072
+ "visibility",
1073
+ "stall",
1074
+ "startup_timeout"
1050
1075
  ]);
1051
- const RecoveryPayloadBase = {
1052
- /** 同一播放器实例内递增的恢复关联号。 */
1076
+ const base = {
1077
+ sessionId: zod.z.string().min(1),
1053
1078
  recoveryId: zod.z.number().int().positive(),
1054
1079
  strategy: RecoveryStrategySchema,
1055
1080
  trigger: RecoveryTriggerSchema,
1056
- /** 当前实际恢复轮次,从 1 开始。 */
1057
- attempt: zod.z.number().int().positive(),
1058
- /** 该策略允许的最大恢复轮次。 */
1081
+ attempt: zod.z.number().int().nonnegative(),
1059
1082
  maxAttempts: zod.z.number().int().positive(),
1060
- /** 有错误起因时复用既有契约错误码;手动恢复可省略。 */
1083
+ /** 从 detected 起,源端用单调时钟测量的毫秒数。 */
1084
+ elapsedMs: zod.z.number().finite().nonnegative(),
1061
1085
  reason: ErrorCodeSchema.optional()
1062
1086
  };
1063
- /**
1064
- * 可验证恢复的单条生命周期记录(ADR-079)。
1065
- *
1066
- * 终态字段按 `phase` 严格区分:`recovered` 只能声明位置推进这一种验证证据;
1067
- * `failed` / `cancelled` 只能携带各自登记的结束原因。`strict()` 刻意拒绝把终态字段
1068
- * 混入检测或验证阶段,避免上报端把“正在验证”误读成“已经恢复”。
1069
- */
1087
+ const issued = {
1088
+ ...base,
1089
+ attempt: zod.z.number().int().positive()
1090
+ };
1091
+ /** 统一恢复的单条事实;取消不伪报成功,跨会话归属不依赖相邻事件猜测。 */
1070
1092
  const RecoveryPayloadSchema = zod.z.discriminatedUnion("phase", [
1071
1093
  zod.z.object({
1072
- ...RecoveryPayloadBase,
1094
+ ...base,
1073
1095
  phase: zod.z.literal("detected")
1074
1096
  }).strict(),
1075
1097
  zod.z.object({
1076
- ...RecoveryPayloadBase,
1098
+ ...base,
1099
+ phase: zod.z.literal("backoff")
1100
+ }).strict(),
1101
+ zod.z.object({
1102
+ ...issued,
1077
1103
  phase: zod.z.literal("attempting")
1078
1104
  }).strict(),
1079
1105
  zod.z.object({
1080
- ...RecoveryPayloadBase,
1106
+ ...issued,
1081
1107
  phase: zod.z.literal("validating")
1082
1108
  }).strict(),
1083
1109
  zod.z.object({
1084
- ...RecoveryPayloadBase,
1110
+ ...issued,
1085
1111
  phase: zod.z.literal("recovered"),
1086
1112
  validatedBy: zod.z.literal("playing_position_advance")
1087
1113
  }).strict(),
1088
1114
  zod.z.object({
1089
- ...RecoveryPayloadBase,
1115
+ ...base,
1090
1116
  phase: zod.z.literal("failed"),
1091
1117
  outcome: zod.z.enum(["timeout", "attempts_exhausted"])
1092
1118
  }).strict(),
1093
1119
  zod.z.object({
1094
- ...RecoveryPayloadBase,
1120
+ ...base,
1095
1121
  phase: zod.z.literal("cancelled"),
1096
1122
  outcome: zod.z.enum([
1097
1123
  "source_changed",
1098
1124
  "destroyed",
1099
- "superseded"
1125
+ "superseded",
1126
+ "user_paused",
1127
+ "natural_recovery"
1100
1128
  ])
1101
1129
  }).strict()
1130
+ ]).refine((value) => value.attempt <= value.maxAttempts, {
1131
+ message: "attempt exceeds episode budget",
1132
+ path: ["attempt"]
1133
+ });
1134
+ //#endregion
1135
+ //#region src/events.ts
1136
+ /**
1137
+ * 清晰度档位。`level` 是索引,传给 `setQuality` 命令用。
1138
+ */
1139
+ const QualityLevelSchema = zod.z.object({
1140
+ level: zod.z.number(),
1141
+ label: zod.z.string().optional(),
1142
+ height: zod.z.number().optional(),
1143
+ bitrate: zod.z.number().optional()
1144
+ });
1145
+ /**
1146
+ * 可用字幕轨(player 加载后暴露给消费方)。`id` 是索引,传给 `setSubtitle` 命令用。
1147
+ *
1148
+ * 和输入侧的 {@link SubtitleTrack}(url/content 两种 `mode`)分开:那个是**消费方喂进来**的
1149
+ * 原始字幕描述,这个是 player **加载后回报**的、可切换的轨道清单(同 QualityLevel 之于 quality)。
1150
+ */
1151
+ const SubtitleTrackInfoSchema = zod.z.object({
1152
+ /** 索引(= source.subtitles 数组下标),`setSubtitle({ id })` 用它引用轨道 */
1153
+ id: zod.z.number(),
1154
+ /** BCP-47,如 `'en'` / `'zh-CN'` / `'th'` */
1155
+ locale: zod.z.string(),
1156
+ /** 展示名,如 `'English'` / `'中文'` */
1157
+ label: zod.z.string()
1158
+ });
1159
+ /**
1160
+ * 「现在能不能正常出画面」的原因枚举({@link PlayerEventSchema} 的 `playablechange`)。
1161
+ *
1162
+ * 优先级(高的压低的,同时命中时报最严重的那个):
1163
+ * `error` > `frame_disconnected` > `autoplay_blocked` > `reconnecting` > `stalled`
1164
+ * > `buffering` > `initializing` > `degraded` > `ok`
1165
+ *
1166
+ * **命名**:枚举值用 `snake_case`,与事件名(全小写连写)是两套命名空间 ——
1167
+ * 事件名对齐 HTML5 媒体事件,枚举值是 payload 里的数据,`autoplay_blocked`
1168
+ * 比 `autoplayblocked` 好读。既有的 `phase: 'start' | 'end'` 是单词故看不出区别。
1169
+ */
1170
+ const PlayableReasonSchema = zod.z.enum([
1171
+ "ok",
1172
+ "initializing",
1173
+ "buffering",
1174
+ "stalled",
1175
+ "reconnecting",
1176
+ "autoplay_blocked",
1177
+ "error",
1178
+ "frame_disconnected",
1179
+ "degraded"
1102
1180
  ]);
1103
1181
  /**
1104
1182
  * 事件。player → host(iframe 模式),或 player-core → 消费方(inline 模式)。
@@ -1110,6 +1188,10 @@ const RecoveryPayloadSchema = zod.z.discriminatedUnion("phase", [
1110
1188
  * 权威来源:docs/protocol/events.md + ARCHITECTURE.md § 8.4。
1111
1189
  */
1112
1190
  const PlayerEventSchema = zod.z.discriminatedUnion("event", [
1191
+ zod.z.object({
1192
+ event: zod.z.literal("pagefullscreenchange"),
1193
+ payload: PageFullscreenStateSchema
1194
+ }),
1113
1195
  zod.z.object({
1114
1196
  event: zod.z.literal("sourceroute"),
1115
1197
  payload: SourceRoutePayloadSchema
@@ -1200,43 +1282,6 @@ id: zod.z.number().nullable() })
1200
1282
  event: zod.z.literal("autoplayblocked"),
1201
1283
  payload: zod.z.object({})
1202
1284
  }),
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
1285
  zod.z.object({
1241
1286
  event: zod.z.literal("recovery"),
1242
1287
  payload: RecoveryPayloadSchema
@@ -1313,7 +1358,15 @@ id: zod.z.number().nullable() })
1313
1358
  *(正常启动,该等),握手失败降级后为 `false`(连不上了,该露重试入口)。
1314
1359
  * 两个阶段共用一个 reason,靠这个字段区分 —— 这正是它存在的意义。
1315
1360
  */
1316
- recoverable: zod.z.boolean()
1361
+ recoverable: zod.z.boolean(),
1362
+ /** 下一步动作能力;宿主无须按错误类型自行推断。 */
1363
+ action: zod.z.enum([
1364
+ "retry",
1365
+ "play",
1366
+ "replace-source",
1367
+ "recreate-frame",
1368
+ "none"
1369
+ ]).optional()
1317
1370
  })
1318
1371
  }),
1319
1372
  zod.z.object({
@@ -1457,6 +1510,28 @@ function toErrorEvent(err) {
1457
1510
  };
1458
1511
  }
1459
1512
  //#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
1460
1535
  //#region src/methods.ts
1461
1536
  /**
1462
1537
  * 命令。host → iframe(iframe 模式),或消费方 → player-core(inline 模式)。
@@ -1527,10 +1602,8 @@ const CommandSchema = zod.z.discriminatedUnion("method", [
1527
1602
  params: zod.z.object({ source: MediaSourceSchema })
1528
1603
  }),
1529
1604
  zod.z.object({
1530
- method: zod.z.literal("reconnect"),
1531
- params: zod.z.object({
1532
- /** true = 把重连计数清零,重新获得完整的 maxRetries 次机会 */
1533
- resetCounter: zod.z.boolean().optional() }).optional()
1605
+ method: zod.z.literal("retry"),
1606
+ params: zod.z.object({}).strict().optional()
1534
1607
  }),
1535
1608
  zod.z.object({
1536
1609
  method: zod.z.literal("pushDanmaku"),
@@ -1562,36 +1635,11 @@ resetCounter: zod.z.boolean().optional() }).optional()
1562
1635
  //#endregion
1563
1636
  //#region src/version.ts
1564
1637
  /**
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`。
1638
+ * host ↔ iframe 通信契约版本,独立于 npm fixed group 和 iframe 应用版本。
1639
+ * 唯一来源是 contract-version.json(ADR-094);只在真实协议变化时升级。
1640
+ * npm 包版本同步不能改变握手与 envelope 的版本。
1593
1641
  */
1594
- const CONTRACT_VERSION = "1.0.1";
1642
+ const CONTRACT_VERSION = "2.0.0";
1595
1643
  const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
1596
1644
  /**
1597
1645
  * 解析 semver 字符串。只接受严格的 `major.minor.patch`,
@@ -1682,8 +1730,18 @@ function createEnvelopeSchema(type, payload) {
1682
1730
  }
1683
1731
  /** 命令包裹(host → iframe) */
1684
1732
  const CommandEnvelopeSchema = createEnvelopeSchema("command", CommandSchema);
1685
- /** 事件包裹(iframe → host) */
1686
- const EventEnvelopeSchema = createEnvelopeSchema("event", PlayerEventSchema);
1733
+ const LegacyEventEnvelopeSchema = createEnvelopeSchema("event", PlayerEventSchema).extend({
1734
+ /** Older iframe builds omit all delivery evidence. */
1735
+ producerSessionId: zod.z.undefined().optional(),
1736
+ sequence: zod.z.undefined().optional()
1737
+ });
1738
+ const DeliveredEventEnvelopeSchema = createEnvelopeSchema("event", PlayerEventSchema).extend({
1739
+ /** Producer-side delivery evidence is atomic: both fields are required together. */
1740
+ producerSessionId: zod.z.string().min(1),
1741
+ sequence: zod.z.number().int().positive()
1742
+ });
1743
+ /** 事件包裹(iframe → host):旧包省略证据,新包同时携带 session 和 sequence。 */
1744
+ const EventEnvelopeSchema = zod.z.union([LegacyEventEnvelopeSchema, DeliveredEventEnvelopeSchema]);
1687
1745
  /**
1688
1746
  * 响应包裹(iframe → host)。命令的结果一律是 void——
1689
1747
  * 播放状态通过事件回来,不塞在 response 里。
@@ -1694,10 +1752,10 @@ const ErrorEnvelopeSchema = createEnvelopeSchema("error", PlayerErrorSchema);
1694
1752
  /**
1695
1753
  * 任意包裹。收到消息时先用它 parse,再按 `type` 分支。
1696
1754
  *
1697
- * 用 discriminatedUnion 而不是 union:错误信息能精确到具体分支,
1698
- * 而不是把四个分支的失败原因全列一遍。
1755
+ * event 分支自身要区分旧包和带 delivery evidence 的新包,因此这里用 union。
1756
+ * 每个分支的 `type` 仍是具体字面量,TypeScript 仍可按 `type` 窄化。
1699
1757
  */
1700
- const EnvelopeSchema = zod.z.discriminatedUnion("type", [
1758
+ const EnvelopeSchema = zod.z.union([
1701
1759
  CommandEnvelopeSchema,
1702
1760
  EventEnvelopeSchema,
1703
1761
  ResponseEnvelopeSchema,
@@ -1725,6 +1783,13 @@ function commandEnvelope(payload, meta) {
1725
1783
  }
1726
1784
  /** 构造事件包裹(iframe → host) */
1727
1785
  function eventEnvelope(payload, meta) {
1786
+ if (meta.producerSessionId !== void 0 && meta.sequence !== void 0) return {
1787
+ ...envelopeBase(meta),
1788
+ type: "event",
1789
+ payload,
1790
+ producerSessionId: meta.producerSessionId,
1791
+ sequence: meta.sequence
1792
+ };
1728
1793
  return {
1729
1794
  ...envelopeBase(meta),
1730
1795
  type: "event",
@@ -1848,8 +1913,10 @@ function resolvePreset(preset, config) {
1848
1913
  exports.CONTRACT_VERSION = CONTRACT_VERSION;
1849
1914
  exports.CommandEnvelopeSchema = CommandEnvelopeSchema;
1850
1915
  exports.CommandSchema = CommandSchema;
1916
+ exports.ControlVisibilityConfigSchema = ControlVisibilityConfigSchema;
1851
1917
  exports.DanmakuConfigSchema = DanmakuConfigSchema;
1852
1918
  exports.DanmakuItemSchema = DanmakuItemSchema;
1919
+ exports.DeliveredPlayerEventSchema = DeliveredPlayerEventSchema;
1853
1920
  exports.ERROR_META = ERROR_META;
1854
1921
  exports.EnvelopeSchema = EnvelopeSchema;
1855
1922
  exports.EnvelopeTypeSchema = EnvelopeTypeSchema;
@@ -1868,6 +1935,9 @@ exports.MediaTypeSchema = MediaTypeSchema;
1868
1935
  exports.MultiSourceObjectSchema = MultiSourceObjectSchema;
1869
1936
  exports.PLAYER_DESTROYED_MESSAGE = PLAYER_DESTROYED_MESSAGE;
1870
1937
  exports.PRESETS = PRESETS;
1938
+ exports.PageFullscreenControlSchema = PageFullscreenControlSchema;
1939
+ exports.PageFullscreenMessageSchema = PageFullscreenMessageSchema;
1940
+ exports.PageFullscreenStateSchema = PageFullscreenStateSchema;
1871
1941
  exports.PauseImageConfigSchema = PauseImageConfigSchema;
1872
1942
  exports.PlayableReasonSchema = PlayableReasonSchema;
1873
1943
  exports.PlaybackContextSchema = PlaybackContextSchema;