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