@video-lab/player-core 3.1.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -198,18 +198,7 @@ function extractHttpStatus(err) {
198
198
  function isAutoplayBlocked(err) {
199
199
  return typeof err === "object" && err !== null && "name" in err && err.name === "NotAllowedError";
200
200
  }
201
- /**
202
- * xgplayer 错误 → 契约的 PlayerError。
203
- *
204
- * 优先级:autoplay 拒绝 > HTTP 认证失败 > errorType > MediaError.code > 兜底 E_UNKNOWN。
205
- * 认证放在 errorType 之前,是因为 401/403 在 xgplayer 里也会报成 `network`,
206
- * 但对消费方来说"登录过期"和"网络断了"要给完全不同的提示。
207
- *
208
- * @example
209
- * mapXgplayerError({ errorType: 'timeout', message: '请求超时' })
210
- * // → { code: 'E_NETWORK_TIMEOUT', category: 'network', retryable: true, ... }
211
- */
212
- function mapXgplayerError(err) {
201
+ function mapXgplayerError(err, context = {}) {
213
202
  if (isAutoplayBlocked(err)) return (0, _video_lab_protocol.makePlayerError)("E_AUTOPLAY_BLOCKED", "浏览器拒绝了自动播放", err);
214
203
  if (typeof err !== "object" || err === null) return (0, _video_lab_protocol.makePlayerError)("E_UNKNOWN", typeof err === "string" ? err : "未知播放错误", err);
215
204
  const xgErr = err;
@@ -217,9 +206,10 @@ function mapXgplayerError(err) {
217
206
  const status = extractHttpStatus(xgErr);
218
207
  if (status === 401) return (0, _video_lab_protocol.makePlayerError)("E_AUTH_EXPIRED", message, err);
219
208
  if (status === 403) return (0, _video_lab_protocol.makePlayerError)("E_AUTH_EXPIRED", message, err);
209
+ const mediaCode = xgErr.mediaError?.code;
210
+ if (context.live === false && mediaCode === 4) return (0, _video_lab_protocol.makePlayerError)("E_MEDIA_NOT_SUPPORTED", message, err);
220
211
  const byType = xgErr.errorType ? ERROR_TYPE_CODES[xgErr.errorType] : void 0;
221
212
  if (byType) return (0, _video_lab_protocol.makePlayerError)(byType, message, err);
222
- const mediaCode = xgErr.mediaError?.code;
223
213
  if (typeof mediaCode === "number") {
224
214
  const byMedia = MEDIA_ERROR_CODES[mediaCode];
225
215
  if (byMedia) return (0, _video_lab_protocol.makePlayerError)(byMedia, message, err);
@@ -256,27 +246,43 @@ function refineByDetails(type, details) {
256
246
  if (type === "otherError" && details.includes("Parsing")) return "E_MANIFEST_PARSE";
257
247
  }
258
248
  /**
259
- * hls.js 内核诊断 → 契约 `PlayerError`。
249
+ * 点播专有的 details 细化(ADR-107,ADR-061「按 type 映射」的例外)。
260
250
  *
261
- * ─── 为什么需要它(#402)─────────────────────────────
251
+ * hls.js 把子播放列表解析失败(`levelParsingError`)归在 `networkError` 下,按 type 会映射成
252
+ * 可重连的 `E_NETWORK`。点播的列表是静态文件,解析失败重试也还是同一份(LiveLab HLS-15 实测),
253
+ * 应为不可重试的 `E_MANIFEST_PARSE`。直播列表动态生成,偶发损坏可能下次就好,不细化。
254
+ * 只作用于 fatal 的 error 通道;`kernelhealth` 分类不受影响。
262
255
  *
263
- * `xgplayer-hls.js` 的包装层把 **NETWORK / MEDIA 两类 fatal 错误私吞了**:
264
- * 前者 `startLoad()` 重试、后者 `recoverMediaError()`,**两条都不 `emit('error')`**。
265
- * 而 `status === 404` 时连重试都没有 —— 那个 `if` 直接落空,什么都不做。
256
+ * ⚠️ **不含 `manifestParsingError`。** 主清单地址返回 HTML(地址错、CDN / WAF 拦截页、
257
+ * 强制门户登录页)时 hls.js 报的就是它,其中一部分重试可能成功;错误覆盖层 e2e 正是这个形态
258
+ * (开发服务器对不存在的路径回 200 HTML),要求保留重试按钮。
259
+ */
260
+ function refineVodByDetails(type, details, context) {
261
+ if (context.live !== false || details === void 0) return void 0;
262
+ if (type === "networkError" && details === "levelParsingError") return "E_MANIFEST_PARSE";
263
+ }
264
+ /**
265
+ * hls.js 内核诊断 → 契约 `PlayerError`。
266
266
  *
267
- * 实测(chromium):manifest 从头 404 → hls.js 报 `manifestLoadError` **fatal=true**,
268
- * 消费方拿到的契约 `error` **0 条**;分片全 abort 40s → 9 条 `hlsError` 含 1 条 fatal,
269
- * 契约 `error` 仍然 **0 条**。**连 fatal 都到不了消费方。**
267
+ * `OwnedHlsPlugin` 统一把 hls.js 诊断送入本映射:fatal 进入契约 `error`,非 fatal 返回
268
+ * `undefined` 并由调用方送入 `kernelhealth` 聚合。恢复动作由统一恢复调度决定,映射层不重试。
270
269
  *
271
270
  * @returns `undefined` 表示这条不该进 error 流(非 fatal)——见 spec § 非 fatal 不进 error 流
272
271
  */
273
- function mapHlsKernelError(payload) {
272
+ function mapHlsKernelError(payload, context = {}) {
274
273
  if (payload.errorFatal !== true) return void 0;
275
274
  const type = payload.errorType;
276
275
  if (typeof type !== "string") return void 0;
277
- const code = refineByDetails(type, payload.errorDetails) ?? HLS_TYPE_CODES[type] ?? "E_UNKNOWN";
278
276
  const details = payload.errorDetails ?? "未知";
279
- return (0, _video_lab_protocol.makePlayerError)(code, `内核错误:${type} / ${details}`, {
277
+ const message = `内核错误:${type} / ${details}`;
278
+ if (payload.errorFatal === true && (payload.httpStatus === 401 || payload.httpStatus === 403)) return (0, _video_lab_protocol.makePlayerError)("E_AUTH_EXPIRED", message, {
279
+ message: `${type} / ${details}`,
280
+ errorType: type,
281
+ errorDetails: payload.errorDetails,
282
+ errorFatal: true,
283
+ status: payload.httpStatus
284
+ });
285
+ return (0, _video_lab_protocol.makePlayerError)(refineVodByDetails(type, payload.errorDetails, context) ?? refineByDetails(type, payload.errorDetails) ?? HLS_TYPE_CODES[type] ?? "E_UNKNOWN", message, {
280
286
  message: `${type} / ${details}`,
281
287
  errorType: type,
282
288
  errorDetails: payload.errorDetails,
@@ -588,6 +594,27 @@ var HlsAdapter = class {
588
594
  };
589
595
  //#endregion
590
596
  //#region src/kernels/owned-hls-plugin.ts
597
+ /**
598
+ * hls.js 默认拒绝重试所有 4xx。运行中的 playlist / fragment 403 另有瞬时
599
+ * CDN/鉴权抖动这一种已测歧义,最多额外尝试三次;401 与其他 4xx 仍按内核默认。
600
+ *
601
+ * `retry` 是内核已经批准的超时、5xx 等重试结果,必须原样保留,避免收窄既有策略。
602
+ */
603
+ function shouldRetryTransientForbidden(_retryConfig, retryCount, isTimeout, response, retry) {
604
+ return retry || !isTimeout && response?.code === 403 && retryCount < 3;
605
+ }
606
+ /** 不修改 hls.js 全局默认值;每个播放器实例取得自己的浅拷贝。 */
607
+ function withTransientForbiddenRetry(policy) {
608
+ const errorRetry = policy.default.errorRetry;
609
+ if (!errorRetry) return policy;
610
+ return { default: {
611
+ ...policy.default,
612
+ errorRetry: {
613
+ ...errorRetry,
614
+ shouldRetry: shouldRetryTransientForbidden
615
+ }
616
+ } };
617
+ }
591
618
  /** 生产 HLS 内部装配层;恢复预算由统一调度器持有,不作为宿主扩展口导出。 */
592
619
  var OwnedHlsPlugin = class extends xgplayer.BasePlugin {
593
620
  static get pluginName() {
@@ -632,14 +659,21 @@ var OwnedHlsPlugin = class extends xgplayer.BasePlugin {
632
659
  if (!this.adapter) {
633
660
  const media = this.player.video;
634
661
  if (!(media instanceof HTMLMediaElement)) return;
635
- const hlsOpts = { ...this.config.hlsOpts };
662
+ const config = this.config;
663
+ const hlsOpts = {
664
+ ...config.hlsOpts,
665
+ playlistLoadPolicy: withTransientForbiddenRetry(config.hlsOpts?.playlistLoadPolicy ?? hls_js_light.default.DefaultConfig.playlistLoadPolicy),
666
+ fragLoadPolicy: withTransientForbiddenRetry(config.hlsOpts?.fragLoadPolicy ?? hls_js_light.default.DefaultConfig.fragLoadPolicy)
667
+ };
636
668
  if (hlsOpts.startPosition === void 0 && typeof this.playerConfig.startTime === "number") hlsOpts.startPosition = this.playerConfig.startTime;
637
669
  this.adapter = new HlsAdapter(media, (error) => {
638
670
  if (this.disposed) return;
671
+ const status = error.response?.code;
639
672
  this.player.emit("HLS_ERROR", {
640
673
  errorType: error.type,
641
674
  errorDetails: error.details,
642
- errorFatal: error.fatal
675
+ errorFatal: error.fatal,
676
+ ...typeof status === "number" && status >= 100 && status < 600 ? { httpStatus: status } : {}
643
677
  });
644
678
  }, hlsOpts);
645
679
  }
@@ -708,6 +742,55 @@ var OwnedHlsPlugin = class extends xgplayer.BasePlugin {
708
742
  }
709
743
  };
710
744
  //#endregion
745
+ //#region src/last-frame-placeholder.ts
746
+ function createLastFramePlaceholder(getMedia) {
747
+ let canvas = null;
748
+ const release = () => {
749
+ if (!canvas) return;
750
+ canvas.remove();
751
+ canvas.width = 0;
752
+ canvas.height = 0;
753
+ canvas = null;
754
+ };
755
+ const capture = () => {
756
+ if (canvas?.parentNode) return true;
757
+ release();
758
+ const media = getMedia();
759
+ if (!(media instanceof HTMLVideoElement) || !media.parentElement) return false;
760
+ const { videoWidth: width, videoHeight: height } = media;
761
+ if (media.readyState < 2 || width <= 0 || height <= 0) return false;
762
+ const next = media.ownerDocument.createElement("canvas");
763
+ next.width = width;
764
+ next.height = height;
765
+ try {
766
+ const context = next.getContext("2d");
767
+ if (!context) return false;
768
+ context.drawImage(media, 0, 0, width, height);
769
+ } catch {
770
+ return false;
771
+ }
772
+ next.dataset.sentinelLastFrame = "";
773
+ next.setAttribute("aria-hidden", "true");
774
+ const fit = media.ownerDocument.defaultView?.getComputedStyle(media).objectFit;
775
+ Object.assign(next.style, {
776
+ position: "absolute",
777
+ top: "0",
778
+ left: "0",
779
+ width: "100%",
780
+ height: "100%",
781
+ objectFit: fit && fit !== "fill" ? fit : "contain",
782
+ pointerEvents: "none"
783
+ });
784
+ media.insertAdjacentElement("afterend", next);
785
+ canvas = next;
786
+ return true;
787
+ };
788
+ return {
789
+ capture,
790
+ release
791
+ };
792
+ }
793
+ //#endregion
711
794
  //#region src/page-fullscreen.ts
712
795
  /**
713
796
  * 接管锁定的 xgplayer 3.0.26 网页全屏按钮与 Esc;宿主确认后才应用本地状态。
@@ -791,6 +874,11 @@ const BACKOFF = [
791
874
  4e3
792
875
  ];
793
876
  /**
877
+ * 复发观察期(ADR-105):判恢复成功后这么久之内再次需要恢复,视为同一故障复发,
878
+ * 新一轮沿用上一轮已用的动作次数与退避档位。持续故障因此在累计 3 次后收口为失败(#103)。
879
+ */
880
+ const RELAPSE_WINDOW_MS = 15e3;
881
+ /**
794
882
  * 实例级内部调度器。执行器须在 abort 时同步停止旧动作;Promise 完成仅代表命令结束。
795
883
  * 强播放证据由调用方判定后交 recovered;生产装配层负责映射公开事件。
796
884
  */
@@ -798,6 +886,11 @@ var RecoveryController = class {
798
886
  execute;
799
887
  onTransition;
800
888
  active = null;
889
+ /**
890
+ * 最近一次判恢复成功的会话、时刻与已用次数。保留到下一次判成功(覆盖)、失败、
891
+ * 非自然恢复的取消、手动重试或换会话为止;被自然恢复取消的一轮不打断计数链。
892
+ */
893
+ lastRecovered = null;
801
894
  sequence = 0;
802
895
  disposed = false;
803
896
  suspended = false;
@@ -806,10 +899,13 @@ var RecoveryController = class {
806
899
  events = [];
807
900
  publishing = false;
808
901
  actionDepth = 0;
809
- constructor(execute, onTransition) {
902
+ constructor(execute, onTransition, options = {}) {
810
903
  this.execute = execute;
811
904
  this.onTransition = onTransition;
905
+ this.resolveStrategy = options.resolveStrategy ?? ((strategy) => strategy);
812
906
  }
907
+ /** 见 {@link StrategyResolver};未提供时按请求时的策略原样上报。 */
908
+ resolveStrategy;
813
909
  /** 供装配层识别同步重入后的新 episode,不是公开播放器查询。 */
814
910
  get activeEpisodeId() {
815
911
  return this.active?.id ?? null;
@@ -824,15 +920,17 @@ var RecoveryController = class {
824
920
  }
825
921
  return this.active.id;
826
922
  }
923
+ const trigger = input.trigger ?? "error";
827
924
  const episode = {
828
925
  id: ++this.sequence,
829
926
  sessionId,
830
927
  deadline: performance.now() + 6e4,
831
928
  startedAt: performance.now(),
832
929
  strategy: input.strategy ?? "reconnect",
833
- trigger: input.trigger ?? "error",
930
+ trigger,
834
931
  reason: input.reason,
835
- attempt: 0,
932
+ attempt: this.inheritedAttempts(sessionId, trigger),
933
+ issued: 0,
836
934
  token: null,
837
935
  abort: null,
838
936
  finishing: false
@@ -849,9 +947,18 @@ var RecoveryController = class {
849
947
  if (!episode || episode.token !== token) return;
850
948
  this.finish(episode, performance.now() >= episode.deadline ? "failed" : "recovered");
851
949
  }
852
- /** 第一动作尚未发出时原播放自行恢复,不制造一次成功动作。 */
950
+ /** 是否有已发出、尚未得到证据的动作;退避等待不算。 */
951
+ get attemptInFlight() {
952
+ return Boolean(this.active?.token && !this.active.finishing);
953
+ }
954
+ /** 在途动作已被确定性证据证伪时立即结束它;退避中没有动作可结束,不消耗预算。 */
955
+ failActiveAttempt() {
956
+ const episode = this.active;
957
+ if (episode?.token && !episode.finishing) this.failAttempt(episode, episode.token);
958
+ }
959
+ /** 本轮第一动作尚未发出时原播放自行恢复,不制造一次成功动作。 */
853
960
  naturalRecovered() {
854
- if (this.active?.attempt === 0) this.finish(this.active, "cancelled", "natural_recovery");
961
+ if (this.active?.issued === 0) this.finish(this.active, "cancelled", "natural_recovery");
855
962
  }
856
963
  /** 换源、用户暂停等取消原因由上层记录;取消不等同于预算耗尽。 */
857
964
  cancel(outcome = "superseded") {
@@ -877,6 +984,19 @@ var RecoveryController = class {
877
984
  this.disposed = true;
878
985
  this.cancel("destroyed");
879
986
  }
987
+ /**
988
+ * 复发继承的已用次数。手动重试总从头开始;换源(会话变化)不继承;
989
+ * 只有「判恢复成功」之后的观察期内才继承。
990
+ */
991
+ inheritedAttempts(sessionId, trigger) {
992
+ const last = this.lastRecovered;
993
+ if (!last) return 0;
994
+ if (trigger === "manual" || last.sessionId !== sessionId) {
995
+ this.lastRecovered = null;
996
+ return 0;
997
+ }
998
+ return performance.now() - last.at < RELAPSE_WINDOW_MS ? last.attempt : 0;
999
+ }
880
1000
  schedule(episode) {
881
1001
  if (this.active !== episode) return;
882
1002
  const delay = BACKOFF[episode.attempt];
@@ -896,12 +1016,14 @@ var RecoveryController = class {
896
1016
  this.finish(episode, "failed");
897
1017
  return;
898
1018
  }
1019
+ const attempt = ++episode.attempt;
899
1020
  const token = Object.freeze({
900
1021
  episode: episode.id,
901
1022
  sessionId: episode.sessionId,
902
- attempt: ++episode.attempt,
903
- strategy: episode.strategy
1023
+ attempt,
1024
+ strategy: this.resolveStrategy(episode.strategy, attempt)
904
1025
  });
1026
+ episode.issued += 1;
905
1027
  const abort = new AbortController();
906
1028
  episode.issuedStrategy = token.strategy;
907
1029
  episode.token = token;
@@ -940,6 +1062,12 @@ var RecoveryController = class {
940
1062
  episode.token = null;
941
1063
  if (phase !== "recovered") episode.abort?.abort();
942
1064
  this.active = null;
1065
+ if (phase === "recovered") this.lastRecovered = {
1066
+ sessionId: episode.sessionId,
1067
+ at: performance.now(),
1068
+ attempt: episode.attempt
1069
+ };
1070
+ else if (outcome !== "natural_recovery") this.lastRecovered = null;
943
1071
  this.publish(episode, phase, outcome ?? (phase === "failed" ? performance.now() >= episode.deadline ? "timeout" : "attempts_exhausted" : void 0));
944
1072
  }
945
1073
  publish(episode, phase, outcome) {
@@ -950,7 +1078,7 @@ var RecoveryController = class {
950
1078
  phase,
951
1079
  elapsedMs: Math.max(0, performance.now() - episode.startedAt),
952
1080
  maxAttempts: BACKOFF.length,
953
- strategy: phase === "detected" || phase === "backoff" ? episode.strategy : episode.issuedStrategy ?? episode.strategy,
1081
+ strategy: phase === "detected" || phase === "backoff" ? this.resolveStrategy(episode.strategy, episode.attempt + 1) : episode.issuedStrategy ?? episode.strategy,
954
1082
  trigger: episode.trigger,
955
1083
  ...episode.reason ? { reason: episode.reason } : {},
956
1084
  ...outcome ? { outcome } : {}
@@ -979,17 +1107,25 @@ var PlaybackRecovery = class {
979
1107
  sessionId;
980
1108
  emit;
981
1109
  display;
1110
+ options;
982
1111
  controller;
983
1112
  error = null;
1113
+ /** 最近一次非致命内核诊断的分类;只用于给无原因的失败补 `reason`(#105) */
1114
+ diagnostic = null;
984
1115
  desiredPlayback = false;
985
1116
  exhausted = false;
986
1117
  disposed = false;
987
1118
  revision = 0;
988
- constructor(sessionId, execute, emit, display) {
1119
+ constructor(sessionId, execute, emit, display, options = {}) {
989
1120
  this.sessionId = sessionId;
990
1121
  this.emit = emit;
991
1122
  this.display = display;
992
- this.controller = new RecoveryController(execute, (event) => this.transition(event));
1123
+ this.options = options;
1124
+ this.controller = new RecoveryController(execute, (event) => this.transition(event), { ...options.resolveStrategy ? { resolveStrategy: options.resolveStrategy } : {} });
1125
+ }
1126
+ /** 是否有已发出、尚未得到证据的恢复动作;退避等待不算。 */
1127
+ get attemptInFlight() {
1128
+ return !this.disposed && this.controller.attemptInFlight;
993
1129
  }
994
1130
  /** 执行器完成异步换源后复查最新意图,避免恢复用户已暂停的播放。 */
995
1131
  get playingIntent() {
@@ -1008,8 +1144,10 @@ var PlaybackRecovery = class {
1008
1144
  failed: true,
1009
1145
  action: this.action
1010
1146
  });
1011
- } else if (this.desiredPlayback && !this.exhausted) this.request("error");
1012
- else this.display({
1147
+ } else if (this.desiredPlayback && !this.exhausted) {
1148
+ this.request("error");
1149
+ if (this.options.failsAttempt?.(error)) this.controller.failActiveAttempt();
1150
+ } else this.display({
1013
1151
  recovering: false,
1014
1152
  failed: true,
1015
1153
  action: this.action
@@ -1025,20 +1163,30 @@ var PlaybackRecovery = class {
1025
1163
  this.controller.cancel("user_paused");
1026
1164
  } else if (this.error?.retryable && !this.exhausted) this.request("manual");
1027
1165
  }
1028
- /** 正常播放结束后不再接受自动恢复意图;不伪报用户暂停或恢复成功。 */
1166
+ /**
1167
+ * 播放到达结尾。正常结束后不再接受自动恢复意图;不伪报用户暂停或恢复成功。
1168
+ * 动作在途时到达结尾说明该动作没能恢复播放,按动作失败处理;预算耗尽后的结尾
1169
+ * 也不抹掉失败终态(#97)。返回 false 表示这不是一次正常结束。
1170
+ */
1029
1171
  endPlayback() {
1030
- if (this.disposed) return;
1172
+ if (this.disposed) return false;
1173
+ if (this.controller.attemptInFlight) {
1174
+ this.controller.failActiveAttempt();
1175
+ return false;
1176
+ }
1177
+ if (this.exhausted) return false;
1031
1178
  const revision = ++this.revision;
1032
1179
  this.desiredPlayback = false;
1033
1180
  this.exhausted = false;
1034
1181
  this.error = null;
1035
1182
  this.controller.cancel("superseded");
1036
- if (this.disposed || revision !== this.revision) return;
1183
+ if (this.disposed || revision !== this.revision) return true;
1037
1184
  this.display({
1038
1185
  recovering: false,
1039
1186
  failed: false,
1040
1187
  action: "none"
1041
1188
  });
1189
+ return true;
1042
1190
  }
1043
1191
  /** 接受/合并手动重试;播放成功仍以当前动作的强证据为准。 */
1044
1192
  async retry() {
@@ -1074,6 +1222,19 @@ var PlaybackRecovery = class {
1074
1222
  action: "none"
1075
1223
  });
1076
1224
  }
1225
+ /**
1226
+ * 记录非致命内核诊断(`kernelhealth` degraded)。不触发恢复,只在恢复失败而没有致命错误时
1227
+ * 作为原因来源(#105);`other` 分类无法对应错误码,不记录。
1228
+ *
1229
+ * `auth` 是内核诊断里带 401/403 时由装配层单独给出的更具体分类(#114):密钥或分片被拒
1230
+ * 之后内核会继续重试并报网络类诊断,所以它在本轮内粘住,不能被后到的网络/媒体诊断冲掉,
1231
+ * 否则「要重新取签名 URL」又会退化成「网络不好,再试试」。换源与恢复成功照旧清空。
1232
+ */
1233
+ noteKernelHealth(reason) {
1234
+ if (this.disposed || reason === "other") return;
1235
+ if (this.diagnostic === "auth" && reason !== "auth") return;
1236
+ this.diagnostic = reason;
1237
+ }
1077
1238
  /** 页面隐藏只暂停动作,不暂停总预算。 */
1078
1239
  setSuspended(hidden) {
1079
1240
  this.controller.setSuspended(hidden);
@@ -1084,6 +1245,7 @@ var PlaybackRecovery = class {
1084
1245
  this.revision += 1;
1085
1246
  this.sessionId = sessionId;
1086
1247
  this.error = null;
1248
+ this.diagnostic = null;
1087
1249
  this.exhausted = false;
1088
1250
  this.controller.cancel("source_changed");
1089
1251
  }
@@ -1093,6 +1255,16 @@ var PlaybackRecovery = class {
1093
1255
  this.disposed = true;
1094
1256
  this.controller.destroy();
1095
1257
  }
1258
+ /**
1259
+ * 恢复失败而本轮没有携带原因时的补填(#105)。致命错误发起或并入恢复时已带上错误码,
1260
+ * 走不到这里;这里只处理非致命诊断分类,都没有(纯起播超时 / 纯卡顿)时为 `E_NETWORK_TIMEOUT`。
1261
+ */
1262
+ get failureReason() {
1263
+ if (this.diagnostic === "auth") return "E_AUTH_EXPIRED";
1264
+ if (this.diagnostic === "network") return "E_NETWORK";
1265
+ if (this.diagnostic === "media") return "E_MEDIA_DECODE";
1266
+ return "E_NETWORK_TIMEOUT";
1267
+ }
1096
1268
  get action() {
1097
1269
  if (!this.error || this.error.retryable) return "retry";
1098
1270
  if (this.error.category === "autoplay") return "play";
@@ -1111,6 +1283,7 @@ var PlaybackRecovery = class {
1111
1283
  if (event.sessionId === this.sessionId && event.phase === "failed") this.exhausted = true;
1112
1284
  if (event.sessionId === this.sessionId && (event.phase === "recovered" || event.outcome === "natural_recovery")) {
1113
1285
  this.error = null;
1286
+ this.diagnostic = null;
1114
1287
  this.exhausted = false;
1115
1288
  }
1116
1289
  this.emit({
@@ -1118,7 +1291,8 @@ var PlaybackRecovery = class {
1118
1291
  payload: _video_lab_protocol.RecoveryPayloadSchema.parse({
1119
1292
  ...fact,
1120
1293
  recoveryId: episode,
1121
- ...event.phase === "recovered" ? { validatedBy: "playing_position_advance" } : {}
1294
+ ...event.phase === "recovered" ? { validatedBy: "playing_position_advance" } : {},
1295
+ ...event.phase === "failed" && !fact.reason ? { reason: this.failureReason } : {}
1122
1296
  })
1123
1297
  });
1124
1298
  if (this.disposed || event.sessionId !== this.sessionId || revision !== this.revision) return;
@@ -1255,7 +1429,8 @@ const DEFAULT_CONFIG$5 = {
1255
1429
  * FullscreenGuardPlugin(P2)· iOS 微信原生全屏崩溃防护
1256
1430
  *
1257
1431
  * 解决的 pitfall:**#7**(iOS 26 微信里触发 `<video>` 原生全屏 `webkitEnterFullscreen()`
1258
- * 直接崩溃 / 白屏)。ARCHITECTURE § 10.1 的处置是「禁用原生全屏 + CSS 兜底」。
1432
+ * 直接崩溃 / 白屏)。处置是「禁用原生全屏 + CSS 兜底」;坑点事实源见
1433
+ * `.claude/context/xgplayer-pitfalls.md`。
1259
1434
  *
1260
1435
  * 做法:仅在 iOS 微信环境,把媒体元素上的 `webkitEnterFullscreen` 换成 no-op,**阻断**
1261
1436
  * 会崩的原生全屏路径。xgplayer / 团队层的 CSS 伪全屏(操作容器,不走 `webkitEnterFullscreen`)
@@ -1305,7 +1480,9 @@ var FullscreenGuardPlugin = class extends xgplayer.BasePlugin {
1305
1480
  const DEFAULT_CONFIG$4 = {
1306
1481
  enabled: true,
1307
1482
  stallThresholdMs: 2e3,
1308
- pollIntervalMs: 1e3
1483
+ pollIntervalMs: 1e3,
1484
+ waitingStallThresholdMs: 1e3,
1485
+ sustainedStallMs: 3e3
1309
1486
  };
1310
1487
  /**
1311
1488
  * 连续几次采样余量下降就判定「在净流失」(ADR-071)。
@@ -1330,13 +1507,16 @@ const DRAIN_STREAK = 3;
1330
1507
  *
1331
1508
  * 解决的 pitfall:**#13**(卡顿检测无信号上报 / 冻帧型隐性卡死)。见 ADR-026。
1332
1509
  *
1333
- * 这是 11 个插件里唯一的**观察者**——不修任何东西,只**测量**播放质量并通过契约事件
1334
- * `stalled` 上报(Phase-1 灰度卡顿率 / 首帧 KPI 的数据源)。
1510
+ * 它负责**测量**播放质量并通过契约事件 `stalled` 上报(Phase-1 灰度卡顿率 / 首帧 KPI 的
1511
+ * 数据源);持续卡顿达到阈值时只提交恢复意图,实际动作仍由统一恢复调度执行。
1335
1512
  *
1336
1513
  * 两条测量路径喂同一个状态机(`stalling`),避免重复计数:
1337
1514
  * - **显性卡顿**:`waiting` → 进入卡顿(记开始时刻 + 位置);`playing` → 结束(算 `durationMs`)。
1338
1515
  * - **隐性卡顿(冻帧)**:定时轮询 `currentTime`,播放中却连续 `stallThresholdMs` 不推进 →
1339
1516
  * 进入卡顿;之后推进了 → 结束。这类卡死 xgplayer 不发 `waiting`,只能主动抓。
1517
+ * - **视频帧冻结(#102 · ADR-104)**:`currentTime` 在走、已解码视频帧却连续 `stallThresholdMs`
1518
+ * 不涨 → 进入 playback 卡顿;帧恢复增长才结束。视频解码停了而音频照常解码时,
1519
+ * `<video>` 按音频时钟推进 `currentTime`,浏览器不发 `waiting` —— 只看时钟的两条路径都看不见。
1340
1520
  *
1341
1521
  * **不计后台卡顿**:`document.hidden` 时不进入卡顿——切后台 `currentTime` 本就停,那不是
1342
1522
  * 质量问题(iOS 后台切回归 VisibilityPlugin)。
@@ -1369,6 +1549,14 @@ var HealthMonitorPlugin = class extends xgplayer.BasePlugin {
1369
1549
  /** 轮询用:上次看到的 currentTime + 它上次推进的时刻 */
1370
1550
  lastTime = 0;
1371
1551
  lastAdvanceAt = 0;
1552
+ /** 帧冻结判据用:上次读到的已解码帧数(`null` = 没有基准)+ 它上次增长的时刻 */
1553
+ lastFrames = null;
1554
+ lastFrameAdvanceAt = 0;
1555
+ /**
1556
+ * 本次加载里**亲眼见过**帧数增长吗。没见过就不启用帧判据 ——
1557
+ * 纯音频流、不支持 `getVideoPlaybackQuality` 的浏览器,帧数恒为 0,那不是冻结。
1558
+ */
1559
+ sawFrameAdvance = false;
1372
1560
  /** 上一次采到的余量;`null` 表示还没有基准(起播 / 刚复位) */
1373
1561
  lastMargin = null;
1374
1562
  /** 已经连续下降了几次 */
@@ -1382,6 +1570,10 @@ var HealthMonitorPlugin = class extends xgplayer.BasePlugin {
1382
1570
  */
1383
1571
  sawBufferAdvance = false;
1384
1572
  pollTimer = null;
1573
+ /** `waiting` 到达、尚未满显性阈值的在途计时器(ADR-103) */
1574
+ waitingTimer = null;
1575
+ /** 当前卡顿持续满 `sustainedStallMs` 的计时器;只为 playback 卡顿设置 */
1576
+ sustainedTimer = null;
1385
1577
  get monitorConfig() {
1386
1578
  return {
1387
1579
  ...DEFAULT_CONFIG$4,
@@ -1397,14 +1589,40 @@ var HealthMonitorPlugin = class extends xgplayer.BasePlugin {
1397
1589
  this.on(xgplayer.Events.PLAYING, this.handlePlaying);
1398
1590
  this.on(xgplayer.Events.SEEKING, this.handleSeeking);
1399
1591
  this.on(xgplayer.Events.SEEKED, this.handleSeeked);
1592
+ this.on(xgplayer.Events.LOAD_START, this.handleLoadStart);
1400
1593
  this.lastAdvanceAt = now();
1401
1594
  this.pollTimer = setInterval(this.poll, this.monitorConfig.pollIntervalMs);
1402
1595
  }
1403
1596
  /** 用箭头函数保持 this,否则 off / clearInterval 匹配不上(见 CLAUDE.md 红线) */
1404
1597
  handleWaiting = () => {
1405
- this.enterStall();
1598
+ if (this.stalling || this.waitingTimer !== null || isHidden()) return;
1599
+ const kind = this.classifyStall();
1600
+ const onsetAt = now();
1601
+ const onsetPosition = numberOr$2(this.surface.currentTime, 0);
1602
+ this.waitingTimer = setTimeout(() => {
1603
+ this.waitingTimer = null;
1604
+ if (numberOr$2(this.surface.currentTime, onsetPosition) > onsetPosition) return;
1605
+ this.enterStall(onsetAt, kind);
1606
+ }, this.monitorConfig.waitingStallThresholdMs);
1607
+ };
1608
+ /** 媒体开始新的加载(换源 / 重拉):回到「未见首帧」,重拉后的起播等待不再记成 playback(ADR-103) */
1609
+ handleLoadStart = () => {
1610
+ this.sawFirstFrame = false;
1611
+ this.lastTime = 0;
1612
+ this.lastAdvanceAt = now();
1613
+ this.resetFrameBase();
1406
1614
  };
1615
+ resetFrameBase() {
1616
+ this.lastFrames = null;
1617
+ this.sawFrameAdvance = false;
1618
+ this.lastFrameAdvanceAt = now();
1619
+ }
1620
+ clearWaiting() {
1621
+ if (this.waitingTimer !== null) clearTimeout(this.waitingTimer);
1622
+ this.waitingTimer = null;
1623
+ }
1407
1624
  handlePlaying = () => {
1625
+ this.clearWaiting();
1408
1626
  this.exitStall();
1409
1627
  this.sawFirstFrame = true;
1410
1628
  };
@@ -1431,19 +1649,61 @@ var HealthMonitorPlugin = class extends xgplayer.BasePlugin {
1431
1649
  if (this.surface.paused === true || isHidden()) {
1432
1650
  this.lastTime = t;
1433
1651
  this.lastAdvanceAt = now();
1652
+ this.lastFrameAdvanceAt = now();
1653
+ this.lastFrames = this.readFrames();
1434
1654
  this.resetDrain();
1435
1655
  return;
1436
1656
  }
1437
1657
  this.sampleBufferHealth();
1658
+ const framesFrozen = this.sampleFrames();
1438
1659
  if (t > this.lastTime) {
1439
1660
  this.lastTime = t;
1440
1661
  this.lastAdvanceAt = now();
1662
+ if (framesFrozen) {
1663
+ this.clearWaiting();
1664
+ this.enterStall(this.lastFrameAdvanceAt, this.classifyStall());
1665
+ return;
1666
+ }
1441
1667
  this.exitStall();
1442
1668
  return;
1443
1669
  }
1444
- if (!this.stalling && now() - this.lastAdvanceAt >= this.monitorConfig.stallThresholdMs) this.enterStall();
1670
+ if (!this.stalling && now() - this.lastAdvanceAt >= this.monitorConfig.stallThresholdMs) {
1671
+ this.clearWaiting();
1672
+ this.enterStall(this.lastAdvanceAt, this.classifyStall());
1673
+ }
1445
1674
  };
1446
1675
  /**
1676
+ * 采一次已解码视频帧数,返回「帧是否已冻结满 `stallThresholdMs`」(#102)。
1677
+ *
1678
+ * 只在见过帧数增长之后才可能返回 true;计数回退(换源重建)时重置基准。
1679
+ * seek 中解码会短暂停下,不按冻结算。
1680
+ */
1681
+ sampleFrames() {
1682
+ const frames = this.readFrames();
1683
+ if (frames === null) return false;
1684
+ const prev = this.lastFrames;
1685
+ this.lastFrames = frames;
1686
+ if (prev !== null && frames < prev) {
1687
+ this.sawFrameAdvance = false;
1688
+ this.lastFrameAdvanceAt = now();
1689
+ return false;
1690
+ }
1691
+ if (prev !== null && frames > prev) {
1692
+ this.sawFrameAdvance = true;
1693
+ this.lastFrameAdvanceAt = now();
1694
+ return false;
1695
+ }
1696
+ if (this.seeking) {
1697
+ this.lastFrameAdvanceAt = now();
1698
+ return false;
1699
+ }
1700
+ return this.sawFrameAdvance && now() - this.lastFrameAdvanceAt >= this.monitorConfig.stallThresholdMs;
1701
+ }
1702
+ readFrames() {
1703
+ const total = numberOr$2(this.surface.videoFrameInfo?.total, NaN);
1704
+ return Number.isFinite(total) ? total : null;
1705
+ }
1706
+ /**
1447
1707
  * 采一次缓冲余量,判断是不是在净流失(ADR-071)。
1448
1708
  *
1449
1709
  * **判据是「连续下降」,不是「低于某个秒数」。** 实测健康播放的余量是
@@ -1525,20 +1785,44 @@ var HealthMonitorPlugin = class extends xgplayer.BasePlugin {
1525
1785
  const end = buffered.end(buffered.length - 1);
1526
1786
  return Number.isFinite(end) ? end : null;
1527
1787
  }
1528
- enterStall() {
1788
+ /**
1789
+ * @param onsetAt 这次卡顿**开始**的时刻(开始等待 / 最后一次推进),`durationMs` 与持续阈值都从它算
1790
+ * @param kind 开始时定下的分类
1791
+ */
1792
+ enterStall(onsetAt, kind) {
1529
1793
  if (this.stalling) return;
1530
1794
  if (isHidden()) return;
1531
1795
  this.stalling = true;
1532
- this.stallStartAt = now();
1533
- this.stallKind = this.classifyStall();
1796
+ this.stallStartAt = onsetAt;
1797
+ this.stallKind = kind;
1534
1798
  const position = numberOr$2(this.surface.currentTime, 0);
1535
1799
  this.monitorConfig.onStall?.({
1536
1800
  phase: "start",
1537
1801
  position,
1538
- kind: this.stallKind
1802
+ kind
1539
1803
  });
1804
+ if (kind !== "playback") return;
1805
+ const remaining = Math.max(0, this.monitorConfig.sustainedStallMs - (now() - onsetAt));
1806
+ this.armSustained(kind, remaining);
1807
+ }
1808
+ /**
1809
+ * 满阈值请求恢复,卡顿仍在则每隔 `sustainedStallMs` 重申。只请求一次会丢请求:
1810
+ * 卡顿恰在上一轮恢复判成功前一刻开始时,请求被并入那一轮后随之结束,画面冻住却再无恢复(ADR-105 ④)。
1811
+ */
1812
+ armSustained(kind, delay) {
1813
+ this.sustainedTimer = setTimeout(() => {
1814
+ this.sustainedTimer = null;
1815
+ if (!this.stalling) return;
1816
+ this.monitorConfig.onSustainedStall?.({
1817
+ kind,
1818
+ position: numberOr$2(this.surface.currentTime, 0)
1819
+ });
1820
+ this.armSustained(kind, this.monitorConfig.sustainedStallMs);
1821
+ }, delay);
1540
1822
  }
1541
1823
  exitStall() {
1824
+ if (this.sustainedTimer !== null) clearTimeout(this.sustainedTimer);
1825
+ this.sustainedTimer = null;
1542
1826
  if (!this.stalling) return;
1543
1827
  this.stalling = false;
1544
1828
  const position = numberOr$2(this.surface.currentTime, 0);
@@ -1554,6 +1838,10 @@ var HealthMonitorPlugin = class extends xgplayer.BasePlugin {
1554
1838
  clearInterval(this.pollTimer);
1555
1839
  this.pollTimer = null;
1556
1840
  }
1841
+ this.clearWaiting();
1842
+ if (this.sustainedTimer !== null) clearTimeout(this.sustainedTimer);
1843
+ this.sustainedTimer = null;
1844
+ this.off(xgplayer.Events.LOAD_START, this.handleLoadStart);
1557
1845
  this.off(xgplayer.Events.WAITING, this.handleWaiting);
1558
1846
  this.off(xgplayer.Events.PLAYING, this.handlePlaying);
1559
1847
  this.off(xgplayer.Events.SEEKING, this.handleSeeking);
@@ -1576,8 +1864,7 @@ function isHidden() {
1576
1864
  /**
1577
1865
  * MediaSessionPlugin · 把契约的 `source.metadata` 喂给 W3C Media Session API
1578
1866
  *
1579
- * 它**不解决任何 pitfall**,所以不在 11 个稳定性插件那张表里 —— 归类上与
1580
- * `PlayableStatePlugin` 同档(见 CLAUDE.md「第 12 / 13 个插件」)。
1867
+ * 它**不解决任何 pitfall**,而是与 `PlayableStatePlugin` 同属生产 preset 的平台接线层。
1581
1868
  *
1582
1869
  * ## 为什么设置点必须在这一层
1583
1870
  *
@@ -1681,7 +1968,7 @@ var MediaSessionPlugin = class extends xgplayer.BasePlugin {
1681
1968
  //#region src/plugins/playable-state.ts
1682
1969
  const DEFAULT_CONFIG$3 = {
1683
1970
  enabled: true,
1684
- bufferingDebounceMs: 300,
1971
+ bufferingDebounceMs: 1e3,
1685
1972
  droppedRateThreshold: 15,
1686
1973
  degradedSamples: 3,
1687
1974
  pollIntervalMs: 1e3,
@@ -1721,6 +2008,10 @@ const REASON_TABLE = {
1721
2008
  playable: false,
1722
2009
  recoverable: true
1723
2010
  },
2011
+ source_switching: {
2012
+ playable: false,
2013
+ recoverable: true
2014
+ },
1724
2015
  autoplay_blocked: {
1725
2016
  playable: false,
1726
2017
  recoverable: false
@@ -1744,6 +2035,7 @@ const PRIORITY = [
1744
2035
  "error",
1745
2036
  "frame_disconnected",
1746
2037
  "autoplay_blocked",
2038
+ "source_switching",
1747
2039
  "reconnecting",
1748
2040
  "stalled",
1749
2041
  "buffering",
@@ -1752,7 +2044,7 @@ const PRIORITY = [
1752
2044
  "ok"
1753
2045
  ];
1754
2046
  /**
1755
- * PlayableStatePlugin(第 12 个稳定性插件)· 聚合播放状态
2047
+ * PlayableStatePlugin · 聚合播放状态
1756
2048
  *
1757
2049
  * 见 ADR-043 / issue #121。**它不产生新信息,只产生唯一结论。**
1758
2050
  *
@@ -1765,7 +2057,7 @@ const PRIORITY = [
1765
2057
  * **信号从两处来,分工是固定的**:
1766
2058
  * - **原生信号**(本插件自己 `this.on`):`loadeddata` / `play` / `waiting` / `playing`
1767
2059
  * - **兄弟插件的信号**(create-player 调本插件的 `setXxx`):卡顿(HealthMonitor)、
1768
- * 重连(Reconnect)、自动播放被拒(AutoplayGuard)、错误(create-player 的 error 映射)
2060
+ * 恢复调度、自动播放被拒(AutoplayGuard)、错误(create-player 的 error 映射)
1769
2061
  *
1770
2062
  * 之所以不让本插件直接监听那几个 —— 它们的判定逻辑在各自插件里(比如冻帧要轮询
1771
2063
  * `currentTime` 才测得出来),重听一遍就是重实现一遍,两份实现必然漂移。
@@ -1823,7 +2115,7 @@ var PlayableStatePlugin = class extends xgplayer.BasePlugin {
1823
2115
  this.stalling = value;
1824
2116
  this.publish();
1825
2117
  }
1826
- /** ReconnectPlugin 的重连状态机。重连**失败**归 `setError`,不是这里 */
2118
+ /** 统一恢复调度的重连状态。恢复**失败**归 `setRecovery`,不是这里 */
1827
2119
  setReconnecting(value) {
1828
2120
  if (this.reconnecting === value) return;
1829
2121
  this.reconnecting = value;
@@ -1913,6 +2205,7 @@ var PlayableStatePlugin = class extends xgplayer.BasePlugin {
1913
2205
  error: this.errored,
1914
2206
  frame_disconnected: false,
1915
2207
  autoplay_blocked: this.autoplayBlocked,
2208
+ source_switching: false,
1916
2209
  reconnecting: this.reconnecting,
1917
2210
  stalled: this.stalling,
1918
2211
  buffering: this.buffering,
@@ -1965,7 +2258,7 @@ const DEFAULT_CONFIG$2 = { restoreRootStyle: true };
1965
2258
  * 解决的 pitfall:**#3**(destroy 后事件监听器残留)、**#4**(destroy 后 timer / root 样式残留)、
1966
2259
  * **#23**(反复 destroy 崩溃)。
1967
2260
  *
1968
- * **没有 `enabled` 开关**——ARCHITECTURE § 10.2 明确它"强制,不可关"。
2261
+ * **没有 `enabled` 开关**——内存泄漏与重复销毁防护是强制能力,不提供关闭入口。
1969
2262
  * 一个能被关掉的内存泄漏防护没有意义。
1970
2263
  *
1971
2264
  * 注意 xgplayer 的 `BasePlugin.__destroy()` 本身已经会做 `offAll()` + `clearAllTimers(this)`,
@@ -2045,13 +2338,13 @@ function pickKernel(type, env) {
2045
2338
  * 最早也只到 `beforeCreate`,拿不到这个时机。所以它是一个纯函数,
2046
2339
  * 由 create-player 在构造 player 前调用。纯函数也更好测:UA 直接传进来就行。
2047
2340
  *
2048
- * 选择规则(ARCHITECTURE § 8.2):
2341
+ * 选择规则(见 ADR-056 与 MEDIA-SOURCE-GUIDE):
2049
2342
  * 1. iOS / 微信 / QQ / UC / 夸克 → **强制 HLS**;没 HLS 退 MP4;只剩 FLV 抛 `E_MEDIA_NOT_SUPPORTED`
2050
2343
  * 2. 其他平台:直播 `flv → hls → mp4`(FLV 延迟低);点播 `hls → mp4 → flv`(HLS 功能全)
2051
2344
  *
2052
2345
  * **降级只发生在选源这一刻,没有运行时兜底。** 上面的「→」是**候选缺失**时往下取
2053
2346
  * (没有 FLV 就用 HLS),**不是播放失败后换一个再试** —— 选完之后 `candidates` 里
2054
- * 剩下的项没有任何代码会再读:ReconnectPlugin 重连 reload 的是同一个 URL(#192),
2347
+ * 剩下的项没有任何代码会再读:统一恢复仍重拉同一个 URL(#192),
2055
2348
  * 而 `load()` 跨内核会直接抛 `E_METHOD_NOT_SUPPORTED`。想要真兜底就得销毁重建 player,
2056
2349
  * 那是一条要走 ADR 的独立能力(#217)。
2057
2350
  *
@@ -2085,7 +2378,7 @@ function routeSource(source, env) {
2085
2378
  reason: "无 HLS 候选,回退到原生 MP4",
2086
2379
  routeReason: "native_mp4_fallback"
2087
2380
  };
2088
- throwPlayerError("E_MEDIA_NOT_SUPPORTED", "FLV 在 iOS / 微信 / UC / 夸克 下无法播放,且候选源里没有 HLS 兜底。FLV 请始终和 HLS 一起放进 sources 数组。");
2381
+ throwPlayerError("E_MEDIA_NOT_SUPPORTED", "当前 SDK 不支持在 iOS / 微信 / UC / 夸克中播放 FLV,且候选源里没有 HLS 兜底。FLV 请始终和 HLS 一起放进 sources 数组。");
2089
2382
  }
2090
2383
  const order = source.live ? [
2091
2384
  "flv",
@@ -2155,7 +2448,7 @@ const DEFAULT_CONFIG$1 = {
2155
2448
  * iOS 把播放页切到后台再切回时,系统可能已回收底层 MSE / 解码管线:画面冻结在最后一帧、
2156
2449
  * `currentTime` 不再推进、声音丢失。xgplayer 自己不会恢复,消费方看到的是"卡死"。
2157
2450
  *
2158
- * 策略(ARCHITECTURE § 10.3「信号 + 外层重建」的插件内自愈版):
2451
+ * 策略(见 ADR-079、ADR-098/099 的统一恢复边界):
2159
2452
  * - 切**后台**(`document.hidden`)时,若正在播放,记下 `wasPlaying` + 当时的 `currentTime`。
2160
2453
  * - 切**回前台**时,只处理**直播**(点播 iOS 原生能续播,reload 会丢进度 → 不动)。
2161
2454
  * 等 `probeDelayMs` 给系统一个自恢复窗口,再探测:`currentTime` 没推进、或 paused → 判定卡死
@@ -2163,8 +2456,9 @@ const DEFAULT_CONFIG$1 = {
2163
2456
  *
2164
2457
  * 自愈的触发、尝试、验证和结果由 create-player 统一出为 `recovery` 事件;健康时不产生事件。
2165
2458
  *
2166
- * 插件不越界 `destroy` player(§ 10.3 红线),只重新拉流(`reloadStream`)——与 ReconnectPlugin
2167
- * **共用同一个实现**;从前是「同一手段」但各写各的,结果 #192 的修复只落在一边(见 `reload-stream.ts`)。
2459
+ * 生产装配下插件只提交恢复意图;没有协调器的独立使用才回落到 `reloadStream`。
2460
+ * 旧 ReconnectPlugin 曾与它各写一套重拉,#192 的修复只落在一边,因此两条历史路径后来共用
2461
+ * `reload-stream.ts`。
2168
2462
  *
2169
2463
  * @example
2170
2464
  * new Player({ el, plugins: [VisibilityPlugin], visibility: { isIOS: env.isIOS } })
@@ -2366,7 +2660,7 @@ const DEFAULT_CONFIG = {
2366
2660
  *
2367
2661
  * 解决的 pitfall:**#28**(覆盖层 z-index 冲突)、**#29**(fullscreen 时兄弟元素遮挡)
2368
2662
  * —— 部分 Android 浏览器 / WebView 把视频层的 z-index 抬得过高,
2369
- * 盖住宿主的弹窗 / 抽屉 / toast(ARCHITECTURE § 10.1「视频层级过高盖弹窗」)。
2663
+ * 盖住宿主的弹窗 / 抽屉 / toast;完整坑点说明见 `.claude/context/xgplayer-pitfalls.md`。
2370
2664
  *
2371
2665
  * ⚠️ 这两个编号**此前不在这里**:JSDoc 只写着「层级冲突」四个字,而
2372
2666
  * `.claude/context/xgplayer-pitfalls.md`(35 个编号的唯一事实源)的快速索引表
@@ -2418,9 +2712,13 @@ var ZIndexGuardPlugin = class extends xgplayer.BasePlugin {
2418
2712
  /**
2419
2713
  * 观察一次恢复动作的强播放证据。仅 playing 后正常位置推进算成功;
2420
2714
  * seek、暂停和等待会重置基准。调用方负责把成功关联到当前动作凭据。
2715
+ * 点播传入 `beyond`(故障位置)时,推进还必须越过它:从头重播出的前缀不是恢复(#96)。
2716
+ * 有视频画面(`videoWidth > 0`)且拿得到解码帧计数时,还要求帧数自基准后增长:
2717
+ * 视频解码停而音频照走时位置也会推进,那不是画面恢复(#103 · ADR-105)。
2421
2718
  */
2422
- function observeRecoveryPlayback(media, signal, recovered) {
2719
+ function observeRecoveryPlayback(media, signal, recovered, options = {}) {
2423
2720
  let baseline = null;
2721
+ let frameBaseline = null;
2424
2722
  let playingSeen = false;
2425
2723
  let disposed = false;
2426
2724
  const reset = () => {
@@ -2430,12 +2728,16 @@ function observeRecoveryPlayback(media, signal, recovered) {
2430
2728
  const playing = () => {
2431
2729
  playingSeen = true;
2432
2730
  baseline = Number.isFinite(media.currentTime) ? media.currentTime : null;
2731
+ frameBaseline = decodedVideoFrames(media);
2433
2732
  };
2434
2733
  const seeking = () => {
2435
2734
  baseline = null;
2436
2735
  };
2437
2736
  const seeked = () => {
2438
- if (playingSeen && !media.paused && !media.seeking && media.readyState >= 2) baseline = Number.isFinite(media.currentTime) ? media.currentTime : null;
2737
+ if (playingSeen && !media.paused && !media.seeking && media.readyState >= 2) {
2738
+ baseline = Number.isFinite(media.currentTime) ? media.currentTime : null;
2739
+ frameBaseline = decodedVideoFrames(media);
2740
+ }
2439
2741
  };
2440
2742
  const resets = [
2441
2743
  "pause",
@@ -2456,6 +2758,8 @@ function observeRecoveryPlayback(media, signal, recovered) {
2456
2758
  const progress = () => {
2457
2759
  if (disposed || baseline === null || media.paused || media.seeking || media.readyState < 2) return;
2458
2760
  if (!Number.isFinite(media.currentTime) || media.currentTime <= baseline) return;
2761
+ if (options.beyond !== void 0 && media.currentTime <= options.beyond) return;
2762
+ if (frameBaseline !== null && (decodedVideoFrames(media) ?? 0) <= frameBaseline) return;
2459
2763
  dispose();
2460
2764
  recovered();
2461
2765
  };
@@ -2468,6 +2772,12 @@ function observeRecoveryPlayback(media, signal, recovered) {
2468
2772
  signal.addEventListener("abort", dispose, { once: true });
2469
2773
  return dispose;
2470
2774
  }
2775
+ /** 有视频画面时的已解码帧累计数;纯音频或浏览器不提供计数时为 `null`(不以帧为证据) */
2776
+ function decodedVideoFrames(media) {
2777
+ if (!(media instanceof HTMLVideoElement) || media.videoWidth <= 0) return null;
2778
+ const quality = media.getVideoPlaybackQuality?.();
2779
+ return quality && Number.isFinite(quality.totalVideoFrames) ? quality.totalVideoFrames : null;
2780
+ }
2471
2781
  /** 媒体修复会重新挂载 MSE;先订阅 canplay,避免对尚未挂载的元素提前 play。 */
2472
2782
  function waitForMediaRepair(media, signal, repair) {
2473
2783
  if (signal.aborted) return Promise.resolve(false);
@@ -2512,6 +2822,19 @@ function describePlaybackRuntime(env) {
2512
2822
  //#endregion
2513
2823
  //#region src/source-normalize.ts
2514
2824
  /**
2825
+ * 把 URL 回显进错误消息前只保留 origin+pathname —— 这两处抛出在 createPlayer 里同步
2826
+ * 发生,不经 `makePlayerError`,原样回显会把签名 URL 的完整 query(含 token)暴露给宿主。
2827
+ * 解析失败(如相对路径拼接错误)时回显占位符,而不是原样吐出未知格式的字符串。
2828
+ */
2829
+ function redactUrlForMessage(url) {
2830
+ try {
2831
+ const parsed = new URL(url);
2832
+ return `${parsed.origin}${parsed.pathname}`;
2833
+ } catch {
2834
+ return "(无法解析的 URL)";
2835
+ }
2836
+ }
2837
+ /**
2515
2838
  * 从 URL 推断媒体类型。
2516
2839
  *
2517
2840
  * 先剥掉 query 和 hash 再看扩展名 —— 鉴权只能走签名 URL(坑 #26),而签名 URL 长这样
@@ -2545,7 +2868,7 @@ function isMultiSource(source) {
2545
2868
  function normalizeSource(source) {
2546
2869
  if (typeof source === "string") {
2547
2870
  const type = inferTypeFromUrl(source);
2548
- if (!type) throw new Error(`无法从 URL 推断媒体类型:${source}。请改用对象形式并显式传 type,如 { url, type: 'hls' }`);
2871
+ if (!type) throw new Error(`无法从 URL 推断媒体类型:${redactUrlForMessage(source)}。请改用对象形式并显式传 type,如 { url, type: 'hls' }`);
2549
2872
  return {
2550
2873
  candidates: [{
2551
2874
  url: source,
@@ -2566,7 +2889,7 @@ function normalizeSource(source) {
2566
2889
  };
2567
2890
  const explicit = source.type;
2568
2891
  const type = explicit && explicit !== "auto" ? explicit : inferTypeFromUrl(source.url);
2569
- if (!type) throw new Error(`无法从 URL 推断媒体类型:${source.url}。请显式传 type,如 { url, type: 'hls' }`);
2892
+ if (!type) throw new Error(`无法从 URL 推断媒体类型:${redactUrlForMessage(source.url)}。请显式传 type,如 { url, type: 'hls' }`);
2570
2893
  return {
2571
2894
  candidates: [{
2572
2895
  url: source.url,
@@ -2606,9 +2929,8 @@ const CONTROL_MESSAGE_KEYS = {
2606
2929
  /**
2607
2930
  * 把契约的 `locale.messages` 里那一族 `controls.*` 翻成 xgplayer 的实例级 i18n 条目。
2608
2931
  *
2609
- * **返回 `null` 表示「一条都没有」,调用方应当整个省略 `i18n` 键** ——
2610
- * 不是传空数组:省略时 xgplayer 走 `this.config.i18n || []`,行为与接线前逐字节相同,
2611
- * 而传 `[]` 虽然等价却让 config 的形状变了,e2e / 快照会无谓地漂。
2932
+ * 每次返回当前实例语言的完整控件快照。第三语言复用内部英文槽位;只注入部分 key
2933
+ * 会在切回另一种语言时留下上一种语言的文案。
2612
2934
  *
2613
2935
  * `lang` 由调用方传入(`toXgLang` 收敛后的 `zh-cn` / `en`)——
2614
2936
  * **注入的语义是「覆盖当前那一档的文案」,不是「注册一个新语种」**:
@@ -2619,7 +2941,7 @@ const CONTROL_MESSAGE_KEYS = {
2619
2941
  *
2620
2942
  * @example
2621
2943
  * toXgI18nEntries({ locale: 'vi-VN', messages: { 'vi-VN': { 'controls.play': 'Phát' } } }, 'en')
2622
- * // → [{ LANG: 'en', TEXT: { PLAY_TIPS: 'Phát' } }]
2944
+ * // → [{ LANG: 'en', TEXT: { PLAY_TIPS: 'Phát', ...其余键逐项回退中文 } }]
2623
2945
  */
2624
2946
  function toXgI18nEntries(locale, lang) {
2625
2947
  const { messages } = (0, _video_lab_protocol.resolveLocaleMessages)(locale);
@@ -2628,10 +2950,10 @@ function toXgI18nEntries(locale, lang) {
2628
2950
  const value = messages[contractKey];
2629
2951
  if (typeof value === "string" && value !== "") text[xgKey] = value;
2630
2952
  }
2631
- return Object.keys(text).length > 0 ? [{
2953
+ return [{
2632
2954
  LANG: lang,
2633
2955
  TEXT: text
2634
- }] : null;
2956
+ }];
2635
2957
  }
2636
2958
  //#endregion
2637
2959
  //#region src/create-player.ts
@@ -2800,29 +3122,28 @@ function createPlayer(options) {
2800
3122
  const normalized = normalizeSource(config.source);
2801
3123
  let sessionId = newSessionId();
2802
3124
  let deliverySequence = 0;
2803
- const makeDelivered = (event) => {
3125
+ const attachDelivery = (event) => {
2804
3126
  deliverySequence += 1;
2805
3127
  return {
2806
- event,
2807
- producerSessionId: sessionId,
2808
- deliveryId: `${sessionId}:${deliverySequence}`,
2809
- occurredAtMs: Date.now(),
2810
- sequence: deliverySequence
3128
+ ...event,
3129
+ delivery: {
3130
+ producerSessionId: sessionId,
3131
+ deliveryId: `${sessionId}:${deliverySequence}`,
3132
+ occurredAtMs: Date.now(),
3133
+ sequence: deliverySequence
3134
+ }
2811
3135
  };
2812
3136
  };
2813
- const notify = (delivered) => {
2814
- try {
2815
- options.onEvent?.(delivered.event);
2816
- } catch {}
3137
+ const notify = (event) => {
2817
3138
  try {
2818
- options.onDeliveredEvent?.(delivered);
3139
+ options.onEvent?.(event);
2819
3140
  } catch {}
2820
3141
  };
2821
3142
  let routed;
2822
3143
  try {
2823
3144
  routed = routeSource(normalized, env);
2824
3145
  } catch (error) {
2825
- if (error instanceof SentinelError && error.playerError.code === "E_MEDIA_NOT_SUPPORTED") notify(makeDelivered({
3146
+ if (error instanceof SentinelError && error.playerError.code === "E_MEDIA_NOT_SUPPORTED") notify(attachDelivery({
2826
3147
  event: "sourceroute",
2827
3148
  payload: unsupportedRoutePayload(sessionId, normalized, env)
2828
3149
  }));
@@ -2838,7 +3159,7 @@ function createPlayer(options) {
2838
3159
  };
2839
3160
  const emit = (event) => {
2840
3161
  if (destroyed) return;
2841
- const delivered = makeDelivered(event);
3162
+ const delivered = attachDelivery(event);
2842
3163
  if (!eventStreamReady) {
2843
3164
  deferredEvents.push(delivered);
2844
3165
  return;
@@ -2916,6 +3237,7 @@ function createPlayer(options) {
2916
3237
  options.el.ownerDocument.addEventListener("keydown", onPageEscape);
2917
3238
  }
2918
3239
  playerRef = player;
3240
+ const lastFrame = createLastFramePlaceholder(() => player.video instanceof HTMLMediaElement ? player.video : null);
2919
3241
  let desiredPlayback = config.autoplay ?? false;
2920
3242
  let recoveryCleanup = null;
2921
3243
  const stopRecoveryLoad = () => {
@@ -2929,10 +3251,29 @@ function createPlayer(options) {
2929
3251
  }
2930
3252
  }
2931
3253
  };
3254
+ /**
3255
+ * 当前是否真的做得了媒体修复:只有 hls.js 内核挂载了插件时才有 recoverMediaError。
3256
+ * 返回可调用的修复函数,`null` 表示这一步只能整条重拉(#111)。
3257
+ */
3258
+ const hlsMediaRepair = () => {
3259
+ if (routed.kernel !== "hls.js") return null;
3260
+ const plugin = player.getPlugin("HlsJsPlugin");
3261
+ return plugin?.recoverMediaError ? () => plugin.recoverMediaError() : null;
3262
+ };
3263
+ let recoveryResume = null;
2932
3264
  recovery = new PlaybackRecovery(sessionId, async (token, signal) => {
2933
3265
  recoveryCleanup?.();
2934
3266
  const media = player.video;
2935
3267
  if (!(media instanceof HTMLMediaElement)) throw new Error("Recovery requires a media element");
3268
+ lastFrame.capture();
3269
+ if (recoveryResume?.episode !== token.episode) {
3270
+ const position = media.currentTime;
3271
+ recoveryResume = {
3272
+ episode: token.episode,
3273
+ position: !normalized.live && Number.isFinite(position) && position > 0 ? position : 0
3274
+ };
3275
+ }
3276
+ const resumeAt = recoveryResume.position;
2936
3277
  const cleanup = () => {
2937
3278
  unobserve();
2938
3279
  signal.removeEventListener("abort", abort);
@@ -2946,14 +3287,21 @@ function createPlayer(options) {
2946
3287
  const unobserve = observeRecoveryPlayback(media, signal, () => {
2947
3288
  cleanup();
2948
3289
  recovery?.recovered(token);
2949
- });
3290
+ }, resumeAt > 0 ? { beyond: resumeAt } : {});
2950
3291
  recoveryCleanup = cleanup;
2951
3292
  signal.addEventListener("abort", abort, { once: true });
2952
- const repaired = token.strategy === "media_recovery" && token.attempt === 1 && routed.kernel === "hls.js" && await waitForMediaRepair(media, signal, () => player.getPlugin("HlsJsPlugin")?.recoverMediaError() ?? false);
3293
+ const repaired = token.strategy === "media_recovery" && await waitForMediaRepair(media, signal, () => hlsMediaRepair()?.() ?? false);
2953
3294
  if (signal.aborted || destroyed) return;
2954
3295
  if (!repaired) await player.switchURL(currentSrcUrl);
3296
+ if (!signal.aborted && !destroyed && media.currentTime < resumeAt) media.currentTime = resumeAt;
2955
3297
  if (!signal.aborted && !destroyed && recovery?.playingIntent) await player.play();
2956
- }, emit, (state) => playableState()?.setRecovery(state));
3298
+ }, emit, (state) => {
3299
+ if (!state.recovering) lastFrame.release();
3300
+ playableState()?.setRecovery(state);
3301
+ }, {
3302
+ failsAttempt: (error) => !normalized.live && error.category === "media",
3303
+ resolveStrategy: (strategy, attempt) => strategy === "media_recovery" && !(attempt === 1 && hlsMediaRepair()) ? "reconnect" : strategy
3304
+ });
2957
3305
  recovery.setPlayingIntent(desiredPlayback);
2958
3306
  const listeners = [];
2959
3307
  const listen = (name, handler) => {
@@ -2962,6 +3310,7 @@ function createPlayer(options) {
2962
3310
  };
2963
3311
  const hlsCleanups = [];
2964
3312
  const disposers = [];
3313
+ disposers.push(() => lastFrame.release());
2965
3314
  disposers.push(observeMediaPlayRejection(player));
2966
3315
  let flvAudioHealth = null;
2967
3316
  let flvPlaybackActive = false;
@@ -2988,18 +3337,18 @@ function createPlayer(options) {
2988
3337
  *
2989
3338
  * ```
2990
3339
  * player.switchURL(url)
2991
- * → xgplayer/es/player.js this.src = _src
2992
- * → xgplayer/es/mediaProxy.js emit(URL_CHANGE, url)
2993
- * → xgplayer-hls.js/es/index.js on(URL_CHANGE) → register(url)
2994
- * → xgplayer-hls.js/es/index.js this.hls.destroy(); this.hls = new Hls(...)
3340
+ * → xgplayer/es/player.js this.src = _src
3341
+ * → xgplayer/es/mediaProxy.js emit(URL_CHANGE, url)
3342
+ * → OwnedHlsPlugin.on(URL_CHANGE) register(url)
3343
+ * → HlsAdapter.stop() + start(url) 换成新的 hls.js 实例
2995
3344
  * ```
2996
3345
  *
2997
3346
  * 布尔标志一旦置起就再也不复位,于是新实例上一条监听都没有 —— `qualitychange`
2998
3347
  * 不再发、`currentQualityLevel` 永远停在 `null`(而契约说 `null` 的含义是
2999
3348
  * 「单码率源或档位未知」,这里会把多码率源持续误报)。
3000
3349
  *
3001
- * **触发入口不只是 `load()`**:`ReconnectPlugin` 的每一次重试和 `VisibilityPlugin`
3002
- * 的 iOS 后台切回自愈都走 `reloadStream()` → `switchURL`。所以复位点**不能只放在
3350
+ * **触发入口不只是 `load()`**:统一恢复的重拉动作和 `VisibilityPlugin`
3351
+ * 的 iOS 后台切回意图都可能走 `reloadStream()` → `switchURL`。所以复位点**不能只放在
3003
3352
  * `load()` 里** —— 判据必须是「实例还是不是同一个」,而不是「有没有换过源」。
3004
3353
  *
3005
3354
  * `reload-stream.ts` 的注释早就写着 `switchURL` 会「重新建 hls 实例并 attach」,
@@ -3046,7 +3395,7 @@ function createPlayer(options) {
3046
3395
  emitContextChange();
3047
3396
  for (const delivered of deferredEvents) {
3048
3397
  notify(delivered);
3049
- reportRecoveryFault(delivered.event, delivered.producerSessionId);
3398
+ reportRecoveryFault(delivered, delivered.delivery?.producerSessionId ?? sessionId);
3050
3399
  }
3051
3400
  deferredEvents.length = 0;
3052
3401
  const attachQualityListener = () => {
@@ -3093,6 +3442,8 @@ function createPlayer(options) {
3093
3442
  };
3094
3443
  disposers.push(() => naturalProof?.abort());
3095
3444
  let sawFirstFrame = false;
3445
+ let firstFrameDataReady = false;
3446
+ let firstFrameStartedAt = performance.now();
3096
3447
  let sawPlaybackStart = false;
3097
3448
  let sawPlaying = false;
3098
3449
  let sawStartupWaiting = false;
@@ -3123,6 +3474,8 @@ function createPlayer(options) {
3123
3474
  armStartupDeadline();
3124
3475
  const resetPrematureVodEndState = () => {
3125
3476
  sawFirstFrame = false;
3477
+ firstFrameDataReady = false;
3478
+ firstFrameStartedAt = performance.now();
3126
3479
  sawPlaybackStart = false;
3127
3480
  sawPlaying = false;
3128
3481
  sawStartupWaiting = false;
@@ -3131,6 +3484,15 @@ function createPlayer(options) {
3131
3484
  seeking = false;
3132
3485
  prematureVodEnd = null;
3133
3486
  };
3487
+ const markFirstFrame = (fvt) => {
3488
+ if (sawFirstFrame) return;
3489
+ sawFirstFrame = true;
3490
+ clearStartupDeadline();
3491
+ emit({
3492
+ event: "firstframe",
3493
+ payload: { fvt }
3494
+ });
3495
+ };
3134
3496
  const observePlaybackPosition = () => {
3135
3497
  const position = safeNumber(player.currentTime);
3136
3498
  const duration = safeNumber(player.duration);
@@ -3197,13 +3559,15 @@ function createPlayer(options) {
3197
3559
  desiredPlayback = false;
3198
3560
  clearStartupDeadline();
3199
3561
  recovery?.endPlayback();
3200
- }
3562
+ } else if (!normalized.live && recovery?.attemptInFlight) recovery.endPlayback();
3563
+ else if (normalized.live) recovery?.requestRecovery("ended");
3201
3564
  emit({
3202
3565
  event: "ended",
3203
3566
  payload: {}
3204
3567
  });
3205
3568
  });
3206
3569
  listen("loadeddata", () => {
3570
+ firstFrameDataReady = true;
3207
3571
  attachQualityListener();
3208
3572
  emit({
3209
3573
  event: "ready",
@@ -3217,6 +3581,8 @@ function createPlayer(options) {
3217
3581
  });
3218
3582
  listen("timeupdate", () => {
3219
3583
  observePlaybackPosition();
3584
+ const media = player.video;
3585
+ if (!sawFirstFrame && firstFrameDataReady && desiredPlayback && media instanceof HTMLMediaElement && !media.paused && media.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) markFirstFrame(Math.max(0, performance.now() - firstFrameStartedAt));
3220
3586
  emit({
3221
3587
  event: "timeupdate",
3222
3588
  payload: {
@@ -3259,12 +3625,7 @@ function createPlayer(options) {
3259
3625
  if (log.eventType !== "firstFrame") return;
3260
3626
  const fvt = log.fvt;
3261
3627
  if (typeof fvt !== "number" || !Number.isFinite(fvt)) return;
3262
- sawFirstFrame = true;
3263
- clearStartupDeadline();
3264
- emit({
3265
- event: "firstframe",
3266
- payload: { fvt }
3267
- });
3628
+ markFirstFrame(fvt);
3268
3629
  });
3269
3630
  listen("fps_stuck", (raw) => {
3270
3631
  const payload = (0, _video_lab_protocol.normalizeFrameFreeze)(raw);
@@ -3277,14 +3638,17 @@ function createPlayer(options) {
3277
3638
  listen("user_action", (raw) => {
3278
3639
  const payload = (0, _video_lab_protocol.normalizeUserAction)(raw);
3279
3640
  if (payload === null) return;
3280
- if (payload.action === "switch_play_pause" && typeof payload.to === "boolean") setPlayingIntent(!payload.to);
3641
+ if (payload.action === "switch_play_pause") {
3642
+ const video = player.video;
3643
+ setPlayingIntent(typeof payload.to === "boolean" ? !payload.to : video instanceof HTMLMediaElement && video.paused);
3644
+ }
3281
3645
  emit({
3282
3646
  event: "useraction",
3283
3647
  payload
3284
3648
  });
3285
3649
  });
3286
3650
  listen("error", (err) => {
3287
- const mapped = mapXgplayerError(err);
3651
+ const mapped = mapXgplayerError(err, { live: normalized.live });
3288
3652
  if ((flvJustReported || hlsJustReported) && mapped.code === "E_UNKNOWN") return;
3289
3653
  sawNativeError = true;
3290
3654
  emit({
@@ -3332,10 +3696,8 @@ function createPlayer(options) {
3332
3696
  /**
3333
3697
  * HLS 侧的同一道闸(#724 · A-8)。理由与 `flvJustReported` **同构**,只是上游形状不同。
3334
3698
  *
3335
- * `xgplayer-hls.js` 对每条 hls 错误先 `emit('HLS_ERROR', …)`,随后
3336
- * `if (data.fatal) switch (data.type)` —— NETWORK / MEDIA 两支私吞,
3337
- * **`default:` 支再 `emit('error', data)`**(即 `muxError` / `keySystemError` /
3338
- * `otherError` 这三类 fatal)。
3699
+ * SDK 自有 `OwnedHlsPlugin` 对每条 hls.js 诊断先发结构化 `HLS_ERROR`;xgplayer 的
3700
+ * 通用错误路径在部分 fatal 形态下仍可能同步补一条信息更少的 `error`。
3339
3701
  *
3340
3702
  * 于是同一次故障走两条路:`HLS_ERROR` 那条经 `mapHlsKernelError` 精确映射(好的),
3341
3703
  * `error` 那条经 `mapXgplayerError` —— 而它读 `errorType`、查的是 xgplayer 自己的词表
@@ -3512,10 +3874,9 @@ function createPlayer(options) {
3512
3874
  /**
3513
3875
  * 内核诊断 → 契约 error 流(#402)。
3514
3876
  *
3515
- * `xgplayer-hls.js` 的包装层把 **NETWORK / MEDIA 两类 fatal 错误私吞了**
3516
- *(`startLoad()` 重试 / `recoverMediaError()`,都不 `emit('error')`),
3517
- * 而 `status === 404` 时连重试都没有。实测:manifest 404 → hls.js 报
3518
- * `manifestLoadError` **fatal=true**,消费方拿到的契约 `error` **0 条**。
3877
+ * SDK 自有 `OwnedHlsPlugin` 会把 hls.js 的诊断清洗为 `HLS_ERROR`;这里负责尽早接住、
3878
+ * 映射为契约错误或送入非 fatal 聚合。不能等 `loadeddata` 后再挂监听,因为 manifest
3879
+ * 加载失败时该事件永远不会发生。
3519
3880
  *
3520
3881
  * ⚠️ **必须挂在 player 上,不能去够 hls 实例。** 三条都是实测出来的:
3521
3882
  * ① `attachQualityListener` 那个挂载点用不了 —— 它在 `loadeddata` 里挂,
@@ -3525,9 +3886,8 @@ function createPlayer(options) {
3525
3886
  * `manifestLoadError fatal=true`,差别只是 50ms 轮询与 `loadSource` 的先后。
3526
3887
  * **一个会随机漏报的接线比没有更坏**,因为它看起来在工作。
3527
3888
  *
3528
- * `HLS_ERROR` 是 player 级事件、包装层对每条 hls 错误都发,三条一次绕开。
3529
- *
3530
- * **只做可见性,不改恢复行为**:不触发重连、不干预 `startLoad` / `recoverMediaError`。
3889
+ * `HLS_ERROR` 是 player 级事件,OwnedHlsPlugin 对每条 hls.js 错误都发;映射层本身只
3890
+ * 提供可见性,恢复动作由 PlaybackRecovery / RecoveryController 决定。
3531
3891
  */
3532
3892
  /**
3533
3893
  * 非致命诊断的**聚合**出口(#391 · ADR-062)。
@@ -3541,17 +3901,21 @@ function createPlayer(options) {
3541
3901
  * 非 fatal 走这里,同一条诊断只进一边。改动时别让 fatal 也 `record` 进来 ——
3542
3902
  * 那会让 `count` 把已经报过 `error` 的东西再数一遍。
3543
3903
  */
3544
- const kernelHealth = createKernelHealthAggregator({ onReport: (payload) => emit({
3545
- event: "kernelhealth",
3546
- payload
3547
- }) });
3904
+ const kernelHealth = createKernelHealthAggregator({ onReport: (payload) => {
3905
+ if (payload.degraded) recovery?.noteKernelHealth(payload.reason);
3906
+ emit({
3907
+ event: "kernelhealth",
3908
+ payload
3909
+ });
3910
+ } });
3548
3911
  disposers.push(() => kernelHealth.dispose());
3549
3912
  listen("HLS_ERROR", (...args) => {
3550
3913
  const payload = args.at(-1);
3551
3914
  if (typeof payload !== "object" || payload === null) return;
3552
3915
  const hls = payload;
3553
- const mapped = mapHlsKernelError(hls);
3916
+ const mapped = mapHlsKernelError(hls, { live: normalized.live });
3554
3917
  if (mapped === void 0) {
3918
+ if (hls.httpStatus === 401 || hls.httpStatus === 403) recovery?.noteKernelHealth("auth");
3555
3919
  kernelHealth.record(hlsHealthReason(hls), `${hls.errorType ?? "未知"} / ${hls.errorDetails ?? "未知"}`);
3556
3920
  return;
3557
3921
  }
@@ -3640,9 +4004,9 @@ function createPlayer(options) {
3640
4004
  setLocale(locale) {
3641
4005
  if (destroyed) return;
3642
4006
  const lang = toXgLang(locale);
3643
- if (!lang) return;
3644
4007
  extendInstanceI18n(player, locale, lang);
3645
4008
  player.lang = lang;
4009
+ syncXgplayerTimeLocale(player);
3646
4010
  },
3647
4011
  pushDanmaku(item) {
3648
4012
  if (destroyed) return;
@@ -3770,24 +4134,14 @@ function createPlayer(options) {
3770
4134
  * 覆盖层是越南语、控件是英文,两半对不上。接了这条线,`locale` 才真正统辖两者。
3771
4135
  *
3772
4136
  * 注意作用范围:这里只管 **xgplayer 自带控件**的文案;player-ui 的 4 个覆盖层文案
3773
- * 走 `locale.messages` 注入(SDK 不内置翻译),两者互不干扰。
3774
- *
3775
- * **未传 locale 时返回 undefined,调用方须整个省略 `lang` 键** —— 不能兜底成 `'en'`。
3776
- * 省略时 xgplayer 走自己的 `getLang()`:
3777
- * `document.documentElement.getAttribute('lang') || navigator.language || 'zh-cn'`
3778
- * (`utils/util.js:815`)。**注意 `<html lang>` 排在浏览器语言前面** —— 宿主页声明了 lang
3779
- * 就以它为准,没声明才跟浏览器走。兜底成 `'en'` 会把这整套既有行为一刀切掉。
3780
- * 这条线只该在消费方明确表态时才接管。
3781
- *
3782
- * ⚠️ 由此带来的一个实测结论(#190 step 3):`apps/embed-app/index.html` 写死 `<html lang="en">`,
3783
- * 所以**静态 iframe 那条路不传 locale 时控件恒为英文**,读者的浏览器语言完全不参与。
3784
- * 那条路现在有 `?locale=` 了(`config-from-url.ts`),它是那一页上唯一能改控件语言的东西;
3785
- * e2e 在 `locale-static-iframe.spec.ts` 里把这两半都钉住了。
4137
+ * 由消费面解析官方资源及 `locale.messages` 后注入,两者使用同一有效语言。
4138
+ *
4139
+ * 未传 locale 时固定选 `zh-cn`,不再跟随宿主页面或浏览器语言(ADR-120)。
3786
4140
  */
3787
4141
  function toXgLang(locale) {
3788
4142
  const tag = typeof locale === "string" ? locale : locale?.locale;
3789
- if (!tag) return void 0;
3790
- return tag.toLowerCase().startsWith("zh") ? "zh-cn" : "en";
4143
+ if (!tag) return "zh-cn";
4144
+ return /^(zh|zh-cn)$/i.test(tag.replaceAll("_", "-")) ? "zh-cn" : "en";
3791
4145
  }
3792
4146
  /**
3793
4147
  * 把 `controls.*` extend 进**这个播放器实例**的 i18n 表(运行时切语言用,ADR-035)。
@@ -3799,11 +4153,28 @@ function toXgLang(locale) {
3799
4153
  */
3800
4154
  function extendInstanceI18n(player, locale, lang) {
3801
4155
  const entries = toXgI18nEntries(locale, lang);
3802
- if (!entries) return;
3803
4156
  const instanceI18n = player.__i18n;
3804
4157
  if (!instanceI18n) return;
3805
4158
  xgplayer.I18N.extend(entries, instanceI18n);
3806
4159
  }
4160
+ /**
4161
+ * 补齐 xgplayer 3.0.26 Time 控件的运行时语言切换。
4162
+ *
4163
+ * 该控件首次渲染 `.time-live-tag` 时直接写入 `i18n.LIVE_TIP`,却没有像其他
4164
+ * 官方控件一样附上 `lang-key`。因此 `player.lang = ...` 会更新播放、全屏等提示,
4165
+ * 却把直播标签留在旧语言。这里只在当前播放器的 Time 插件根节点内补齐文案和语言键;
4166
+ * 无该插件、DOM 形状变化或翻译缺失时都安全降级,不写 xgplayer 全局 i18n。
4167
+ */
4168
+ function syncXgplayerTimeLocale(player) {
4169
+ const playerWithI18n = player;
4170
+ const liveTag = playerWithI18n.getPlugin?.("time")?.root?.querySelector?.(".time-live-tag");
4171
+ if (!liveTag) return;
4172
+ const liveText = playerWithI18n.i18n?.LIVE_TIP;
4173
+ if (typeof liveText !== "string" || liveText === "") return;
4174
+ liveTag.textContent = liveText;
4175
+ const langKey = playerWithI18n.i18nKeys?.LIVE_TIP;
4176
+ if (typeof langKey === "string" && langKey !== "") liveTag.setAttribute("lang-key", langKey);
4177
+ }
3807
4178
  function buildXgplayerConfig(options, routed, normalized, emit, env, playableState, recovery) {
3808
4179
  const { config } = options;
3809
4180
  const live = normalized.live;
@@ -3836,12 +4207,10 @@ function buildXgplayerConfig(options, routed, normalized, emit, env, playableSta
3836
4207
  ...config.controlVisibility?.volume === false ? ["volume"] : [],
3837
4208
  ...config.controlVisibility?.time === false ? ["time"] : []
3838
4209
  ],
3839
- ...toXgLang(config.locale) ? { lang: toXgLang(config.locale) } : {},
4210
+ lang: toXgLang(config.locale),
3840
4211
  ...(() => {
3841
4212
  const lang = toXgLang(config.locale);
3842
- if (!lang) return {};
3843
- const entries = toXgI18nEntries(config.locale, lang);
3844
- return entries ? { i18n: entries } : {};
4213
+ return { i18n: toXgI18nEntries(config.locale, lang) };
3845
4214
  })(),
3846
4215
  plugins,
3847
4216
  ...subtitleList.length > 0 ? { texttrack: {
@@ -3885,7 +4254,9 @@ function buildXgplayerConfig(options, routed, normalized, emit, env, playableSta
3885
4254
  payload
3886
4255
  });
3887
4256
  playableState()?.setStalled(payload.phase === "start");
3888
- if (payload.phase === "start" && payload.kind === "playback") recovery()?.requestRecovery("stall");
4257
+ },
4258
+ onSustainedStall: () => {
4259
+ recovery()?.requestRecovery("stall");
3889
4260
  },
3890
4261
  onBufferHealth: (payload) => {
3891
4262
  emit({
@@ -3918,7 +4289,7 @@ function buildXgplayerConfig(options, routed, normalized, emit, env, playableSta
3918
4289
  };
3919
4290
  }
3920
4291
  /**
3921
- * 取 xgplayer-hls.js 插件持有的 hls.js 实例。非 HLS 源(MP4 走原生、FLV 走 flv.js)
4292
+ * 取 SDK 自有 OwnedHlsPlugin 持有的 hls.js 实例。非 HLS 源(MP4 走原生、FLV 走 flv.js)
3922
4293
  * 拿不到,返回 undefined —— 上层据此把多码率相关能力降级成 no-op / 空档位。
3923
4294
  */
3924
4295
  function getHlsInstance(player) {