@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/README.md +57 -23
- package/dist/index.cjs +518 -147
- package/dist/index.d.cts +21 -19
- package/dist/index.d.mts +21 -19
- package/dist/index.mjs +517 -147
- package/package.json +2 -2
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
|
-
*
|
|
249
|
+
* 点播专有的 details 细化(ADR-107,ADR-061「按 type 映射」的例外)。
|
|
260
250
|
*
|
|
261
|
-
*
|
|
251
|
+
* hls.js 把子播放列表解析失败(`levelParsingError`)归在 `networkError` 下,按 type 会映射成
|
|
252
|
+
* 可重连的 `E_NETWORK`。点播的列表是静态文件,解析失败重试也还是同一份(LiveLab HLS-15 实测),
|
|
253
|
+
* 应为不可重试的 `E_MANIFEST_PARSE`。直播列表动态生成,偶发损坏可能下次就好,不细化。
|
|
254
|
+
* 只作用于 fatal 的 error 通道;`kernelhealth` 分类不受影响。
|
|
262
255
|
*
|
|
263
|
-
* `
|
|
264
|
-
*
|
|
265
|
-
*
|
|
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
|
-
*
|
|
268
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
930
|
+
trigger,
|
|
834
931
|
reason: input.reason,
|
|
835
|
-
attempt:
|
|
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?.
|
|
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
|
|
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.
|
|
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)
|
|
1012
|
-
|
|
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
|
-
* 直接崩溃 / 白屏)
|
|
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
|
-
*
|
|
1334
|
-
*
|
|
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.
|
|
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)
|
|
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
|
-
|
|
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 =
|
|
1533
|
-
this.stallKind =
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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`
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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", "
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2167
|
-
*
|
|
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
|
|
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)
|
|
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
|
-
*
|
|
2610
|
-
*
|
|
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
|
|
2953
|
+
return [{
|
|
2632
2954
|
LANG: lang,
|
|
2633
2955
|
TEXT: text
|
|
2634
|
-
}]
|
|
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
|
|
3125
|
+
const attachDelivery = (event) => {
|
|
2804
3126
|
deliverySequence += 1;
|
|
2805
3127
|
return {
|
|
2806
|
-
event,
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
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 = (
|
|
2814
|
-
try {
|
|
2815
|
-
options.onEvent?.(delivered.event);
|
|
2816
|
-
} catch {}
|
|
3137
|
+
const notify = (event) => {
|
|
2817
3138
|
try {
|
|
2818
|
-
options.
|
|
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(
|
|
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 =
|
|
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" &&
|
|
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) =>
|
|
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
|
|
2992
|
-
* → xgplayer/es/mediaProxy.js
|
|
2993
|
-
* →
|
|
2994
|
-
* →
|
|
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()
|
|
3002
|
-
* 的 iOS
|
|
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
|
|
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
|
-
|
|
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"
|
|
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
|
-
* `
|
|
3336
|
-
*
|
|
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
|
-
* `
|
|
3516
|
-
*
|
|
3517
|
-
*
|
|
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
|
|
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) =>
|
|
3545
|
-
|
|
3546
|
-
|
|
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
|
-
*
|
|
3774
|
-
*
|
|
3775
|
-
*
|
|
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
|
|
3790
|
-
return
|
|
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
|
-
|
|
4210
|
+
lang: toXgLang(config.locale),
|
|
3840
4211
|
...(() => {
|
|
3841
4212
|
const lang = toXgLang(config.locale);
|
|
3842
|
-
|
|
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
|
-
|
|
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
|
-
* 取
|
|
4292
|
+
* 取 SDK 自有 OwnedHlsPlugin 持有的 hls.js 实例。非 HLS 源(MP4 走原生、FLV 走 flv.js)
|
|
3922
4293
|
* 拿不到,返回 undefined —— 上层据此把多码率相关能力降级成 no-op / 空档位。
|
|
3923
4294
|
*/
|
|
3924
4295
|
function getHlsInstance(player) {
|