@video-lab/player-core 3.0.1 → 4.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/dist/index.mjs CHANGED
@@ -172,18 +172,7 @@ function extractHttpStatus(err) {
172
172
  function isAutoplayBlocked(err) {
173
173
  return typeof err === "object" && err !== null && "name" in err && err.name === "NotAllowedError";
174
174
  }
175
- /**
176
- * xgplayer 错误 → 契约的 PlayerError。
177
- *
178
- * 优先级:autoplay 拒绝 > HTTP 认证失败 > errorType > MediaError.code > 兜底 E_UNKNOWN。
179
- * 认证放在 errorType 之前,是因为 401/403 在 xgplayer 里也会报成 `network`,
180
- * 但对消费方来说"登录过期"和"网络断了"要给完全不同的提示。
181
- *
182
- * @example
183
- * mapXgplayerError({ errorType: 'timeout', message: '请求超时' })
184
- * // → { code: 'E_NETWORK_TIMEOUT', category: 'network', retryable: true, ... }
185
- */
186
- function mapXgplayerError(err) {
175
+ function mapXgplayerError(err, context = {}) {
187
176
  if (isAutoplayBlocked(err)) return makePlayerError("E_AUTOPLAY_BLOCKED", "浏览器拒绝了自动播放", err);
188
177
  if (typeof err !== "object" || err === null) return makePlayerError("E_UNKNOWN", typeof err === "string" ? err : "未知播放错误", err);
189
178
  const xgErr = err;
@@ -191,9 +180,10 @@ function mapXgplayerError(err) {
191
180
  const status = extractHttpStatus(xgErr);
192
181
  if (status === 401) return makePlayerError("E_AUTH_EXPIRED", message, err);
193
182
  if (status === 403) return makePlayerError("E_AUTH_EXPIRED", message, err);
183
+ const mediaCode = xgErr.mediaError?.code;
184
+ if (context.live === false && mediaCode === 4) return makePlayerError("E_MEDIA_NOT_SUPPORTED", message, err);
194
185
  const byType = xgErr.errorType ? ERROR_TYPE_CODES[xgErr.errorType] : void 0;
195
186
  if (byType) return makePlayerError(byType, message, err);
196
- const mediaCode = xgErr.mediaError?.code;
197
187
  if (typeof mediaCode === "number") {
198
188
  const byMedia = MEDIA_ERROR_CODES[mediaCode];
199
189
  if (byMedia) return makePlayerError(byMedia, message, err);
@@ -230,27 +220,43 @@ function refineByDetails(type, details) {
230
220
  if (type === "otherError" && details.includes("Parsing")) return "E_MANIFEST_PARSE";
231
221
  }
232
222
  /**
233
- * hls.js 内核诊断 → 契约 `PlayerError`。
223
+ * 点播专有的 details 细化(ADR-107,ADR-061「按 type 映射」的例外)。
234
224
  *
235
- * ─── 为什么需要它(#402)─────────────────────────────
225
+ * hls.js 把子播放列表解析失败(`levelParsingError`)归在 `networkError` 下,按 type 会映射成
226
+ * 可重连的 `E_NETWORK`。点播的列表是静态文件,解析失败重试也还是同一份(LiveLab HLS-15 实测),
227
+ * 应为不可重试的 `E_MANIFEST_PARSE`。直播列表动态生成,偶发损坏可能下次就好,不细化。
228
+ * 只作用于 fatal 的 error 通道;`kernelhealth` 分类不受影响。
236
229
  *
237
- * `xgplayer-hls.js` 的包装层把 **NETWORK / MEDIA 两类 fatal 错误私吞了**:
238
- * 前者 `startLoad()` 重试、后者 `recoverMediaError()`,**两条都不 `emit('error')`**。
239
- * 而 `status === 404` 时连重试都没有 —— 那个 `if` 直接落空,什么都不做。
230
+ * ⚠️ **不含 `manifestParsingError`。** 主清单地址返回 HTML(地址错、CDN / WAF 拦截页、
231
+ * 强制门户登录页)时 hls.js 报的就是它,其中一部分重试可能成功;错误覆盖层 e2e 正是这个形态
232
+ * (开发服务器对不存在的路径回 200 HTML),要求保留重试按钮。
233
+ */
234
+ function refineVodByDetails(type, details, context) {
235
+ if (context.live !== false || details === void 0) return void 0;
236
+ if (type === "networkError" && details === "levelParsingError") return "E_MANIFEST_PARSE";
237
+ }
238
+ /**
239
+ * hls.js 内核诊断 → 契约 `PlayerError`。
240
240
  *
241
- * 实测(chromium):manifest 从头 404 → hls.js 报 `manifestLoadError` **fatal=true**,
242
- * 消费方拿到的契约 `error` **0 条**;分片全 abort 40s → 9 条 `hlsError` 含 1 条 fatal,
243
- * 契约 `error` 仍然 **0 条**。**连 fatal 都到不了消费方。**
241
+ * `OwnedHlsPlugin` 统一把 hls.js 诊断送入本映射:fatal 进入契约 `error`,非 fatal 返回
242
+ * `undefined` 并由调用方送入 `kernelhealth` 聚合。恢复动作由统一恢复调度决定,映射层不重试。
244
243
  *
245
244
  * @returns `undefined` 表示这条不该进 error 流(非 fatal)——见 spec § 非 fatal 不进 error 流
246
245
  */
247
- function mapHlsKernelError(payload) {
246
+ function mapHlsKernelError(payload, context = {}) {
248
247
  if (payload.errorFatal !== true) return void 0;
249
248
  const type = payload.errorType;
250
249
  if (typeof type !== "string") return void 0;
251
- const code = refineByDetails(type, payload.errorDetails) ?? HLS_TYPE_CODES[type] ?? "E_UNKNOWN";
252
250
  const details = payload.errorDetails ?? "未知";
253
- return makePlayerError(code, `内核错误:${type} / ${details}`, {
251
+ const message = `内核错误:${type} / ${details}`;
252
+ if (payload.errorFatal === true && (payload.httpStatus === 401 || payload.httpStatus === 403)) return makePlayerError("E_AUTH_EXPIRED", message, {
253
+ message: `${type} / ${details}`,
254
+ errorType: type,
255
+ errorDetails: payload.errorDetails,
256
+ errorFatal: true,
257
+ status: payload.httpStatus
258
+ });
259
+ return makePlayerError(refineVodByDetails(type, payload.errorDetails, context) ?? refineByDetails(type, payload.errorDetails) ?? HLS_TYPE_CODES[type] ?? "E_UNKNOWN", message, {
254
260
  message: `${type} / ${details}`,
255
261
  errorType: type,
256
262
  errorDetails: payload.errorDetails,
@@ -562,6 +568,27 @@ var HlsAdapter = class {
562
568
  };
563
569
  //#endregion
564
570
  //#region src/kernels/owned-hls-plugin.ts
571
+ /**
572
+ * hls.js 默认拒绝重试所有 4xx。运行中的 playlist / fragment 403 另有瞬时
573
+ * CDN/鉴权抖动这一种已测歧义,最多额外尝试三次;401 与其他 4xx 仍按内核默认。
574
+ *
575
+ * `retry` 是内核已经批准的超时、5xx 等重试结果,必须原样保留,避免收窄既有策略。
576
+ */
577
+ function shouldRetryTransientForbidden(_retryConfig, retryCount, isTimeout, response, retry) {
578
+ return retry || !isTimeout && response?.code === 403 && retryCount < 3;
579
+ }
580
+ /** 不修改 hls.js 全局默认值;每个播放器实例取得自己的浅拷贝。 */
581
+ function withTransientForbiddenRetry(policy) {
582
+ const errorRetry = policy.default.errorRetry;
583
+ if (!errorRetry) return policy;
584
+ return { default: {
585
+ ...policy.default,
586
+ errorRetry: {
587
+ ...errorRetry,
588
+ shouldRetry: shouldRetryTransientForbidden
589
+ }
590
+ } };
591
+ }
565
592
  /** 生产 HLS 内部装配层;恢复预算由统一调度器持有,不作为宿主扩展口导出。 */
566
593
  var OwnedHlsPlugin = class extends BasePlugin {
567
594
  static get pluginName() {
@@ -606,14 +633,21 @@ var OwnedHlsPlugin = class extends BasePlugin {
606
633
  if (!this.adapter) {
607
634
  const media = this.player.video;
608
635
  if (!(media instanceof HTMLMediaElement)) return;
609
- const hlsOpts = { ...this.config.hlsOpts };
636
+ const config = this.config;
637
+ const hlsOpts = {
638
+ ...config.hlsOpts,
639
+ playlistLoadPolicy: withTransientForbiddenRetry(config.hlsOpts?.playlistLoadPolicy ?? HlsLight.DefaultConfig.playlistLoadPolicy),
640
+ fragLoadPolicy: withTransientForbiddenRetry(config.hlsOpts?.fragLoadPolicy ?? HlsLight.DefaultConfig.fragLoadPolicy)
641
+ };
610
642
  if (hlsOpts.startPosition === void 0 && typeof this.playerConfig.startTime === "number") hlsOpts.startPosition = this.playerConfig.startTime;
611
643
  this.adapter = new HlsAdapter(media, (error) => {
612
644
  if (this.disposed) return;
645
+ const status = error.response?.code;
613
646
  this.player.emit("HLS_ERROR", {
614
647
  errorType: error.type,
615
648
  errorDetails: error.details,
616
- errorFatal: error.fatal
649
+ errorFatal: error.fatal,
650
+ ...typeof status === "number" && status >= 100 && status < 600 ? { httpStatus: status } : {}
617
651
  });
618
652
  }, hlsOpts);
619
653
  }
@@ -682,6 +716,55 @@ var OwnedHlsPlugin = class extends BasePlugin {
682
716
  }
683
717
  };
684
718
  //#endregion
719
+ //#region src/last-frame-placeholder.ts
720
+ function createLastFramePlaceholder(getMedia) {
721
+ let canvas = null;
722
+ const release = () => {
723
+ if (!canvas) return;
724
+ canvas.remove();
725
+ canvas.width = 0;
726
+ canvas.height = 0;
727
+ canvas = null;
728
+ };
729
+ const capture = () => {
730
+ if (canvas?.parentNode) return true;
731
+ release();
732
+ const media = getMedia();
733
+ if (!(media instanceof HTMLVideoElement) || !media.parentElement) return false;
734
+ const { videoWidth: width, videoHeight: height } = media;
735
+ if (media.readyState < 2 || width <= 0 || height <= 0) return false;
736
+ const next = media.ownerDocument.createElement("canvas");
737
+ next.width = width;
738
+ next.height = height;
739
+ try {
740
+ const context = next.getContext("2d");
741
+ if (!context) return false;
742
+ context.drawImage(media, 0, 0, width, height);
743
+ } catch {
744
+ return false;
745
+ }
746
+ next.dataset.sentinelLastFrame = "";
747
+ next.setAttribute("aria-hidden", "true");
748
+ const fit = media.ownerDocument.defaultView?.getComputedStyle(media).objectFit;
749
+ Object.assign(next.style, {
750
+ position: "absolute",
751
+ top: "0",
752
+ left: "0",
753
+ width: "100%",
754
+ height: "100%",
755
+ objectFit: fit && fit !== "fill" ? fit : "contain",
756
+ pointerEvents: "none"
757
+ });
758
+ media.insertAdjacentElement("afterend", next);
759
+ canvas = next;
760
+ return true;
761
+ };
762
+ return {
763
+ capture,
764
+ release
765
+ };
766
+ }
767
+ //#endregion
685
768
  //#region src/page-fullscreen.ts
686
769
  /**
687
770
  * 接管锁定的 xgplayer 3.0.26 网页全屏按钮与 Esc;宿主确认后才应用本地状态。
@@ -765,6 +848,11 @@ const BACKOFF = [
765
848
  4e3
766
849
  ];
767
850
  /**
851
+ * 复发观察期(ADR-105):判恢复成功后这么久之内再次需要恢复,视为同一故障复发,
852
+ * 新一轮沿用上一轮已用的动作次数与退避档位。持续故障因此在累计 3 次后收口为失败(#103)。
853
+ */
854
+ const RELAPSE_WINDOW_MS = 15e3;
855
+ /**
768
856
  * 实例级内部调度器。执行器须在 abort 时同步停止旧动作;Promise 完成仅代表命令结束。
769
857
  * 强播放证据由调用方判定后交 recovered;生产装配层负责映射公开事件。
770
858
  */
@@ -772,6 +860,11 @@ var RecoveryController = class {
772
860
  execute;
773
861
  onTransition;
774
862
  active = null;
863
+ /**
864
+ * 最近一次判恢复成功的会话、时刻与已用次数。保留到下一次判成功(覆盖)、失败、
865
+ * 非自然恢复的取消、手动重试或换会话为止;被自然恢复取消的一轮不打断计数链。
866
+ */
867
+ lastRecovered = null;
775
868
  sequence = 0;
776
869
  disposed = false;
777
870
  suspended = false;
@@ -780,10 +873,13 @@ var RecoveryController = class {
780
873
  events = [];
781
874
  publishing = false;
782
875
  actionDepth = 0;
783
- constructor(execute, onTransition) {
876
+ constructor(execute, onTransition, options = {}) {
784
877
  this.execute = execute;
785
878
  this.onTransition = onTransition;
879
+ this.resolveStrategy = options.resolveStrategy ?? ((strategy) => strategy);
786
880
  }
881
+ /** 见 {@link StrategyResolver};未提供时按请求时的策略原样上报。 */
882
+ resolveStrategy;
787
883
  /** 供装配层识别同步重入后的新 episode,不是公开播放器查询。 */
788
884
  get activeEpisodeId() {
789
885
  return this.active?.id ?? null;
@@ -798,15 +894,17 @@ var RecoveryController = class {
798
894
  }
799
895
  return this.active.id;
800
896
  }
897
+ const trigger = input.trigger ?? "error";
801
898
  const episode = {
802
899
  id: ++this.sequence,
803
900
  sessionId,
804
901
  deadline: performance.now() + 6e4,
805
902
  startedAt: performance.now(),
806
903
  strategy: input.strategy ?? "reconnect",
807
- trigger: input.trigger ?? "error",
904
+ trigger,
808
905
  reason: input.reason,
809
- attempt: 0,
906
+ attempt: this.inheritedAttempts(sessionId, trigger),
907
+ issued: 0,
810
908
  token: null,
811
909
  abort: null,
812
910
  finishing: false
@@ -823,9 +921,18 @@ var RecoveryController = class {
823
921
  if (!episode || episode.token !== token) return;
824
922
  this.finish(episode, performance.now() >= episode.deadline ? "failed" : "recovered");
825
923
  }
826
- /** 第一动作尚未发出时原播放自行恢复,不制造一次成功动作。 */
924
+ /** 是否有已发出、尚未得到证据的动作;退避等待不算。 */
925
+ get attemptInFlight() {
926
+ return Boolean(this.active?.token && !this.active.finishing);
927
+ }
928
+ /** 在途动作已被确定性证据证伪时立即结束它;退避中没有动作可结束,不消耗预算。 */
929
+ failActiveAttempt() {
930
+ const episode = this.active;
931
+ if (episode?.token && !episode.finishing) this.failAttempt(episode, episode.token);
932
+ }
933
+ /** 本轮第一动作尚未发出时原播放自行恢复,不制造一次成功动作。 */
827
934
  naturalRecovered() {
828
- if (this.active?.attempt === 0) this.finish(this.active, "cancelled", "natural_recovery");
935
+ if (this.active?.issued === 0) this.finish(this.active, "cancelled", "natural_recovery");
829
936
  }
830
937
  /** 换源、用户暂停等取消原因由上层记录;取消不等同于预算耗尽。 */
831
938
  cancel(outcome = "superseded") {
@@ -851,6 +958,19 @@ var RecoveryController = class {
851
958
  this.disposed = true;
852
959
  this.cancel("destroyed");
853
960
  }
961
+ /**
962
+ * 复发继承的已用次数。手动重试总从头开始;换源(会话变化)不继承;
963
+ * 只有「判恢复成功」之后的观察期内才继承。
964
+ */
965
+ inheritedAttempts(sessionId, trigger) {
966
+ const last = this.lastRecovered;
967
+ if (!last) return 0;
968
+ if (trigger === "manual" || last.sessionId !== sessionId) {
969
+ this.lastRecovered = null;
970
+ return 0;
971
+ }
972
+ return performance.now() - last.at < RELAPSE_WINDOW_MS ? last.attempt : 0;
973
+ }
854
974
  schedule(episode) {
855
975
  if (this.active !== episode) return;
856
976
  const delay = BACKOFF[episode.attempt];
@@ -870,12 +990,14 @@ var RecoveryController = class {
870
990
  this.finish(episode, "failed");
871
991
  return;
872
992
  }
993
+ const attempt = ++episode.attempt;
873
994
  const token = Object.freeze({
874
995
  episode: episode.id,
875
996
  sessionId: episode.sessionId,
876
- attempt: ++episode.attempt,
877
- strategy: episode.strategy
997
+ attempt,
998
+ strategy: this.resolveStrategy(episode.strategy, attempt)
878
999
  });
1000
+ episode.issued += 1;
879
1001
  const abort = new AbortController();
880
1002
  episode.issuedStrategy = token.strategy;
881
1003
  episode.token = token;
@@ -914,6 +1036,12 @@ var RecoveryController = class {
914
1036
  episode.token = null;
915
1037
  if (phase !== "recovered") episode.abort?.abort();
916
1038
  this.active = null;
1039
+ if (phase === "recovered") this.lastRecovered = {
1040
+ sessionId: episode.sessionId,
1041
+ at: performance.now(),
1042
+ attempt: episode.attempt
1043
+ };
1044
+ else if (outcome !== "natural_recovery") this.lastRecovered = null;
917
1045
  this.publish(episode, phase, outcome ?? (phase === "failed" ? performance.now() >= episode.deadline ? "timeout" : "attempts_exhausted" : void 0));
918
1046
  }
919
1047
  publish(episode, phase, outcome) {
@@ -924,7 +1052,7 @@ var RecoveryController = class {
924
1052
  phase,
925
1053
  elapsedMs: Math.max(0, performance.now() - episode.startedAt),
926
1054
  maxAttempts: BACKOFF.length,
927
- strategy: phase === "detected" || phase === "backoff" ? episode.strategy : episode.issuedStrategy ?? episode.strategy,
1055
+ strategy: phase === "detected" || phase === "backoff" ? this.resolveStrategy(episode.strategy, episode.attempt + 1) : episode.issuedStrategy ?? episode.strategy,
928
1056
  trigger: episode.trigger,
929
1057
  ...episode.reason ? { reason: episode.reason } : {},
930
1058
  ...outcome ? { outcome } : {}
@@ -953,17 +1081,25 @@ var PlaybackRecovery = class {
953
1081
  sessionId;
954
1082
  emit;
955
1083
  display;
1084
+ options;
956
1085
  controller;
957
1086
  error = null;
1087
+ /** 最近一次非致命内核诊断的分类;只用于给无原因的失败补 `reason`(#105) */
1088
+ diagnostic = null;
958
1089
  desiredPlayback = false;
959
1090
  exhausted = false;
960
1091
  disposed = false;
961
1092
  revision = 0;
962
- constructor(sessionId, execute, emit, display) {
1093
+ constructor(sessionId, execute, emit, display, options = {}) {
963
1094
  this.sessionId = sessionId;
964
1095
  this.emit = emit;
965
1096
  this.display = display;
966
- this.controller = new RecoveryController(execute, (event) => this.transition(event));
1097
+ this.options = options;
1098
+ this.controller = new RecoveryController(execute, (event) => this.transition(event), { ...options.resolveStrategy ? { resolveStrategy: options.resolveStrategy } : {} });
1099
+ }
1100
+ /** 是否有已发出、尚未得到证据的恢复动作;退避等待不算。 */
1101
+ get attemptInFlight() {
1102
+ return !this.disposed && this.controller.attemptInFlight;
967
1103
  }
968
1104
  /** 执行器完成异步换源后复查最新意图,避免恢复用户已暂停的播放。 */
969
1105
  get playingIntent() {
@@ -982,8 +1118,10 @@ var PlaybackRecovery = class {
982
1118
  failed: true,
983
1119
  action: this.action
984
1120
  });
985
- } else if (this.desiredPlayback && !this.exhausted) this.request("error");
986
- else this.display({
1121
+ } else if (this.desiredPlayback && !this.exhausted) {
1122
+ this.request("error");
1123
+ if (this.options.failsAttempt?.(error)) this.controller.failActiveAttempt();
1124
+ } else this.display({
987
1125
  recovering: false,
988
1126
  failed: true,
989
1127
  action: this.action
@@ -999,20 +1137,30 @@ var PlaybackRecovery = class {
999
1137
  this.controller.cancel("user_paused");
1000
1138
  } else if (this.error?.retryable && !this.exhausted) this.request("manual");
1001
1139
  }
1002
- /** 正常播放结束后不再接受自动恢复意图;不伪报用户暂停或恢复成功。 */
1140
+ /**
1141
+ * 播放到达结尾。正常结束后不再接受自动恢复意图;不伪报用户暂停或恢复成功。
1142
+ * 动作在途时到达结尾说明该动作没能恢复播放,按动作失败处理;预算耗尽后的结尾
1143
+ * 也不抹掉失败终态(#97)。返回 false 表示这不是一次正常结束。
1144
+ */
1003
1145
  endPlayback() {
1004
- if (this.disposed) return;
1146
+ if (this.disposed) return false;
1147
+ if (this.controller.attemptInFlight) {
1148
+ this.controller.failActiveAttempt();
1149
+ return false;
1150
+ }
1151
+ if (this.exhausted) return false;
1005
1152
  const revision = ++this.revision;
1006
1153
  this.desiredPlayback = false;
1007
1154
  this.exhausted = false;
1008
1155
  this.error = null;
1009
1156
  this.controller.cancel("superseded");
1010
- if (this.disposed || revision !== this.revision) return;
1157
+ if (this.disposed || revision !== this.revision) return true;
1011
1158
  this.display({
1012
1159
  recovering: false,
1013
1160
  failed: false,
1014
1161
  action: "none"
1015
1162
  });
1163
+ return true;
1016
1164
  }
1017
1165
  /** 接受/合并手动重试;播放成功仍以当前动作的强证据为准。 */
1018
1166
  async retry() {
@@ -1048,6 +1196,19 @@ var PlaybackRecovery = class {
1048
1196
  action: "none"
1049
1197
  });
1050
1198
  }
1199
+ /**
1200
+ * 记录非致命内核诊断(`kernelhealth` degraded)。不触发恢复,只在恢复失败而没有致命错误时
1201
+ * 作为原因来源(#105);`other` 分类无法对应错误码,不记录。
1202
+ *
1203
+ * `auth` 是内核诊断里带 401/403 时由装配层单独给出的更具体分类(#114):密钥或分片被拒
1204
+ * 之后内核会继续重试并报网络类诊断,所以它在本轮内粘住,不能被后到的网络/媒体诊断冲掉,
1205
+ * 否则「要重新取签名 URL」又会退化成「网络不好,再试试」。换源与恢复成功照旧清空。
1206
+ */
1207
+ noteKernelHealth(reason) {
1208
+ if (this.disposed || reason === "other") return;
1209
+ if (this.diagnostic === "auth" && reason !== "auth") return;
1210
+ this.diagnostic = reason;
1211
+ }
1051
1212
  /** 页面隐藏只暂停动作,不暂停总预算。 */
1052
1213
  setSuspended(hidden) {
1053
1214
  this.controller.setSuspended(hidden);
@@ -1058,6 +1219,7 @@ var PlaybackRecovery = class {
1058
1219
  this.revision += 1;
1059
1220
  this.sessionId = sessionId;
1060
1221
  this.error = null;
1222
+ this.diagnostic = null;
1061
1223
  this.exhausted = false;
1062
1224
  this.controller.cancel("source_changed");
1063
1225
  }
@@ -1067,6 +1229,16 @@ var PlaybackRecovery = class {
1067
1229
  this.disposed = true;
1068
1230
  this.controller.destroy();
1069
1231
  }
1232
+ /**
1233
+ * 恢复失败而本轮没有携带原因时的补填(#105)。致命错误发起或并入恢复时已带上错误码,
1234
+ * 走不到这里;这里只处理非致命诊断分类,都没有(纯起播超时 / 纯卡顿)时为 `E_NETWORK_TIMEOUT`。
1235
+ */
1236
+ get failureReason() {
1237
+ if (this.diagnostic === "auth") return "E_AUTH_EXPIRED";
1238
+ if (this.diagnostic === "network") return "E_NETWORK";
1239
+ if (this.diagnostic === "media") return "E_MEDIA_DECODE";
1240
+ return "E_NETWORK_TIMEOUT";
1241
+ }
1070
1242
  get action() {
1071
1243
  if (!this.error || this.error.retryable) return "retry";
1072
1244
  if (this.error.category === "autoplay") return "play";
@@ -1085,6 +1257,7 @@ var PlaybackRecovery = class {
1085
1257
  if (event.sessionId === this.sessionId && event.phase === "failed") this.exhausted = true;
1086
1258
  if (event.sessionId === this.sessionId && (event.phase === "recovered" || event.outcome === "natural_recovery")) {
1087
1259
  this.error = null;
1260
+ this.diagnostic = null;
1088
1261
  this.exhausted = false;
1089
1262
  }
1090
1263
  this.emit({
@@ -1092,7 +1265,8 @@ var PlaybackRecovery = class {
1092
1265
  payload: RecoveryPayloadSchema.parse({
1093
1266
  ...fact,
1094
1267
  recoveryId: episode,
1095
- ...event.phase === "recovered" ? { validatedBy: "playing_position_advance" } : {}
1268
+ ...event.phase === "recovered" ? { validatedBy: "playing_position_advance" } : {},
1269
+ ...event.phase === "failed" && !fact.reason ? { reason: this.failureReason } : {}
1096
1270
  })
1097
1271
  });
1098
1272
  if (this.disposed || event.sessionId !== this.sessionId || revision !== this.revision) return;
@@ -1229,7 +1403,8 @@ const DEFAULT_CONFIG$5 = {
1229
1403
  * FullscreenGuardPlugin(P2)· iOS 微信原生全屏崩溃防护
1230
1404
  *
1231
1405
  * 解决的 pitfall:**#7**(iOS 26 微信里触发 `<video>` 原生全屏 `webkitEnterFullscreen()`
1232
- * 直接崩溃 / 白屏)。ARCHITECTURE § 10.1 的处置是「禁用原生全屏 + CSS 兜底」。
1406
+ * 直接崩溃 / 白屏)。处置是「禁用原生全屏 + CSS 兜底」;坑点事实源见
1407
+ * `.claude/context/xgplayer-pitfalls.md`。
1233
1408
  *
1234
1409
  * 做法:仅在 iOS 微信环境,把媒体元素上的 `webkitEnterFullscreen` 换成 no-op,**阻断**
1235
1410
  * 会崩的原生全屏路径。xgplayer / 团队层的 CSS 伪全屏(操作容器,不走 `webkitEnterFullscreen`)
@@ -1279,7 +1454,9 @@ var FullscreenGuardPlugin = class extends BasePlugin {
1279
1454
  const DEFAULT_CONFIG$4 = {
1280
1455
  enabled: true,
1281
1456
  stallThresholdMs: 2e3,
1282
- pollIntervalMs: 1e3
1457
+ pollIntervalMs: 1e3,
1458
+ waitingStallThresholdMs: 1e3,
1459
+ sustainedStallMs: 3e3
1283
1460
  };
1284
1461
  /**
1285
1462
  * 连续几次采样余量下降就判定「在净流失」(ADR-071)。
@@ -1304,13 +1481,16 @@ const DRAIN_STREAK = 3;
1304
1481
  *
1305
1482
  * 解决的 pitfall:**#13**(卡顿检测无信号上报 / 冻帧型隐性卡死)。见 ADR-026。
1306
1483
  *
1307
- * 这是 11 个插件里唯一的**观察者**——不修任何东西,只**测量**播放质量并通过契约事件
1308
- * `stalled` 上报(Phase-1 灰度卡顿率 / 首帧 KPI 的数据源)。
1484
+ * 它负责**测量**播放质量并通过契约事件 `stalled` 上报(Phase-1 灰度卡顿率 / 首帧 KPI 的
1485
+ * 数据源);持续卡顿达到阈值时只提交恢复意图,实际动作仍由统一恢复调度执行。
1309
1486
  *
1310
1487
  * 两条测量路径喂同一个状态机(`stalling`),避免重复计数:
1311
1488
  * - **显性卡顿**:`waiting` → 进入卡顿(记开始时刻 + 位置);`playing` → 结束(算 `durationMs`)。
1312
1489
  * - **隐性卡顿(冻帧)**:定时轮询 `currentTime`,播放中却连续 `stallThresholdMs` 不推进 →
1313
1490
  * 进入卡顿;之后推进了 → 结束。这类卡死 xgplayer 不发 `waiting`,只能主动抓。
1491
+ * - **视频帧冻结(#102 · ADR-104)**:`currentTime` 在走、已解码视频帧却连续 `stallThresholdMs`
1492
+ * 不涨 → 进入 playback 卡顿;帧恢复增长才结束。视频解码停了而音频照常解码时,
1493
+ * `<video>` 按音频时钟推进 `currentTime`,浏览器不发 `waiting` —— 只看时钟的两条路径都看不见。
1314
1494
  *
1315
1495
  * **不计后台卡顿**:`document.hidden` 时不进入卡顿——切后台 `currentTime` 本就停,那不是
1316
1496
  * 质量问题(iOS 后台切回归 VisibilityPlugin)。
@@ -1343,6 +1523,14 @@ var HealthMonitorPlugin = class extends BasePlugin {
1343
1523
  /** 轮询用:上次看到的 currentTime + 它上次推进的时刻 */
1344
1524
  lastTime = 0;
1345
1525
  lastAdvanceAt = 0;
1526
+ /** 帧冻结判据用:上次读到的已解码帧数(`null` = 没有基准)+ 它上次增长的时刻 */
1527
+ lastFrames = null;
1528
+ lastFrameAdvanceAt = 0;
1529
+ /**
1530
+ * 本次加载里**亲眼见过**帧数增长吗。没见过就不启用帧判据 ——
1531
+ * 纯音频流、不支持 `getVideoPlaybackQuality` 的浏览器,帧数恒为 0,那不是冻结。
1532
+ */
1533
+ sawFrameAdvance = false;
1346
1534
  /** 上一次采到的余量;`null` 表示还没有基准(起播 / 刚复位) */
1347
1535
  lastMargin = null;
1348
1536
  /** 已经连续下降了几次 */
@@ -1356,6 +1544,10 @@ var HealthMonitorPlugin = class extends BasePlugin {
1356
1544
  */
1357
1545
  sawBufferAdvance = false;
1358
1546
  pollTimer = null;
1547
+ /** `waiting` 到达、尚未满显性阈值的在途计时器(ADR-103) */
1548
+ waitingTimer = null;
1549
+ /** 当前卡顿持续满 `sustainedStallMs` 的计时器;只为 playback 卡顿设置 */
1550
+ sustainedTimer = null;
1359
1551
  get monitorConfig() {
1360
1552
  return {
1361
1553
  ...DEFAULT_CONFIG$4,
@@ -1371,14 +1563,40 @@ var HealthMonitorPlugin = class extends BasePlugin {
1371
1563
  this.on(Events.PLAYING, this.handlePlaying);
1372
1564
  this.on(Events.SEEKING, this.handleSeeking);
1373
1565
  this.on(Events.SEEKED, this.handleSeeked);
1566
+ this.on(Events.LOAD_START, this.handleLoadStart);
1374
1567
  this.lastAdvanceAt = now();
1375
1568
  this.pollTimer = setInterval(this.poll, this.monitorConfig.pollIntervalMs);
1376
1569
  }
1377
1570
  /** 用箭头函数保持 this,否则 off / clearInterval 匹配不上(见 CLAUDE.md 红线) */
1378
1571
  handleWaiting = () => {
1379
- this.enterStall();
1572
+ if (this.stalling || this.waitingTimer !== null || isHidden()) return;
1573
+ const kind = this.classifyStall();
1574
+ const onsetAt = now();
1575
+ const onsetPosition = numberOr$2(this.surface.currentTime, 0);
1576
+ this.waitingTimer = setTimeout(() => {
1577
+ this.waitingTimer = null;
1578
+ if (numberOr$2(this.surface.currentTime, onsetPosition) > onsetPosition) return;
1579
+ this.enterStall(onsetAt, kind);
1580
+ }, this.monitorConfig.waitingStallThresholdMs);
1581
+ };
1582
+ /** 媒体开始新的加载(换源 / 重拉):回到「未见首帧」,重拉后的起播等待不再记成 playback(ADR-103) */
1583
+ handleLoadStart = () => {
1584
+ this.sawFirstFrame = false;
1585
+ this.lastTime = 0;
1586
+ this.lastAdvanceAt = now();
1587
+ this.resetFrameBase();
1380
1588
  };
1589
+ resetFrameBase() {
1590
+ this.lastFrames = null;
1591
+ this.sawFrameAdvance = false;
1592
+ this.lastFrameAdvanceAt = now();
1593
+ }
1594
+ clearWaiting() {
1595
+ if (this.waitingTimer !== null) clearTimeout(this.waitingTimer);
1596
+ this.waitingTimer = null;
1597
+ }
1381
1598
  handlePlaying = () => {
1599
+ this.clearWaiting();
1382
1600
  this.exitStall();
1383
1601
  this.sawFirstFrame = true;
1384
1602
  };
@@ -1405,19 +1623,61 @@ var HealthMonitorPlugin = class extends BasePlugin {
1405
1623
  if (this.surface.paused === true || isHidden()) {
1406
1624
  this.lastTime = t;
1407
1625
  this.lastAdvanceAt = now();
1626
+ this.lastFrameAdvanceAt = now();
1627
+ this.lastFrames = this.readFrames();
1408
1628
  this.resetDrain();
1409
1629
  return;
1410
1630
  }
1411
1631
  this.sampleBufferHealth();
1632
+ const framesFrozen = this.sampleFrames();
1412
1633
  if (t > this.lastTime) {
1413
1634
  this.lastTime = t;
1414
1635
  this.lastAdvanceAt = now();
1636
+ if (framesFrozen) {
1637
+ this.clearWaiting();
1638
+ this.enterStall(this.lastFrameAdvanceAt, this.classifyStall());
1639
+ return;
1640
+ }
1415
1641
  this.exitStall();
1416
1642
  return;
1417
1643
  }
1418
- if (!this.stalling && now() - this.lastAdvanceAt >= this.monitorConfig.stallThresholdMs) this.enterStall();
1644
+ if (!this.stalling && now() - this.lastAdvanceAt >= this.monitorConfig.stallThresholdMs) {
1645
+ this.clearWaiting();
1646
+ this.enterStall(this.lastAdvanceAt, this.classifyStall());
1647
+ }
1419
1648
  };
1420
1649
  /**
1650
+ * 采一次已解码视频帧数,返回「帧是否已冻结满 `stallThresholdMs`」(#102)。
1651
+ *
1652
+ * 只在见过帧数增长之后才可能返回 true;计数回退(换源重建)时重置基准。
1653
+ * seek 中解码会短暂停下,不按冻结算。
1654
+ */
1655
+ sampleFrames() {
1656
+ const frames = this.readFrames();
1657
+ if (frames === null) return false;
1658
+ const prev = this.lastFrames;
1659
+ this.lastFrames = frames;
1660
+ if (prev !== null && frames < prev) {
1661
+ this.sawFrameAdvance = false;
1662
+ this.lastFrameAdvanceAt = now();
1663
+ return false;
1664
+ }
1665
+ if (prev !== null && frames > prev) {
1666
+ this.sawFrameAdvance = true;
1667
+ this.lastFrameAdvanceAt = now();
1668
+ return false;
1669
+ }
1670
+ if (this.seeking) {
1671
+ this.lastFrameAdvanceAt = now();
1672
+ return false;
1673
+ }
1674
+ return this.sawFrameAdvance && now() - this.lastFrameAdvanceAt >= this.monitorConfig.stallThresholdMs;
1675
+ }
1676
+ readFrames() {
1677
+ const total = numberOr$2(this.surface.videoFrameInfo?.total, NaN);
1678
+ return Number.isFinite(total) ? total : null;
1679
+ }
1680
+ /**
1421
1681
  * 采一次缓冲余量,判断是不是在净流失(ADR-071)。
1422
1682
  *
1423
1683
  * **判据是「连续下降」,不是「低于某个秒数」。** 实测健康播放的余量是
@@ -1499,20 +1759,44 @@ var HealthMonitorPlugin = class extends BasePlugin {
1499
1759
  const end = buffered.end(buffered.length - 1);
1500
1760
  return Number.isFinite(end) ? end : null;
1501
1761
  }
1502
- enterStall() {
1762
+ /**
1763
+ * @param onsetAt 这次卡顿**开始**的时刻(开始等待 / 最后一次推进),`durationMs` 与持续阈值都从它算
1764
+ * @param kind 开始时定下的分类
1765
+ */
1766
+ enterStall(onsetAt, kind) {
1503
1767
  if (this.stalling) return;
1504
1768
  if (isHidden()) return;
1505
1769
  this.stalling = true;
1506
- this.stallStartAt = now();
1507
- this.stallKind = this.classifyStall();
1770
+ this.stallStartAt = onsetAt;
1771
+ this.stallKind = kind;
1508
1772
  const position = numberOr$2(this.surface.currentTime, 0);
1509
1773
  this.monitorConfig.onStall?.({
1510
1774
  phase: "start",
1511
1775
  position,
1512
- kind: this.stallKind
1776
+ kind
1513
1777
  });
1778
+ if (kind !== "playback") return;
1779
+ const remaining = Math.max(0, this.monitorConfig.sustainedStallMs - (now() - onsetAt));
1780
+ this.armSustained(kind, remaining);
1781
+ }
1782
+ /**
1783
+ * 满阈值请求恢复,卡顿仍在则每隔 `sustainedStallMs` 重申。只请求一次会丢请求:
1784
+ * 卡顿恰在上一轮恢复判成功前一刻开始时,请求被并入那一轮后随之结束,画面冻住却再无恢复(ADR-105 ④)。
1785
+ */
1786
+ armSustained(kind, delay) {
1787
+ this.sustainedTimer = setTimeout(() => {
1788
+ this.sustainedTimer = null;
1789
+ if (!this.stalling) return;
1790
+ this.monitorConfig.onSustainedStall?.({
1791
+ kind,
1792
+ position: numberOr$2(this.surface.currentTime, 0)
1793
+ });
1794
+ this.armSustained(kind, this.monitorConfig.sustainedStallMs);
1795
+ }, delay);
1514
1796
  }
1515
1797
  exitStall() {
1798
+ if (this.sustainedTimer !== null) clearTimeout(this.sustainedTimer);
1799
+ this.sustainedTimer = null;
1516
1800
  if (!this.stalling) return;
1517
1801
  this.stalling = false;
1518
1802
  const position = numberOr$2(this.surface.currentTime, 0);
@@ -1528,6 +1812,10 @@ var HealthMonitorPlugin = class extends BasePlugin {
1528
1812
  clearInterval(this.pollTimer);
1529
1813
  this.pollTimer = null;
1530
1814
  }
1815
+ this.clearWaiting();
1816
+ if (this.sustainedTimer !== null) clearTimeout(this.sustainedTimer);
1817
+ this.sustainedTimer = null;
1818
+ this.off(Events.LOAD_START, this.handleLoadStart);
1531
1819
  this.off(Events.WAITING, this.handleWaiting);
1532
1820
  this.off(Events.PLAYING, this.handlePlaying);
1533
1821
  this.off(Events.SEEKING, this.handleSeeking);
@@ -1550,8 +1838,7 @@ function isHidden() {
1550
1838
  /**
1551
1839
  * MediaSessionPlugin · 把契约的 `source.metadata` 喂给 W3C Media Session API
1552
1840
  *
1553
- * 它**不解决任何 pitfall**,所以不在 11 个稳定性插件那张表里 —— 归类上与
1554
- * `PlayableStatePlugin` 同档(见 CLAUDE.md「第 12 / 13 个插件」)。
1841
+ * 它**不解决任何 pitfall**,而是与 `PlayableStatePlugin` 同属生产 preset 的平台接线层。
1555
1842
  *
1556
1843
  * ## 为什么设置点必须在这一层
1557
1844
  *
@@ -1655,7 +1942,7 @@ var MediaSessionPlugin = class extends BasePlugin {
1655
1942
  //#region src/plugins/playable-state.ts
1656
1943
  const DEFAULT_CONFIG$3 = {
1657
1944
  enabled: true,
1658
- bufferingDebounceMs: 300,
1945
+ bufferingDebounceMs: 1e3,
1659
1946
  droppedRateThreshold: 15,
1660
1947
  degradedSamples: 3,
1661
1948
  pollIntervalMs: 1e3,
@@ -1695,6 +1982,10 @@ const REASON_TABLE = {
1695
1982
  playable: false,
1696
1983
  recoverable: true
1697
1984
  },
1985
+ source_switching: {
1986
+ playable: false,
1987
+ recoverable: true
1988
+ },
1698
1989
  autoplay_blocked: {
1699
1990
  playable: false,
1700
1991
  recoverable: false
@@ -1718,6 +2009,7 @@ const PRIORITY = [
1718
2009
  "error",
1719
2010
  "frame_disconnected",
1720
2011
  "autoplay_blocked",
2012
+ "source_switching",
1721
2013
  "reconnecting",
1722
2014
  "stalled",
1723
2015
  "buffering",
@@ -1726,7 +2018,7 @@ const PRIORITY = [
1726
2018
  "ok"
1727
2019
  ];
1728
2020
  /**
1729
- * PlayableStatePlugin(第 12 个稳定性插件)· 聚合播放状态
2021
+ * PlayableStatePlugin · 聚合播放状态
1730
2022
  *
1731
2023
  * 见 ADR-043 / issue #121。**它不产生新信息,只产生唯一结论。**
1732
2024
  *
@@ -1739,7 +2031,7 @@ const PRIORITY = [
1739
2031
  * **信号从两处来,分工是固定的**:
1740
2032
  * - **原生信号**(本插件自己 `this.on`):`loadeddata` / `play` / `waiting` / `playing`
1741
2033
  * - **兄弟插件的信号**(create-player 调本插件的 `setXxx`):卡顿(HealthMonitor)、
1742
- * 重连(Reconnect)、自动播放被拒(AutoplayGuard)、错误(create-player 的 error 映射)
2034
+ * 恢复调度、自动播放被拒(AutoplayGuard)、错误(create-player 的 error 映射)
1743
2035
  *
1744
2036
  * 之所以不让本插件直接监听那几个 —— 它们的判定逻辑在各自插件里(比如冻帧要轮询
1745
2037
  * `currentTime` 才测得出来),重听一遍就是重实现一遍,两份实现必然漂移。
@@ -1797,7 +2089,7 @@ var PlayableStatePlugin = class extends BasePlugin {
1797
2089
  this.stalling = value;
1798
2090
  this.publish();
1799
2091
  }
1800
- /** ReconnectPlugin 的重连状态机。重连**失败**归 `setError`,不是这里 */
2092
+ /** 统一恢复调度的重连状态。恢复**失败**归 `setRecovery`,不是这里 */
1801
2093
  setReconnecting(value) {
1802
2094
  if (this.reconnecting === value) return;
1803
2095
  this.reconnecting = value;
@@ -1887,6 +2179,7 @@ var PlayableStatePlugin = class extends BasePlugin {
1887
2179
  error: this.errored,
1888
2180
  frame_disconnected: false,
1889
2181
  autoplay_blocked: this.autoplayBlocked,
2182
+ source_switching: false,
1890
2183
  reconnecting: this.reconnecting,
1891
2184
  stalled: this.stalling,
1892
2185
  buffering: this.buffering,
@@ -1939,7 +2232,7 @@ const DEFAULT_CONFIG$2 = { restoreRootStyle: true };
1939
2232
  * 解决的 pitfall:**#3**(destroy 后事件监听器残留)、**#4**(destroy 后 timer / root 样式残留)、
1940
2233
  * **#23**(反复 destroy 崩溃)。
1941
2234
  *
1942
- * **没有 `enabled` 开关**——ARCHITECTURE § 10.2 明确它"强制,不可关"。
2235
+ * **没有 `enabled` 开关**——内存泄漏与重复销毁防护是强制能力,不提供关闭入口。
1943
2236
  * 一个能被关掉的内存泄漏防护没有意义。
1944
2237
  *
1945
2238
  * 注意 xgplayer 的 `BasePlugin.__destroy()` 本身已经会做 `offAll()` + `clearAllTimers(this)`,
@@ -2019,13 +2312,13 @@ function pickKernel(type, env) {
2019
2312
  * 最早也只到 `beforeCreate`,拿不到这个时机。所以它是一个纯函数,
2020
2313
  * 由 create-player 在构造 player 前调用。纯函数也更好测:UA 直接传进来就行。
2021
2314
  *
2022
- * 选择规则(ARCHITECTURE § 8.2):
2315
+ * 选择规则(见 ADR-056 与 MEDIA-SOURCE-GUIDE):
2023
2316
  * 1. iOS / 微信 / QQ / UC / 夸克 → **强制 HLS**;没 HLS 退 MP4;只剩 FLV 抛 `E_MEDIA_NOT_SUPPORTED`
2024
2317
  * 2. 其他平台:直播 `flv → hls → mp4`(FLV 延迟低);点播 `hls → mp4 → flv`(HLS 功能全)
2025
2318
  *
2026
2319
  * **降级只发生在选源这一刻,没有运行时兜底。** 上面的「→」是**候选缺失**时往下取
2027
2320
  * (没有 FLV 就用 HLS),**不是播放失败后换一个再试** —— 选完之后 `candidates` 里
2028
- * 剩下的项没有任何代码会再读:ReconnectPlugin 重连 reload 的是同一个 URL(#192),
2321
+ * 剩下的项没有任何代码会再读:统一恢复仍重拉同一个 URL(#192),
2029
2322
  * 而 `load()` 跨内核会直接抛 `E_METHOD_NOT_SUPPORTED`。想要真兜底就得销毁重建 player,
2030
2323
  * 那是一条要走 ADR 的独立能力(#217)。
2031
2324
  *
@@ -2059,7 +2352,7 @@ function routeSource(source, env) {
2059
2352
  reason: "无 HLS 候选,回退到原生 MP4",
2060
2353
  routeReason: "native_mp4_fallback"
2061
2354
  };
2062
- throwPlayerError("E_MEDIA_NOT_SUPPORTED", "FLV 在 iOS / 微信 / UC / 夸克 下无法播放,且候选源里没有 HLS 兜底。FLV 请始终和 HLS 一起放进 sources 数组。");
2355
+ throwPlayerError("E_MEDIA_NOT_SUPPORTED", "当前 SDK 不支持在 iOS / 微信 / UC / 夸克中播放 FLV,且候选源里没有 HLS 兜底。FLV 请始终和 HLS 一起放进 sources 数组。");
2063
2356
  }
2064
2357
  const order = source.live ? [
2065
2358
  "flv",
@@ -2129,7 +2422,7 @@ const DEFAULT_CONFIG$1 = {
2129
2422
  * iOS 把播放页切到后台再切回时,系统可能已回收底层 MSE / 解码管线:画面冻结在最后一帧、
2130
2423
  * `currentTime` 不再推进、声音丢失。xgplayer 自己不会恢复,消费方看到的是"卡死"。
2131
2424
  *
2132
- * 策略(ARCHITECTURE § 10.3「信号 + 外层重建」的插件内自愈版):
2425
+ * 策略(见 ADR-079、ADR-098/099 的统一恢复边界):
2133
2426
  * - 切**后台**(`document.hidden`)时,若正在播放,记下 `wasPlaying` + 当时的 `currentTime`。
2134
2427
  * - 切**回前台**时,只处理**直播**(点播 iOS 原生能续播,reload 会丢进度 → 不动)。
2135
2428
  * 等 `probeDelayMs` 给系统一个自恢复窗口,再探测:`currentTime` 没推进、或 paused → 判定卡死
@@ -2137,8 +2430,9 @@ const DEFAULT_CONFIG$1 = {
2137
2430
  *
2138
2431
  * 自愈的触发、尝试、验证和结果由 create-player 统一出为 `recovery` 事件;健康时不产生事件。
2139
2432
  *
2140
- * 插件不越界 `destroy` player(§ 10.3 红线),只重新拉流(`reloadStream`)——与 ReconnectPlugin
2141
- * **共用同一个实现**;从前是「同一手段」但各写各的,结果 #192 的修复只落在一边(见 `reload-stream.ts`)。
2433
+ * 生产装配下插件只提交恢复意图;没有协调器的独立使用才回落到 `reloadStream`。
2434
+ * 旧 ReconnectPlugin 曾与它各写一套重拉,#192 的修复只落在一边,因此两条历史路径后来共用
2435
+ * `reload-stream.ts`。
2142
2436
  *
2143
2437
  * @example
2144
2438
  * new Player({ el, plugins: [VisibilityPlugin], visibility: { isIOS: env.isIOS } })
@@ -2340,7 +2634,7 @@ const DEFAULT_CONFIG = {
2340
2634
  *
2341
2635
  * 解决的 pitfall:**#28**(覆盖层 z-index 冲突)、**#29**(fullscreen 时兄弟元素遮挡)
2342
2636
  * —— 部分 Android 浏览器 / WebView 把视频层的 z-index 抬得过高,
2343
- * 盖住宿主的弹窗 / 抽屉 / toast(ARCHITECTURE § 10.1「视频层级过高盖弹窗」)。
2637
+ * 盖住宿主的弹窗 / 抽屉 / toast;完整坑点说明见 `.claude/context/xgplayer-pitfalls.md`。
2344
2638
  *
2345
2639
  * ⚠️ 这两个编号**此前不在这里**:JSDoc 只写着「层级冲突」四个字,而
2346
2640
  * `.claude/context/xgplayer-pitfalls.md`(35 个编号的唯一事实源)的快速索引表
@@ -2392,9 +2686,13 @@ var ZIndexGuardPlugin = class extends BasePlugin {
2392
2686
  /**
2393
2687
  * 观察一次恢复动作的强播放证据。仅 playing 后正常位置推进算成功;
2394
2688
  * seek、暂停和等待会重置基准。调用方负责把成功关联到当前动作凭据。
2689
+ * 点播传入 `beyond`(故障位置)时,推进还必须越过它:从头重播出的前缀不是恢复(#96)。
2690
+ * 有视频画面(`videoWidth > 0`)且拿得到解码帧计数时,还要求帧数自基准后增长:
2691
+ * 视频解码停而音频照走时位置也会推进,那不是画面恢复(#103 · ADR-105)。
2395
2692
  */
2396
- function observeRecoveryPlayback(media, signal, recovered) {
2693
+ function observeRecoveryPlayback(media, signal, recovered, options = {}) {
2397
2694
  let baseline = null;
2695
+ let frameBaseline = null;
2398
2696
  let playingSeen = false;
2399
2697
  let disposed = false;
2400
2698
  const reset = () => {
@@ -2404,12 +2702,16 @@ function observeRecoveryPlayback(media, signal, recovered) {
2404
2702
  const playing = () => {
2405
2703
  playingSeen = true;
2406
2704
  baseline = Number.isFinite(media.currentTime) ? media.currentTime : null;
2705
+ frameBaseline = decodedVideoFrames(media);
2407
2706
  };
2408
2707
  const seeking = () => {
2409
2708
  baseline = null;
2410
2709
  };
2411
2710
  const seeked = () => {
2412
- if (playingSeen && !media.paused && !media.seeking && media.readyState >= 2) baseline = Number.isFinite(media.currentTime) ? media.currentTime : null;
2711
+ if (playingSeen && !media.paused && !media.seeking && media.readyState >= 2) {
2712
+ baseline = Number.isFinite(media.currentTime) ? media.currentTime : null;
2713
+ frameBaseline = decodedVideoFrames(media);
2714
+ }
2413
2715
  };
2414
2716
  const resets = [
2415
2717
  "pause",
@@ -2430,6 +2732,8 @@ function observeRecoveryPlayback(media, signal, recovered) {
2430
2732
  const progress = () => {
2431
2733
  if (disposed || baseline === null || media.paused || media.seeking || media.readyState < 2) return;
2432
2734
  if (!Number.isFinite(media.currentTime) || media.currentTime <= baseline) return;
2735
+ if (options.beyond !== void 0 && media.currentTime <= options.beyond) return;
2736
+ if (frameBaseline !== null && (decodedVideoFrames(media) ?? 0) <= frameBaseline) return;
2433
2737
  dispose();
2434
2738
  recovered();
2435
2739
  };
@@ -2442,6 +2746,12 @@ function observeRecoveryPlayback(media, signal, recovered) {
2442
2746
  signal.addEventListener("abort", dispose, { once: true });
2443
2747
  return dispose;
2444
2748
  }
2749
+ /** 有视频画面时的已解码帧累计数;纯音频或浏览器不提供计数时为 `null`(不以帧为证据) */
2750
+ function decodedVideoFrames(media) {
2751
+ if (!(media instanceof HTMLVideoElement) || media.videoWidth <= 0) return null;
2752
+ const quality = media.getVideoPlaybackQuality?.();
2753
+ return quality && Number.isFinite(quality.totalVideoFrames) ? quality.totalVideoFrames : null;
2754
+ }
2445
2755
  /** 媒体修复会重新挂载 MSE;先订阅 canplay,避免对尚未挂载的元素提前 play。 */
2446
2756
  function waitForMediaRepair(media, signal, repair) {
2447
2757
  if (signal.aborted) return Promise.resolve(false);
@@ -2486,6 +2796,19 @@ function describePlaybackRuntime(env) {
2486
2796
  //#endregion
2487
2797
  //#region src/source-normalize.ts
2488
2798
  /**
2799
+ * 把 URL 回显进错误消息前只保留 origin+pathname —— 这两处抛出在 createPlayer 里同步
2800
+ * 发生,不经 `makePlayerError`,原样回显会把签名 URL 的完整 query(含 token)暴露给宿主。
2801
+ * 解析失败(如相对路径拼接错误)时回显占位符,而不是原样吐出未知格式的字符串。
2802
+ */
2803
+ function redactUrlForMessage(url) {
2804
+ try {
2805
+ const parsed = new URL(url);
2806
+ return `${parsed.origin}${parsed.pathname}`;
2807
+ } catch {
2808
+ return "(无法解析的 URL)";
2809
+ }
2810
+ }
2811
+ /**
2489
2812
  * 从 URL 推断媒体类型。
2490
2813
  *
2491
2814
  * 先剥掉 query 和 hash 再看扩展名 —— 鉴权只能走签名 URL(坑 #26),而签名 URL 长这样
@@ -2519,7 +2842,7 @@ function isMultiSource(source) {
2519
2842
  function normalizeSource(source) {
2520
2843
  if (typeof source === "string") {
2521
2844
  const type = inferTypeFromUrl(source);
2522
- if (!type) throw new Error(`无法从 URL 推断媒体类型:${source}。请改用对象形式并显式传 type,如 { url, type: 'hls' }`);
2845
+ if (!type) throw new Error(`无法从 URL 推断媒体类型:${redactUrlForMessage(source)}。请改用对象形式并显式传 type,如 { url, type: 'hls' }`);
2523
2846
  return {
2524
2847
  candidates: [{
2525
2848
  url: source,
@@ -2540,7 +2863,7 @@ function normalizeSource(source) {
2540
2863
  };
2541
2864
  const explicit = source.type;
2542
2865
  const type = explicit && explicit !== "auto" ? explicit : inferTypeFromUrl(source.url);
2543
- if (!type) throw new Error(`无法从 URL 推断媒体类型:${source.url}。请显式传 type,如 { url, type: 'hls' }`);
2866
+ if (!type) throw new Error(`无法从 URL 推断媒体类型:${redactUrlForMessage(source.url)}。请显式传 type,如 { url, type: 'hls' }`);
2544
2867
  return {
2545
2868
  candidates: [{
2546
2869
  url: source.url,
@@ -2580,9 +2903,8 @@ const CONTROL_MESSAGE_KEYS = {
2580
2903
  /**
2581
2904
  * 把契约的 `locale.messages` 里那一族 `controls.*` 翻成 xgplayer 的实例级 i18n 条目。
2582
2905
  *
2583
- * **返回 `null` 表示「一条都没有」,调用方应当整个省略 `i18n` 键** ——
2584
- * 不是传空数组:省略时 xgplayer 走 `this.config.i18n || []`,行为与接线前逐字节相同,
2585
- * 而传 `[]` 虽然等价却让 config 的形状变了,e2e / 快照会无谓地漂。
2906
+ * 每次返回当前实例语言的完整控件快照。第三语言复用内部英文槽位;只注入部分 key
2907
+ * 会在切回另一种语言时留下上一种语言的文案。
2586
2908
  *
2587
2909
  * `lang` 由调用方传入(`toXgLang` 收敛后的 `zh-cn` / `en`)——
2588
2910
  * **注入的语义是「覆盖当前那一档的文案」,不是「注册一个新语种」**:
@@ -2593,7 +2915,7 @@ const CONTROL_MESSAGE_KEYS = {
2593
2915
  *
2594
2916
  * @example
2595
2917
  * toXgI18nEntries({ locale: 'vi-VN', messages: { 'vi-VN': { 'controls.play': 'Phát' } } }, 'en')
2596
- * // → [{ LANG: 'en', TEXT: { PLAY_TIPS: 'Phát' } }]
2918
+ * // → [{ LANG: 'en', TEXT: { PLAY_TIPS: 'Phát', ...其余键逐项回退中文 } }]
2597
2919
  */
2598
2920
  function toXgI18nEntries(locale, lang) {
2599
2921
  const { messages } = resolveLocaleMessages(locale);
@@ -2602,10 +2924,10 @@ function toXgI18nEntries(locale, lang) {
2602
2924
  const value = messages[contractKey];
2603
2925
  if (typeof value === "string" && value !== "") text[xgKey] = value;
2604
2926
  }
2605
- return Object.keys(text).length > 0 ? [{
2927
+ return [{
2606
2928
  LANG: lang,
2607
2929
  TEXT: text
2608
- }] : null;
2930
+ }];
2609
2931
  }
2610
2932
  //#endregion
2611
2933
  //#region src/create-player.ts
@@ -2774,29 +3096,28 @@ function createPlayer(options) {
2774
3096
  const normalized = normalizeSource(config.source);
2775
3097
  let sessionId = newSessionId();
2776
3098
  let deliverySequence = 0;
2777
- const makeDelivered = (event) => {
3099
+ const attachDelivery = (event) => {
2778
3100
  deliverySequence += 1;
2779
3101
  return {
2780
- event,
2781
- producerSessionId: sessionId,
2782
- deliveryId: `${sessionId}:${deliverySequence}`,
2783
- occurredAtMs: Date.now(),
2784
- sequence: deliverySequence
3102
+ ...event,
3103
+ delivery: {
3104
+ producerSessionId: sessionId,
3105
+ deliveryId: `${sessionId}:${deliverySequence}`,
3106
+ occurredAtMs: Date.now(),
3107
+ sequence: deliverySequence
3108
+ }
2785
3109
  };
2786
3110
  };
2787
- const notify = (delivered) => {
2788
- try {
2789
- options.onEvent?.(delivered.event);
2790
- } catch {}
3111
+ const notify = (event) => {
2791
3112
  try {
2792
- options.onDeliveredEvent?.(delivered);
3113
+ options.onEvent?.(event);
2793
3114
  } catch {}
2794
3115
  };
2795
3116
  let routed;
2796
3117
  try {
2797
3118
  routed = routeSource(normalized, env);
2798
3119
  } catch (error) {
2799
- if (error instanceof SentinelError && error.playerError.code === "E_MEDIA_NOT_SUPPORTED") notify(makeDelivered({
3120
+ if (error instanceof SentinelError && error.playerError.code === "E_MEDIA_NOT_SUPPORTED") notify(attachDelivery({
2800
3121
  event: "sourceroute",
2801
3122
  payload: unsupportedRoutePayload(sessionId, normalized, env)
2802
3123
  }));
@@ -2812,7 +3133,7 @@ function createPlayer(options) {
2812
3133
  };
2813
3134
  const emit = (event) => {
2814
3135
  if (destroyed) return;
2815
- const delivered = makeDelivered(event);
3136
+ const delivered = attachDelivery(event);
2816
3137
  if (!eventStreamReady) {
2817
3138
  deferredEvents.push(delivered);
2818
3139
  return;
@@ -2890,6 +3211,7 @@ function createPlayer(options) {
2890
3211
  options.el.ownerDocument.addEventListener("keydown", onPageEscape);
2891
3212
  }
2892
3213
  playerRef = player;
3214
+ const lastFrame = createLastFramePlaceholder(() => player.video instanceof HTMLMediaElement ? player.video : null);
2893
3215
  let desiredPlayback = config.autoplay ?? false;
2894
3216
  let recoveryCleanup = null;
2895
3217
  const stopRecoveryLoad = () => {
@@ -2903,10 +3225,29 @@ function createPlayer(options) {
2903
3225
  }
2904
3226
  }
2905
3227
  };
3228
+ /**
3229
+ * 当前是否真的做得了媒体修复:只有 hls.js 内核挂载了插件时才有 recoverMediaError。
3230
+ * 返回可调用的修复函数,`null` 表示这一步只能整条重拉(#111)。
3231
+ */
3232
+ const hlsMediaRepair = () => {
3233
+ if (routed.kernel !== "hls.js") return null;
3234
+ const plugin = player.getPlugin("HlsJsPlugin");
3235
+ return plugin?.recoverMediaError ? () => plugin.recoverMediaError() : null;
3236
+ };
3237
+ let recoveryResume = null;
2906
3238
  recovery = new PlaybackRecovery(sessionId, async (token, signal) => {
2907
3239
  recoveryCleanup?.();
2908
3240
  const media = player.video;
2909
3241
  if (!(media instanceof HTMLMediaElement)) throw new Error("Recovery requires a media element");
3242
+ lastFrame.capture();
3243
+ if (recoveryResume?.episode !== token.episode) {
3244
+ const position = media.currentTime;
3245
+ recoveryResume = {
3246
+ episode: token.episode,
3247
+ position: !normalized.live && Number.isFinite(position) && position > 0 ? position : 0
3248
+ };
3249
+ }
3250
+ const resumeAt = recoveryResume.position;
2910
3251
  const cleanup = () => {
2911
3252
  unobserve();
2912
3253
  signal.removeEventListener("abort", abort);
@@ -2920,14 +3261,21 @@ function createPlayer(options) {
2920
3261
  const unobserve = observeRecoveryPlayback(media, signal, () => {
2921
3262
  cleanup();
2922
3263
  recovery?.recovered(token);
2923
- });
3264
+ }, resumeAt > 0 ? { beyond: resumeAt } : {});
2924
3265
  recoveryCleanup = cleanup;
2925
3266
  signal.addEventListener("abort", abort, { once: true });
2926
- const repaired = token.strategy === "media_recovery" && token.attempt === 1 && routed.kernel === "hls.js" && await waitForMediaRepair(media, signal, () => player.getPlugin("HlsJsPlugin")?.recoverMediaError() ?? false);
3267
+ const repaired = token.strategy === "media_recovery" && await waitForMediaRepair(media, signal, () => hlsMediaRepair()?.() ?? false);
2927
3268
  if (signal.aborted || destroyed) return;
2928
3269
  if (!repaired) await player.switchURL(currentSrcUrl);
3270
+ if (!signal.aborted && !destroyed && media.currentTime < resumeAt) media.currentTime = resumeAt;
2929
3271
  if (!signal.aborted && !destroyed && recovery?.playingIntent) await player.play();
2930
- }, emit, (state) => playableState()?.setRecovery(state));
3272
+ }, emit, (state) => {
3273
+ if (!state.recovering) lastFrame.release();
3274
+ playableState()?.setRecovery(state);
3275
+ }, {
3276
+ failsAttempt: (error) => !normalized.live && error.category === "media",
3277
+ resolveStrategy: (strategy, attempt) => strategy === "media_recovery" && !(attempt === 1 && hlsMediaRepair()) ? "reconnect" : strategy
3278
+ });
2931
3279
  recovery.setPlayingIntent(desiredPlayback);
2932
3280
  const listeners = [];
2933
3281
  const listen = (name, handler) => {
@@ -2936,6 +3284,7 @@ function createPlayer(options) {
2936
3284
  };
2937
3285
  const hlsCleanups = [];
2938
3286
  const disposers = [];
3287
+ disposers.push(() => lastFrame.release());
2939
3288
  disposers.push(observeMediaPlayRejection(player));
2940
3289
  let flvAudioHealth = null;
2941
3290
  let flvPlaybackActive = false;
@@ -2962,18 +3311,18 @@ function createPlayer(options) {
2962
3311
  *
2963
3312
  * ```
2964
3313
  * player.switchURL(url)
2965
- * → xgplayer/es/player.js this.src = _src
2966
- * → xgplayer/es/mediaProxy.js emit(URL_CHANGE, url)
2967
- * → xgplayer-hls.js/es/index.js on(URL_CHANGE) → register(url)
2968
- * → xgplayer-hls.js/es/index.js this.hls.destroy(); this.hls = new Hls(...)
3314
+ * → xgplayer/es/player.js this.src = _src
3315
+ * → xgplayer/es/mediaProxy.js emit(URL_CHANGE, url)
3316
+ * → OwnedHlsPlugin.on(URL_CHANGE) register(url)
3317
+ * → HlsAdapter.stop() + start(url) 换成新的 hls.js 实例
2969
3318
  * ```
2970
3319
  *
2971
3320
  * 布尔标志一旦置起就再也不复位,于是新实例上一条监听都没有 —— `qualitychange`
2972
3321
  * 不再发、`currentQualityLevel` 永远停在 `null`(而契约说 `null` 的含义是
2973
3322
  * 「单码率源或档位未知」,这里会把多码率源持续误报)。
2974
3323
  *
2975
- * **触发入口不只是 `load()`**:`ReconnectPlugin` 的每一次重试和 `VisibilityPlugin`
2976
- * 的 iOS 后台切回自愈都走 `reloadStream()` → `switchURL`。所以复位点**不能只放在
3324
+ * **触发入口不只是 `load()`**:统一恢复的重拉动作和 `VisibilityPlugin`
3325
+ * 的 iOS 后台切回意图都可能走 `reloadStream()` → `switchURL`。所以复位点**不能只放在
2977
3326
  * `load()` 里** —— 判据必须是「实例还是不是同一个」,而不是「有没有换过源」。
2978
3327
  *
2979
3328
  * `reload-stream.ts` 的注释早就写着 `switchURL` 会「重新建 hls 实例并 attach」,
@@ -3020,7 +3369,7 @@ function createPlayer(options) {
3020
3369
  emitContextChange();
3021
3370
  for (const delivered of deferredEvents) {
3022
3371
  notify(delivered);
3023
- reportRecoveryFault(delivered.event, delivered.producerSessionId);
3372
+ reportRecoveryFault(delivered, delivered.delivery?.producerSessionId ?? sessionId);
3024
3373
  }
3025
3374
  deferredEvents.length = 0;
3026
3375
  const attachQualityListener = () => {
@@ -3067,6 +3416,8 @@ function createPlayer(options) {
3067
3416
  };
3068
3417
  disposers.push(() => naturalProof?.abort());
3069
3418
  let sawFirstFrame = false;
3419
+ let firstFrameDataReady = false;
3420
+ let firstFrameStartedAt = performance.now();
3070
3421
  let sawPlaybackStart = false;
3071
3422
  let sawPlaying = false;
3072
3423
  let sawStartupWaiting = false;
@@ -3097,6 +3448,8 @@ function createPlayer(options) {
3097
3448
  armStartupDeadline();
3098
3449
  const resetPrematureVodEndState = () => {
3099
3450
  sawFirstFrame = false;
3451
+ firstFrameDataReady = false;
3452
+ firstFrameStartedAt = performance.now();
3100
3453
  sawPlaybackStart = false;
3101
3454
  sawPlaying = false;
3102
3455
  sawStartupWaiting = false;
@@ -3105,6 +3458,15 @@ function createPlayer(options) {
3105
3458
  seeking = false;
3106
3459
  prematureVodEnd = null;
3107
3460
  };
3461
+ const markFirstFrame = (fvt) => {
3462
+ if (sawFirstFrame) return;
3463
+ sawFirstFrame = true;
3464
+ clearStartupDeadline();
3465
+ emit({
3466
+ event: "firstframe",
3467
+ payload: { fvt }
3468
+ });
3469
+ };
3108
3470
  const observePlaybackPosition = () => {
3109
3471
  const position = safeNumber(player.currentTime);
3110
3472
  const duration = safeNumber(player.duration);
@@ -3171,13 +3533,15 @@ function createPlayer(options) {
3171
3533
  desiredPlayback = false;
3172
3534
  clearStartupDeadline();
3173
3535
  recovery?.endPlayback();
3174
- }
3536
+ } else if (!normalized.live && recovery?.attemptInFlight) recovery.endPlayback();
3537
+ else if (normalized.live) recovery?.requestRecovery("ended");
3175
3538
  emit({
3176
3539
  event: "ended",
3177
3540
  payload: {}
3178
3541
  });
3179
3542
  });
3180
3543
  listen("loadeddata", () => {
3544
+ firstFrameDataReady = true;
3181
3545
  attachQualityListener();
3182
3546
  emit({
3183
3547
  event: "ready",
@@ -3191,6 +3555,8 @@ function createPlayer(options) {
3191
3555
  });
3192
3556
  listen("timeupdate", () => {
3193
3557
  observePlaybackPosition();
3558
+ const media = player.video;
3559
+ if (!sawFirstFrame && firstFrameDataReady && desiredPlayback && media instanceof HTMLMediaElement && !media.paused && media.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) markFirstFrame(Math.max(0, performance.now() - firstFrameStartedAt));
3194
3560
  emit({
3195
3561
  event: "timeupdate",
3196
3562
  payload: {
@@ -3233,12 +3599,7 @@ function createPlayer(options) {
3233
3599
  if (log.eventType !== "firstFrame") return;
3234
3600
  const fvt = log.fvt;
3235
3601
  if (typeof fvt !== "number" || !Number.isFinite(fvt)) return;
3236
- sawFirstFrame = true;
3237
- clearStartupDeadline();
3238
- emit({
3239
- event: "firstframe",
3240
- payload: { fvt }
3241
- });
3602
+ markFirstFrame(fvt);
3242
3603
  });
3243
3604
  listen("fps_stuck", (raw) => {
3244
3605
  const payload = normalizeFrameFreeze(raw);
@@ -3251,14 +3612,17 @@ function createPlayer(options) {
3251
3612
  listen("user_action", (raw) => {
3252
3613
  const payload = normalizeUserAction(raw);
3253
3614
  if (payload === null) return;
3254
- if (payload.action === "switch_play_pause" && typeof payload.to === "boolean") setPlayingIntent(!payload.to);
3615
+ if (payload.action === "switch_play_pause") {
3616
+ const video = player.video;
3617
+ setPlayingIntent(typeof payload.to === "boolean" ? !payload.to : video instanceof HTMLMediaElement && video.paused);
3618
+ }
3255
3619
  emit({
3256
3620
  event: "useraction",
3257
3621
  payload
3258
3622
  });
3259
3623
  });
3260
3624
  listen("error", (err) => {
3261
- const mapped = mapXgplayerError(err);
3625
+ const mapped = mapXgplayerError(err, { live: normalized.live });
3262
3626
  if ((flvJustReported || hlsJustReported) && mapped.code === "E_UNKNOWN") return;
3263
3627
  sawNativeError = true;
3264
3628
  emit({
@@ -3306,10 +3670,8 @@ function createPlayer(options) {
3306
3670
  /**
3307
3671
  * HLS 侧的同一道闸(#724 · A-8)。理由与 `flvJustReported` **同构**,只是上游形状不同。
3308
3672
  *
3309
- * `xgplayer-hls.js` 对每条 hls 错误先 `emit('HLS_ERROR', …)`,随后
3310
- * `if (data.fatal) switch (data.type)` —— NETWORK / MEDIA 两支私吞,
3311
- * **`default:` 支再 `emit('error', data)`**(即 `muxError` / `keySystemError` /
3312
- * `otherError` 这三类 fatal)。
3673
+ * SDK 自有 `OwnedHlsPlugin` 对每条 hls.js 诊断先发结构化 `HLS_ERROR`;xgplayer 的
3674
+ * 通用错误路径在部分 fatal 形态下仍可能同步补一条信息更少的 `error`。
3313
3675
  *
3314
3676
  * 于是同一次故障走两条路:`HLS_ERROR` 那条经 `mapHlsKernelError` 精确映射(好的),
3315
3677
  * `error` 那条经 `mapXgplayerError` —— 而它读 `errorType`、查的是 xgplayer 自己的词表
@@ -3486,10 +3848,9 @@ function createPlayer(options) {
3486
3848
  /**
3487
3849
  * 内核诊断 → 契约 error 流(#402)。
3488
3850
  *
3489
- * `xgplayer-hls.js` 的包装层把 **NETWORK / MEDIA 两类 fatal 错误私吞了**
3490
- *(`startLoad()` 重试 / `recoverMediaError()`,都不 `emit('error')`),
3491
- * 而 `status === 404` 时连重试都没有。实测:manifest 404 → hls.js 报
3492
- * `manifestLoadError` **fatal=true**,消费方拿到的契约 `error` **0 条**。
3851
+ * SDK 自有 `OwnedHlsPlugin` 会把 hls.js 的诊断清洗为 `HLS_ERROR`;这里负责尽早接住、
3852
+ * 映射为契约错误或送入非 fatal 聚合。不能等 `loadeddata` 后再挂监听,因为 manifest
3853
+ * 加载失败时该事件永远不会发生。
3493
3854
  *
3494
3855
  * ⚠️ **必须挂在 player 上,不能去够 hls 实例。** 三条都是实测出来的:
3495
3856
  * ① `attachQualityListener` 那个挂载点用不了 —— 它在 `loadeddata` 里挂,
@@ -3499,9 +3860,8 @@ function createPlayer(options) {
3499
3860
  * `manifestLoadError fatal=true`,差别只是 50ms 轮询与 `loadSource` 的先后。
3500
3861
  * **一个会随机漏报的接线比没有更坏**,因为它看起来在工作。
3501
3862
  *
3502
- * `HLS_ERROR` 是 player 级事件、包装层对每条 hls 错误都发,三条一次绕开。
3503
- *
3504
- * **只做可见性,不改恢复行为**:不触发重连、不干预 `startLoad` / `recoverMediaError`。
3863
+ * `HLS_ERROR` 是 player 级事件,OwnedHlsPlugin 对每条 hls.js 错误都发;映射层本身只
3864
+ * 提供可见性,恢复动作由 PlaybackRecovery / RecoveryController 决定。
3505
3865
  */
3506
3866
  /**
3507
3867
  * 非致命诊断的**聚合**出口(#391 · ADR-062)。
@@ -3515,17 +3875,21 @@ function createPlayer(options) {
3515
3875
  * 非 fatal 走这里,同一条诊断只进一边。改动时别让 fatal 也 `record` 进来 ——
3516
3876
  * 那会让 `count` 把已经报过 `error` 的东西再数一遍。
3517
3877
  */
3518
- const kernelHealth = createKernelHealthAggregator({ onReport: (payload) => emit({
3519
- event: "kernelhealth",
3520
- payload
3521
- }) });
3878
+ const kernelHealth = createKernelHealthAggregator({ onReport: (payload) => {
3879
+ if (payload.degraded) recovery?.noteKernelHealth(payload.reason);
3880
+ emit({
3881
+ event: "kernelhealth",
3882
+ payload
3883
+ });
3884
+ } });
3522
3885
  disposers.push(() => kernelHealth.dispose());
3523
3886
  listen("HLS_ERROR", (...args) => {
3524
3887
  const payload = args.at(-1);
3525
3888
  if (typeof payload !== "object" || payload === null) return;
3526
3889
  const hls = payload;
3527
- const mapped = mapHlsKernelError(hls);
3890
+ const mapped = mapHlsKernelError(hls, { live: normalized.live });
3528
3891
  if (mapped === void 0) {
3892
+ if (hls.httpStatus === 401 || hls.httpStatus === 403) recovery?.noteKernelHealth("auth");
3529
3893
  kernelHealth.record(hlsHealthReason(hls), `${hls.errorType ?? "未知"} / ${hls.errorDetails ?? "未知"}`);
3530
3894
  return;
3531
3895
  }
@@ -3614,9 +3978,9 @@ function createPlayer(options) {
3614
3978
  setLocale(locale) {
3615
3979
  if (destroyed) return;
3616
3980
  const lang = toXgLang(locale);
3617
- if (!lang) return;
3618
3981
  extendInstanceI18n(player, locale, lang);
3619
3982
  player.lang = lang;
3983
+ syncXgplayerTimeLocale(player);
3620
3984
  },
3621
3985
  pushDanmaku(item) {
3622
3986
  if (destroyed) return;
@@ -3744,24 +4108,14 @@ function createPlayer(options) {
3744
4108
  * 覆盖层是越南语、控件是英文,两半对不上。接了这条线,`locale` 才真正统辖两者。
3745
4109
  *
3746
4110
  * 注意作用范围:这里只管 **xgplayer 自带控件**的文案;player-ui 的 4 个覆盖层文案
3747
- * 走 `locale.messages` 注入(SDK 不内置翻译),两者互不干扰。
4111
+ * 由消费面解析官方资源及 `locale.messages` 后注入,两者使用同一有效语言。
3748
4112
  *
3749
- * **未传 locale 时返回 undefined,调用方须整个省略 `lang` 键** —— 不能兜底成 `'en'`。
3750
- * 省略时 xgplayer 走自己的 `getLang()`:
3751
- * `document.documentElement.getAttribute('lang') || navigator.language || 'zh-cn'`
3752
- * (`utils/util.js:815`)。**注意 `<html lang>` 排在浏览器语言前面** —— 宿主页声明了 lang
3753
- * 就以它为准,没声明才跟浏览器走。兜底成 `'en'` 会把这整套既有行为一刀切掉。
3754
- * 这条线只该在消费方明确表态时才接管。
3755
- *
3756
- * ⚠️ 由此带来的一个实测结论(#190 step 3):`apps/embed-app/index.html` 写死 `<html lang="en">`,
3757
- * 所以**静态 iframe 那条路不传 locale 时控件恒为英文**,读者的浏览器语言完全不参与。
3758
- * 那条路现在有 `?locale=` 了(`config-from-url.ts`),它是那一页上唯一能改控件语言的东西;
3759
- * e2e 在 `locale-static-iframe.spec.ts` 里把这两半都钉住了。
4113
+ * 未传 locale 时固定选 `zh-cn`,不再跟随宿主页面或浏览器语言(ADR-120)。
3760
4114
  */
3761
4115
  function toXgLang(locale) {
3762
4116
  const tag = typeof locale === "string" ? locale : locale?.locale;
3763
- if (!tag) return void 0;
3764
- return tag.toLowerCase().startsWith("zh") ? "zh-cn" : "en";
4117
+ if (!tag) return "zh-cn";
4118
+ return /^(zh|zh-cn)$/i.test(tag.replaceAll("_", "-")) ? "zh-cn" : "en";
3765
4119
  }
3766
4120
  /**
3767
4121
  * 把 `controls.*` extend 进**这个播放器实例**的 i18n 表(运行时切语言用,ADR-035)。
@@ -3773,11 +4127,28 @@ function toXgLang(locale) {
3773
4127
  */
3774
4128
  function extendInstanceI18n(player, locale, lang) {
3775
4129
  const entries = toXgI18nEntries(locale, lang);
3776
- if (!entries) return;
3777
4130
  const instanceI18n = player.__i18n;
3778
4131
  if (!instanceI18n) return;
3779
4132
  I18N.extend(entries, instanceI18n);
3780
4133
  }
4134
+ /**
4135
+ * 补齐 xgplayer 3.0.26 Time 控件的运行时语言切换。
4136
+ *
4137
+ * 该控件首次渲染 `.time-live-tag` 时直接写入 `i18n.LIVE_TIP`,却没有像其他
4138
+ * 官方控件一样附上 `lang-key`。因此 `player.lang = ...` 会更新播放、全屏等提示,
4139
+ * 却把直播标签留在旧语言。这里只在当前播放器的 Time 插件根节点内补齐文案和语言键;
4140
+ * 无该插件、DOM 形状变化或翻译缺失时都安全降级,不写 xgplayer 全局 i18n。
4141
+ */
4142
+ function syncXgplayerTimeLocale(player) {
4143
+ const playerWithI18n = player;
4144
+ const liveTag = playerWithI18n.getPlugin?.("time")?.root?.querySelector?.(".time-live-tag");
4145
+ if (!liveTag) return;
4146
+ const liveText = playerWithI18n.i18n?.LIVE_TIP;
4147
+ if (typeof liveText !== "string" || liveText === "") return;
4148
+ liveTag.textContent = liveText;
4149
+ const langKey = playerWithI18n.i18nKeys?.LIVE_TIP;
4150
+ if (typeof langKey === "string" && langKey !== "") liveTag.setAttribute("lang-key", langKey);
4151
+ }
3781
4152
  function buildXgplayerConfig(options, routed, normalized, emit, env, playableState, recovery) {
3782
4153
  const { config } = options;
3783
4154
  const live = normalized.live;
@@ -3810,12 +4181,10 @@ function buildXgplayerConfig(options, routed, normalized, emit, env, playableSta
3810
4181
  ...config.controlVisibility?.volume === false ? ["volume"] : [],
3811
4182
  ...config.controlVisibility?.time === false ? ["time"] : []
3812
4183
  ],
3813
- ...toXgLang(config.locale) ? { lang: toXgLang(config.locale) } : {},
4184
+ lang: toXgLang(config.locale),
3814
4185
  ...(() => {
3815
4186
  const lang = toXgLang(config.locale);
3816
- if (!lang) return {};
3817
- const entries = toXgI18nEntries(config.locale, lang);
3818
- return entries ? { i18n: entries } : {};
4187
+ return { i18n: toXgI18nEntries(config.locale, lang) };
3819
4188
  })(),
3820
4189
  plugins,
3821
4190
  ...subtitleList.length > 0 ? { texttrack: {
@@ -3859,7 +4228,9 @@ function buildXgplayerConfig(options, routed, normalized, emit, env, playableSta
3859
4228
  payload
3860
4229
  });
3861
4230
  playableState()?.setStalled(payload.phase === "start");
3862
- if (payload.phase === "start" && payload.kind === "playback") recovery()?.requestRecovery("stall");
4231
+ },
4232
+ onSustainedStall: () => {
4233
+ recovery()?.requestRecovery("stall");
3863
4234
  },
3864
4235
  onBufferHealth: (payload) => {
3865
4236
  emit({
@@ -3892,7 +4263,7 @@ function buildXgplayerConfig(options, routed, normalized, emit, env, playableSta
3892
4263
  };
3893
4264
  }
3894
4265
  /**
3895
- * 取 xgplayer-hls.js 插件持有的 hls.js 实例。非 HLS 源(MP4 走原生、FLV 走 flv.js)
4266
+ * 取 SDK 自有 OwnedHlsPlugin 持有的 hls.js 实例。非 HLS 源(MP4 走原生、FLV 走 flv.js)
3896
4267
  * 拿不到,返回 undefined —— 上层据此把多码率相关能力降级成 no-op / 空档位。
3897
4268
  */
3898
4269
  function getHlsInstance(player) {