@video-lab/player-core 2.0.0 → 3.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 +16 -1
- package/dist/index.cjs +1044 -569
- package/dist/index.d.cts +17 -6
- package/dist/index.d.mts +17 -6
- package/dist/index.mjs +1044 -569
- package/package.json +4 -4
package/dist/index.cjs
CHANGED
|
@@ -26,8 +26,8 @@ let xgplayer = require("xgplayer");
|
|
|
26
26
|
xgplayer = __toESM(xgplayer, 1);
|
|
27
27
|
let xgplayer_flv_js = require("xgplayer-flv.js");
|
|
28
28
|
xgplayer_flv_js = __toESM(xgplayer_flv_js, 1);
|
|
29
|
-
let
|
|
30
|
-
|
|
29
|
+
let hls_js_light = require("hls.js/light");
|
|
30
|
+
hls_js_light = __toESM(hls_js_light, 1);
|
|
31
31
|
/**
|
|
32
32
|
* 只判断 FLV 的**数据轨**连续性,不是内容声音检测。
|
|
33
33
|
*
|
|
@@ -498,6 +498,646 @@ function createKernelHealthAggregator(options) {
|
|
|
498
498
|
};
|
|
499
499
|
}
|
|
500
500
|
//#endregion
|
|
501
|
+
//#region src/kernels/hls-adapter.ts
|
|
502
|
+
/** 内部 HLS 生命周期适配器;恢复预算由调用者持有,不属于公开导出面。 */
|
|
503
|
+
var HlsAdapter = class {
|
|
504
|
+
media;
|
|
505
|
+
onError;
|
|
506
|
+
config;
|
|
507
|
+
create;
|
|
508
|
+
current = null;
|
|
509
|
+
generation = 0;
|
|
510
|
+
destroyed = false;
|
|
511
|
+
repair = null;
|
|
512
|
+
constructor(media, onError, config = {}, create = (options) => new hls_js_light.default(options)) {
|
|
513
|
+
this.media = media;
|
|
514
|
+
this.onError = onError;
|
|
515
|
+
this.config = config;
|
|
516
|
+
this.create = create;
|
|
517
|
+
}
|
|
518
|
+
/** 供现有清晰度、音轨和诊断接线访问当前内核;替换后旧实例不得再使用。 */
|
|
519
|
+
get kernel() {
|
|
520
|
+
return this.current;
|
|
521
|
+
}
|
|
522
|
+
/** 创建一次显式加载动作;不重建媒体元素,不在错误回调里自动重试。 */
|
|
523
|
+
start(url) {
|
|
524
|
+
if (this.destroyed) throw new Error("HlsAdapter is destroyed");
|
|
525
|
+
this.stop();
|
|
526
|
+
const generation = this.generation;
|
|
527
|
+
const hls = this.create({
|
|
528
|
+
...this.config,
|
|
529
|
+
autoStartLoad: false
|
|
530
|
+
});
|
|
531
|
+
this.current = hls;
|
|
532
|
+
let running = true;
|
|
533
|
+
let sourceLoaded = false;
|
|
534
|
+
let repairPosition = null;
|
|
535
|
+
const valid = () => this.current === hls && this.generation === generation && running;
|
|
536
|
+
hls.on(hls_js_light.default.Events.MEDIA_ATTACHED, () => {
|
|
537
|
+
if (!valid()) return;
|
|
538
|
+
if (!sourceLoaded) {
|
|
539
|
+
sourceLoaded = true;
|
|
540
|
+
hls.loadSource(url);
|
|
541
|
+
if (valid()) hls.startLoad();
|
|
542
|
+
} else if (repairPosition !== null) {
|
|
543
|
+
const position = repairPosition;
|
|
544
|
+
repairPosition = null;
|
|
545
|
+
hls.startLoad(position);
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
this.repair = () => {
|
|
549
|
+
if (this.current !== hls || this.generation !== generation) return;
|
|
550
|
+
running = true;
|
|
551
|
+
repairPosition = this.media.currentTime;
|
|
552
|
+
hls.recoverMediaError();
|
|
553
|
+
};
|
|
554
|
+
hls.on(hls_js_light.default.Events.ERROR, (_event, error) => {
|
|
555
|
+
if (!valid()) return;
|
|
556
|
+
if (error.fatal) {
|
|
557
|
+
running = false;
|
|
558
|
+
hls.stopLoad();
|
|
559
|
+
}
|
|
560
|
+
this.onError(error);
|
|
561
|
+
});
|
|
562
|
+
try {
|
|
563
|
+
hls.attachMedia(this.media);
|
|
564
|
+
} catch (error) {
|
|
565
|
+
if (this.current === hls && this.generation === generation) this.stop();
|
|
566
|
+
throw error;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
/** 仅由调度器发出的显式媒体修复动作;无内核时由调用方重拉。 */
|
|
570
|
+
recoverMediaError() {
|
|
571
|
+
if (this.destroyed || !this.current || !this.repair) return false;
|
|
572
|
+
this.repair();
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
/** 先使回调失效再销毁内核;媒体元素由播放器持有。 */
|
|
576
|
+
stop() {
|
|
577
|
+
this.generation += 1;
|
|
578
|
+
const hls = this.current;
|
|
579
|
+
this.current = null;
|
|
580
|
+
this.repair = null;
|
|
581
|
+
hls?.destroy();
|
|
582
|
+
}
|
|
583
|
+
/** 终止生命周期;重复销毁无副作用。 */
|
|
584
|
+
destroy() {
|
|
585
|
+
this.destroyed = true;
|
|
586
|
+
this.stop();
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
//#endregion
|
|
590
|
+
//#region src/kernels/owned-hls-plugin.ts
|
|
591
|
+
/** 生产 HLS 内部装配层;恢复预算由统一调度器持有,不作为宿主扩展口导出。 */
|
|
592
|
+
var OwnedHlsPlugin = class extends xgplayer.BasePlugin {
|
|
593
|
+
static get pluginName() {
|
|
594
|
+
return "HlsJsPlugin";
|
|
595
|
+
}
|
|
596
|
+
static get defaultConfig() {
|
|
597
|
+
return { hlsOpts: {} };
|
|
598
|
+
}
|
|
599
|
+
adapter = null;
|
|
600
|
+
statisticsTimer = null;
|
|
601
|
+
disposed = false;
|
|
602
|
+
urlDescriptor;
|
|
603
|
+
constructor(args) {
|
|
604
|
+
super(args);
|
|
605
|
+
this.player.handleSource = false;
|
|
606
|
+
}
|
|
607
|
+
/** 保留现有 core 对 HlsJsPlugin.hls 的访问方式。 */
|
|
608
|
+
get hls() {
|
|
609
|
+
return this.adapter?.kernel ?? void 0;
|
|
610
|
+
}
|
|
611
|
+
afterCreate() {
|
|
612
|
+
this.urlDescriptor = Object.getOwnPropertyDescriptor(this.player, "url");
|
|
613
|
+
Object.defineProperty(this.player, "url", {
|
|
614
|
+
configurable: true,
|
|
615
|
+
get: () => {
|
|
616
|
+
const media = this.player.video;
|
|
617
|
+
return media instanceof HTMLMediaElement ? media.src : void 0;
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
this.on(xgplayer.Events.URL_CHANGE, this.onUrlChange);
|
|
621
|
+
}
|
|
622
|
+
beforePlayerInit() {
|
|
623
|
+
this.register(this.playerConfig.url);
|
|
624
|
+
}
|
|
625
|
+
onUrlChange = (url) => {
|
|
626
|
+
if (url.startsWith("blob:")) return;
|
|
627
|
+
this.playerConfig.url = url;
|
|
628
|
+
this.register(url);
|
|
629
|
+
};
|
|
630
|
+
register(url) {
|
|
631
|
+
if (this.disposed || typeof url !== "string" || !url) return;
|
|
632
|
+
if (!this.adapter) {
|
|
633
|
+
const media = this.player.video;
|
|
634
|
+
if (!(media instanceof HTMLMediaElement)) return;
|
|
635
|
+
const hlsOpts = { ...this.config.hlsOpts };
|
|
636
|
+
if (hlsOpts.startPosition === void 0 && typeof this.playerConfig.startTime === "number") hlsOpts.startPosition = this.playerConfig.startTime;
|
|
637
|
+
this.adapter = new HlsAdapter(media, (error) => {
|
|
638
|
+
if (this.disposed) return;
|
|
639
|
+
this.player.emit("HLS_ERROR", {
|
|
640
|
+
errorType: error.type,
|
|
641
|
+
errorDetails: error.details,
|
|
642
|
+
errorFatal: error.fatal
|
|
643
|
+
});
|
|
644
|
+
}, hlsOpts);
|
|
645
|
+
}
|
|
646
|
+
this.clearStatistics();
|
|
647
|
+
this.adapter.start(url);
|
|
648
|
+
this.attachStatistics();
|
|
649
|
+
}
|
|
650
|
+
attachStatistics() {
|
|
651
|
+
const hls = this.hls;
|
|
652
|
+
if (!hls) return;
|
|
653
|
+
const valid = () => !this.disposed && this.hls === hls;
|
|
654
|
+
let speed = 0;
|
|
655
|
+
hls.on(hls_js_light.default.Events.FRAG_LOADED, (_event, data) => {
|
|
656
|
+
if (valid()) speed = data.frag.stats.loaded / 1e3;
|
|
657
|
+
});
|
|
658
|
+
hls.on(hls_js_light.default.Events.BUFFER_CODECS, (_event, tracks) => {
|
|
659
|
+
if (!valid()) return;
|
|
660
|
+
const audio = tracks.audio;
|
|
661
|
+
const video = tracks.video ?? tracks.audiovideo;
|
|
662
|
+
const info = {
|
|
663
|
+
videoDataRate: 0,
|
|
664
|
+
audioDataRate: 0,
|
|
665
|
+
hasAudio: Boolean(audio || tracks.audiovideo),
|
|
666
|
+
hasVideo: Boolean(video),
|
|
667
|
+
audioChannelCount: audio?.metadata?.channelCount ?? 0,
|
|
668
|
+
audioCodec: audio?.codec,
|
|
669
|
+
videoCodec: video?.codec,
|
|
670
|
+
width: video?.metadata?.width,
|
|
671
|
+
height: video?.metadata?.height
|
|
672
|
+
};
|
|
673
|
+
Object.assign(this.player, { mediainfo: info });
|
|
674
|
+
this.player.emit("media_info", info);
|
|
675
|
+
});
|
|
676
|
+
this.statisticsTimer = setInterval(() => {
|
|
677
|
+
if (!valid()) return;
|
|
678
|
+
this.player.emit("statistics_info", {
|
|
679
|
+
speed,
|
|
680
|
+
playerType: "HlsPlayer"
|
|
681
|
+
});
|
|
682
|
+
speed = 0;
|
|
683
|
+
}, 1e3);
|
|
684
|
+
}
|
|
685
|
+
clearStatistics() {
|
|
686
|
+
if (this.statisticsTimer !== null) clearInterval(this.statisticsTimer);
|
|
687
|
+
this.statisticsTimer = null;
|
|
688
|
+
}
|
|
689
|
+
/** 调度器停止当前内核,保留插件、播放器及媒体元素。 */
|
|
690
|
+
stopLoading() {
|
|
691
|
+
this.clearStatistics();
|
|
692
|
+
this.adapter?.stop();
|
|
693
|
+
}
|
|
694
|
+
/** 显式媒体修复;无可修复内核时返回 false,供调度器选择重拉。 */
|
|
695
|
+
recoverMediaError() {
|
|
696
|
+
return this.adapter?.recoverMediaError() ?? false;
|
|
697
|
+
}
|
|
698
|
+
destroy() {
|
|
699
|
+
if (this.disposed) return;
|
|
700
|
+
this.disposed = true;
|
|
701
|
+
this.clearStatistics();
|
|
702
|
+
this.off(xgplayer.Events.URL_CHANGE, this.onUrlChange);
|
|
703
|
+
const adapter = this.adapter;
|
|
704
|
+
this.adapter = null;
|
|
705
|
+
if (this.urlDescriptor) Object.defineProperty(this.player, "url", this.urlDescriptor);
|
|
706
|
+
else Reflect.deleteProperty(this.player, "url");
|
|
707
|
+
adapter?.destroy();
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
//#endregion
|
|
711
|
+
//#region src/page-fullscreen.ts
|
|
712
|
+
/**
|
|
713
|
+
* 接管锁定的 xgplayer 3.0.26 网页全屏按钮与 Esc;宿主确认后才应用本地状态。
|
|
714
|
+
* 不替换 CSS 全屏方法,以保留原生/旋转全屏内部退出 CSS 全屏的清理路径。
|
|
715
|
+
* 无 onRequest 时保留 inline 的本地行为。播放器插件初始化完成后调用。
|
|
716
|
+
*/
|
|
717
|
+
function attachPageFullscreen(player, options) {
|
|
718
|
+
const managed = options.onRequest !== void 0;
|
|
719
|
+
let available = !managed;
|
|
720
|
+
let destroyed = false;
|
|
721
|
+
const cssPlugin = player.getPlugin("cssFullscreen");
|
|
722
|
+
const keyboard = player.getPlugin("keyboard");
|
|
723
|
+
const escapeKey = keyboard?.keyCodeMap?.esc;
|
|
724
|
+
const originalEscape = escapeKey?.action;
|
|
725
|
+
const originalDisplay = cssPlugin?.root?.style.display ?? "";
|
|
726
|
+
const onChange = (active) => options.onChange(active);
|
|
727
|
+
player.on("cssFullscreen_change", onChange);
|
|
728
|
+
const request = (active) => {
|
|
729
|
+
if (!destroyed && available) options.onRequest?.(active);
|
|
730
|
+
};
|
|
731
|
+
const onButton = (_plugin, event) => {
|
|
732
|
+
if (available) cssPlugin?.emitUserAction?.(event, "switch_cssfullscreen", { cssfullscreen: player.isCssfullScreen });
|
|
733
|
+
request(!player.isCssfullScreen);
|
|
734
|
+
return false;
|
|
735
|
+
};
|
|
736
|
+
const onEscape = () => {
|
|
737
|
+
if (player.fullscreen) {
|
|
738
|
+
keyboard?.emitUserAction?.("keyup", "switch_fullscreen", {
|
|
739
|
+
prop: "fullscreen",
|
|
740
|
+
from: true,
|
|
741
|
+
to: false
|
|
742
|
+
});
|
|
743
|
+
player.exitFullscreen();
|
|
744
|
+
}
|
|
745
|
+
if (player.isCssfullScreen) {
|
|
746
|
+
keyboard?.emitUserAction?.("keyup", "switch_css_fullscreen", {
|
|
747
|
+
prop: "cssfullscreen",
|
|
748
|
+
from: true,
|
|
749
|
+
to: false
|
|
750
|
+
});
|
|
751
|
+
request(false);
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
const setAvailable = (next) => {
|
|
755
|
+
if (destroyed) return;
|
|
756
|
+
available = next;
|
|
757
|
+
if (managed && cssPlugin?.root) cssPlugin.root.style.display = next ? originalDisplay : "none";
|
|
758
|
+
};
|
|
759
|
+
if (managed) {
|
|
760
|
+
if (cssPlugin && !cssPlugin.config?.disable) player.usePluginHooks("cssFullscreen", "cssFullscreen_change", onButton);
|
|
761
|
+
if (escapeKey) escapeKey.action = onEscape;
|
|
762
|
+
setAvailable(false);
|
|
763
|
+
}
|
|
764
|
+
return {
|
|
765
|
+
/** 显式协商成功后开放按钮;撤销能力不伪造实际状态。 */
|
|
766
|
+
setAvailable,
|
|
767
|
+
/** 应用已确认状态;重复确认不重复操作 xgplayer。 */
|
|
768
|
+
setActive(active) {
|
|
769
|
+
if (destroyed || player.isCssfullScreen === active) return;
|
|
770
|
+
if (active) player.getCssFullscreen();
|
|
771
|
+
else player.exitCssFullscreen();
|
|
772
|
+
},
|
|
773
|
+
/** 移除本适配器的入口与监听,恢复插件原行为。 */
|
|
774
|
+
destroy() {
|
|
775
|
+
if (destroyed) return;
|
|
776
|
+
destroyed = true;
|
|
777
|
+
player.off("cssFullscreen_change", onChange);
|
|
778
|
+
if (managed) {
|
|
779
|
+
if (cssPlugin && !cssPlugin.config?.disable) player.removePluginHooks("cssFullscreen", "cssFullscreen_change", onButton);
|
|
780
|
+
if (escapeKey && escapeKey.action === onEscape && originalEscape !== void 0) escapeKey.action = originalEscape;
|
|
781
|
+
if (cssPlugin?.root) cssPlugin.root.style.display = originalDisplay;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
//#endregion
|
|
787
|
+
//#region src/recovery-controller.ts
|
|
788
|
+
const BACKOFF = [
|
|
789
|
+
1e3,
|
|
790
|
+
2e3,
|
|
791
|
+
4e3
|
|
792
|
+
];
|
|
793
|
+
/**
|
|
794
|
+
* 实例级内部调度器。执行器须在 abort 时同步停止旧动作;Promise 完成仅代表命令结束。
|
|
795
|
+
* 强播放证据由调用方判定后交 recovered;生产装配层负责映射公开事件。
|
|
796
|
+
*/
|
|
797
|
+
var RecoveryController = class {
|
|
798
|
+
execute;
|
|
799
|
+
onTransition;
|
|
800
|
+
active = null;
|
|
801
|
+
sequence = 0;
|
|
802
|
+
disposed = false;
|
|
803
|
+
suspended = false;
|
|
804
|
+
timer = null;
|
|
805
|
+
deadlineTimer = null;
|
|
806
|
+
events = [];
|
|
807
|
+
publishing = false;
|
|
808
|
+
actionDepth = 0;
|
|
809
|
+
constructor(execute, onTransition) {
|
|
810
|
+
this.execute = execute;
|
|
811
|
+
this.onTransition = onTransition;
|
|
812
|
+
}
|
|
813
|
+
/** 供装配层识别同步重入后的新 episode,不是公开播放器查询。 */
|
|
814
|
+
get activeEpisodeId() {
|
|
815
|
+
return this.active?.id ?? null;
|
|
816
|
+
}
|
|
817
|
+
/** 在途请求合并;切换会话前由调用方显式 cancel。 */
|
|
818
|
+
request(sessionId, input = {}) {
|
|
819
|
+
if (this.disposed) return null;
|
|
820
|
+
if (this.active) {
|
|
821
|
+
if (!this.active.finishing && this.active.sessionId === sessionId) {
|
|
822
|
+
if (input.strategy) this.active.strategy = input.strategy;
|
|
823
|
+
if (input.reason) this.active.reason = input.reason;
|
|
824
|
+
}
|
|
825
|
+
return this.active.id;
|
|
826
|
+
}
|
|
827
|
+
const episode = {
|
|
828
|
+
id: ++this.sequence,
|
|
829
|
+
sessionId,
|
|
830
|
+
deadline: performance.now() + 6e4,
|
|
831
|
+
startedAt: performance.now(),
|
|
832
|
+
strategy: input.strategy ?? "reconnect",
|
|
833
|
+
trigger: input.trigger ?? "error",
|
|
834
|
+
reason: input.reason,
|
|
835
|
+
attempt: 0,
|
|
836
|
+
token: null,
|
|
837
|
+
abort: null,
|
|
838
|
+
finishing: false
|
|
839
|
+
};
|
|
840
|
+
this.active = episode;
|
|
841
|
+
this.deadlineTimer = setTimeout(() => this.finish(episode, "failed"), 6e4);
|
|
842
|
+
this.publish(episode, "detected");
|
|
843
|
+
this.schedule(episode);
|
|
844
|
+
return episode.id;
|
|
845
|
+
}
|
|
846
|
+
/** 只接受当前动作的强证据,过期证据不得覆盖预算终态。 */
|
|
847
|
+
recovered(token) {
|
|
848
|
+
const episode = this.active;
|
|
849
|
+
if (!episode || episode.token !== token) return;
|
|
850
|
+
this.finish(episode, performance.now() >= episode.deadline ? "failed" : "recovered");
|
|
851
|
+
}
|
|
852
|
+
/** 第一动作尚未发出时原播放自行恢复,不制造一次成功动作。 */
|
|
853
|
+
naturalRecovered() {
|
|
854
|
+
if (this.active?.attempt === 0) this.finish(this.active, "cancelled", "natural_recovery");
|
|
855
|
+
}
|
|
856
|
+
/** 换源、用户暂停等取消原因由上层记录;取消不等同于预算耗尽。 */
|
|
857
|
+
cancel(outcome = "superseded") {
|
|
858
|
+
if (this.active) this.finish(this.active, "cancelled", outcome);
|
|
859
|
+
}
|
|
860
|
+
/** 后台暂停发动作,保留总截止时间和已消耗预算。 */
|
|
861
|
+
setSuspended(suspended) {
|
|
862
|
+
if (this.disposed || this.suspended === suspended) return;
|
|
863
|
+
this.suspended = suspended;
|
|
864
|
+
const episode = this.active;
|
|
865
|
+
if (!episode) return;
|
|
866
|
+
if (suspended) {
|
|
867
|
+
if (this.timer !== null) clearTimeout(this.timer);
|
|
868
|
+
this.timer = null;
|
|
869
|
+
episode.token = null;
|
|
870
|
+
const abort = episode.abort;
|
|
871
|
+
episode.abort = null;
|
|
872
|
+
abort?.abort();
|
|
873
|
+
} else this.schedule(episode);
|
|
874
|
+
}
|
|
875
|
+
/** 销毁后不再接受新恢复请求。 */
|
|
876
|
+
destroy() {
|
|
877
|
+
this.disposed = true;
|
|
878
|
+
this.cancel("destroyed");
|
|
879
|
+
}
|
|
880
|
+
schedule(episode) {
|
|
881
|
+
if (this.active !== episode) return;
|
|
882
|
+
const delay = BACKOFF[episode.attempt];
|
|
883
|
+
const remaining = episode.deadline - performance.now();
|
|
884
|
+
if (delay === void 0 || remaining <= 0) {
|
|
885
|
+
this.finish(episode, "failed");
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (!this.suspended) this.timer = setTimeout(() => this.begin(episode), Math.min(delay, remaining));
|
|
889
|
+
this.publish(episode, "backoff");
|
|
890
|
+
}
|
|
891
|
+
begin(episode) {
|
|
892
|
+
if (this.active !== episode) return;
|
|
893
|
+
this.timer = null;
|
|
894
|
+
const remaining = episode.deadline - performance.now();
|
|
895
|
+
if (remaining <= 0) {
|
|
896
|
+
this.finish(episode, "failed");
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
const token = Object.freeze({
|
|
900
|
+
episode: episode.id,
|
|
901
|
+
sessionId: episode.sessionId,
|
|
902
|
+
attempt: ++episode.attempt,
|
|
903
|
+
strategy: episode.strategy
|
|
904
|
+
});
|
|
905
|
+
const abort = new AbortController();
|
|
906
|
+
episode.issuedStrategy = token.strategy;
|
|
907
|
+
episode.token = token;
|
|
908
|
+
episode.abort = abort;
|
|
909
|
+
this.timer = setTimeout(() => this.failAttempt(episode, token), Math.min(15e3, remaining));
|
|
910
|
+
this.actionDepth += 1;
|
|
911
|
+
this.publish(episode, "attempting");
|
|
912
|
+
try {
|
|
913
|
+
const result = this.execute(token, abort.signal);
|
|
914
|
+
if (this.active === episode && episode.token === token) this.publish(episode, "validating");
|
|
915
|
+
Promise.resolve(result).catch(() => this.failAttempt(episode, token));
|
|
916
|
+
} catch {
|
|
917
|
+
this.failAttempt(episode, token);
|
|
918
|
+
} finally {
|
|
919
|
+
this.actionDepth -= 1;
|
|
920
|
+
this.flushEvents();
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
failAttempt(episode, token) {
|
|
924
|
+
if (this.active !== episode || episode.token !== token) return;
|
|
925
|
+
if (this.timer !== null) clearTimeout(this.timer);
|
|
926
|
+
this.timer = null;
|
|
927
|
+
episode.token = null;
|
|
928
|
+
const abort = episode.abort;
|
|
929
|
+
episode.abort = null;
|
|
930
|
+
abort?.abort();
|
|
931
|
+
this.schedule(episode);
|
|
932
|
+
}
|
|
933
|
+
finish(episode, phase, outcome) {
|
|
934
|
+
if (this.active !== episode || episode.finishing) return;
|
|
935
|
+
episode.finishing = true;
|
|
936
|
+
if (this.timer !== null) clearTimeout(this.timer);
|
|
937
|
+
if (this.deadlineTimer !== null) clearTimeout(this.deadlineTimer);
|
|
938
|
+
this.timer = null;
|
|
939
|
+
this.deadlineTimer = null;
|
|
940
|
+
episode.token = null;
|
|
941
|
+
if (phase !== "recovered") episode.abort?.abort();
|
|
942
|
+
this.active = null;
|
|
943
|
+
this.publish(episode, phase, outcome ?? (phase === "failed" ? performance.now() >= episode.deadline ? "timeout" : "attempts_exhausted" : void 0));
|
|
944
|
+
}
|
|
945
|
+
publish(episode, phase, outcome) {
|
|
946
|
+
this.events.push(Object.freeze({
|
|
947
|
+
episode: episode.id,
|
|
948
|
+
sessionId: episode.sessionId,
|
|
949
|
+
attempt: episode.attempt,
|
|
950
|
+
phase,
|
|
951
|
+
elapsedMs: Math.max(0, performance.now() - episode.startedAt),
|
|
952
|
+
maxAttempts: BACKOFF.length,
|
|
953
|
+
strategy: phase === "detected" || phase === "backoff" ? episode.strategy : episode.issuedStrategy ?? episode.strategy,
|
|
954
|
+
trigger: episode.trigger,
|
|
955
|
+
...episode.reason ? { reason: episode.reason } : {},
|
|
956
|
+
...outcome ? { outcome } : {}
|
|
957
|
+
}));
|
|
958
|
+
this.flushEvents();
|
|
959
|
+
}
|
|
960
|
+
flushEvents() {
|
|
961
|
+
if (this.publishing || this.actionDepth > 0) return;
|
|
962
|
+
this.publishing = true;
|
|
963
|
+
try {
|
|
964
|
+
while (this.events.length > 0) {
|
|
965
|
+
const event = this.events.shift();
|
|
966
|
+
if (event) try {
|
|
967
|
+
this.onTransition(event);
|
|
968
|
+
} catch {}
|
|
969
|
+
}
|
|
970
|
+
} finally {
|
|
971
|
+
this.publishing = false;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
};
|
|
975
|
+
//#endregion
|
|
976
|
+
//#region src/playback-recovery.ts
|
|
977
|
+
/** 播放意图、故障终态与调度预算之间的实例级协调;尚未公开导出。 */
|
|
978
|
+
var PlaybackRecovery = class {
|
|
979
|
+
sessionId;
|
|
980
|
+
emit;
|
|
981
|
+
display;
|
|
982
|
+
controller;
|
|
983
|
+
error = null;
|
|
984
|
+
desiredPlayback = false;
|
|
985
|
+
exhausted = false;
|
|
986
|
+
disposed = false;
|
|
987
|
+
revision = 0;
|
|
988
|
+
constructor(sessionId, execute, emit, display) {
|
|
989
|
+
this.sessionId = sessionId;
|
|
990
|
+
this.emit = emit;
|
|
991
|
+
this.display = display;
|
|
992
|
+
this.controller = new RecoveryController(execute, (event) => this.transition(event));
|
|
993
|
+
}
|
|
994
|
+
/** 执行器完成异步换源后复查最新意图,避免恢复用户已暂停的播放。 */
|
|
995
|
+
get playingIntent() {
|
|
996
|
+
return this.desiredPlayback && !this.disposed;
|
|
997
|
+
}
|
|
998
|
+
/** 已归一诊断进入此处;终态后的重复诊断不重新分配预算。 */
|
|
999
|
+
fault(error, sessionId = this.sessionId) {
|
|
1000
|
+
if (this.disposed || sessionId !== this.sessionId) return;
|
|
1001
|
+
const revision = ++this.revision;
|
|
1002
|
+
this.error = error;
|
|
1003
|
+
if (!error.retryable) {
|
|
1004
|
+
this.controller.cancel();
|
|
1005
|
+
if (this.disposed || revision !== this.revision) return;
|
|
1006
|
+
this.display({
|
|
1007
|
+
recovering: false,
|
|
1008
|
+
failed: true,
|
|
1009
|
+
action: this.action
|
|
1010
|
+
});
|
|
1011
|
+
} else if (this.desiredPlayback && !this.exhausted) this.request("error");
|
|
1012
|
+
else this.display({
|
|
1013
|
+
recovering: false,
|
|
1014
|
+
failed: true,
|
|
1015
|
+
action: this.action
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
/** 仅接显式播放/暂停意图;内核 stop 产生的 pause 不调用此方法。 */
|
|
1019
|
+
setPlayingIntent(playing) {
|
|
1020
|
+
if (this.disposed) return;
|
|
1021
|
+
this.revision += 1;
|
|
1022
|
+
this.desiredPlayback = playing;
|
|
1023
|
+
if (!playing) {
|
|
1024
|
+
this.exhausted = false;
|
|
1025
|
+
this.controller.cancel("user_paused");
|
|
1026
|
+
} else if (this.error?.retryable && !this.exhausted) this.request("manual");
|
|
1027
|
+
}
|
|
1028
|
+
/** 正常播放结束后不再接受自动恢复意图;不伪报用户暂停或恢复成功。 */
|
|
1029
|
+
endPlayback() {
|
|
1030
|
+
if (this.disposed) return;
|
|
1031
|
+
const revision = ++this.revision;
|
|
1032
|
+
this.desiredPlayback = false;
|
|
1033
|
+
this.exhausted = false;
|
|
1034
|
+
this.error = null;
|
|
1035
|
+
this.controller.cancel("superseded");
|
|
1036
|
+
if (this.disposed || revision !== this.revision) return;
|
|
1037
|
+
this.display({
|
|
1038
|
+
recovering: false,
|
|
1039
|
+
failed: false,
|
|
1040
|
+
action: "none"
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
/** 接受/合并手动重试;播放成功仍以当前动作的强证据为准。 */
|
|
1044
|
+
async retry() {
|
|
1045
|
+
if (this.disposed) throw new SentinelError((0, _video_lab_protocol.makePlayerError)("E_PLAYER_DESTROYED", _video_lab_protocol.PLAYER_DESTROYED_MESSAGE));
|
|
1046
|
+
if (this.error && !this.error.retryable) throw new SentinelError((0, _video_lab_protocol.makePlayerError)("E_METHOD_NOT_SUPPORTED", "当前源不能直接重试,请按动作提示处理"));
|
|
1047
|
+
this.desiredPlayback = true;
|
|
1048
|
+
this.revision += 1;
|
|
1049
|
+
this.exhausted = false;
|
|
1050
|
+
this.request("manual");
|
|
1051
|
+
}
|
|
1052
|
+
/** 接收测量或可见性产生的恢复意图,不要求捏造 raw error。 */
|
|
1053
|
+
requestRecovery(trigger) {
|
|
1054
|
+
if (this.disposed || !this.desiredPlayback || this.exhausted) return;
|
|
1055
|
+
if (this.error && !this.error.retryable) return;
|
|
1056
|
+
this.request(trigger);
|
|
1057
|
+
}
|
|
1058
|
+
/** 执行器提交当前动作的播放证据。 */
|
|
1059
|
+
recovered(token) {
|
|
1060
|
+
this.controller.recovered(token);
|
|
1061
|
+
}
|
|
1062
|
+
/** 原播放的强证据:无动作时结束等待,有在途动作时交其专属凭据判定。 */
|
|
1063
|
+
naturalRecovered() {
|
|
1064
|
+
if (this.disposed) return;
|
|
1065
|
+
if (this.controller.activeEpisodeId !== null) {
|
|
1066
|
+
this.controller.naturalRecovered();
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
this.error = null;
|
|
1070
|
+
this.exhausted = false;
|
|
1071
|
+
this.display({
|
|
1072
|
+
recovering: false,
|
|
1073
|
+
failed: false,
|
|
1074
|
+
action: "none"
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
/** 页面隐藏只暂停动作,不暂停总预算。 */
|
|
1078
|
+
setSuspended(hidden) {
|
|
1079
|
+
this.controller.setSuspended(hidden);
|
|
1080
|
+
}
|
|
1081
|
+
/** 换源先使新会话生效,再取消旧动作,防止旧回调覆盖新展示。 */
|
|
1082
|
+
setSession(sessionId) {
|
|
1083
|
+
if (this.disposed) return;
|
|
1084
|
+
this.revision += 1;
|
|
1085
|
+
this.sessionId = sessionId;
|
|
1086
|
+
this.error = null;
|
|
1087
|
+
this.exhausted = false;
|
|
1088
|
+
this.controller.cancel("source_changed");
|
|
1089
|
+
}
|
|
1090
|
+
/** 销毁后所有重试命令明确拒绝。 */
|
|
1091
|
+
destroy() {
|
|
1092
|
+
if (this.disposed) return;
|
|
1093
|
+
this.disposed = true;
|
|
1094
|
+
this.controller.destroy();
|
|
1095
|
+
}
|
|
1096
|
+
get action() {
|
|
1097
|
+
if (!this.error || this.error.retryable) return "retry";
|
|
1098
|
+
if (this.error.category === "autoplay") return "play";
|
|
1099
|
+
return this.error.category === "env" ? "none" : "replace-source";
|
|
1100
|
+
}
|
|
1101
|
+
request(trigger) {
|
|
1102
|
+
this.controller.request(this.sessionId, {
|
|
1103
|
+
trigger,
|
|
1104
|
+
strategy: trigger === "visibility" ? "visibility_reload" : this.error?.category === "media" ? "media_recovery" : "reconnect",
|
|
1105
|
+
...this.error ? { reason: this.error.code } : {}
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
transition(event) {
|
|
1109
|
+
const { episode, ...fact } = event;
|
|
1110
|
+
const revision = this.revision;
|
|
1111
|
+
if (event.sessionId === this.sessionId && event.phase === "failed") this.exhausted = true;
|
|
1112
|
+
if (event.sessionId === this.sessionId && (event.phase === "recovered" || event.outcome === "natural_recovery")) {
|
|
1113
|
+
this.error = null;
|
|
1114
|
+
this.exhausted = false;
|
|
1115
|
+
}
|
|
1116
|
+
this.emit({
|
|
1117
|
+
event: "recovery",
|
|
1118
|
+
payload: _video_lab_protocol.RecoveryPayloadSchema.parse({
|
|
1119
|
+
...fact,
|
|
1120
|
+
recoveryId: episode,
|
|
1121
|
+
...event.phase === "recovered" ? { validatedBy: "playing_position_advance" } : {}
|
|
1122
|
+
})
|
|
1123
|
+
});
|
|
1124
|
+
if (this.disposed || event.sessionId !== this.sessionId || revision !== this.revision) return;
|
|
1125
|
+
const current = this.controller.activeEpisodeId;
|
|
1126
|
+
if (current !== null && current !== episode) return;
|
|
1127
|
+
const recovering = [
|
|
1128
|
+
"detected",
|
|
1129
|
+
"backoff",
|
|
1130
|
+
"attempting",
|
|
1131
|
+
"validating"
|
|
1132
|
+
].includes(event.phase);
|
|
1133
|
+
this.display({
|
|
1134
|
+
recovering,
|
|
1135
|
+
failed: !recovering && (this.exhausted || this.error !== null),
|
|
1136
|
+
action: recovering || !this.exhausted && !this.error ? "none" : this.action
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
1140
|
+
//#endregion
|
|
501
1141
|
//#region src/plugins/autoplay-guard.ts
|
|
502
1142
|
/**
|
|
503
1143
|
* AutoplayGuard(P0)· 检测浏览器 autoplay 拒绝并告知业务
|
|
@@ -564,7 +1204,7 @@ function detectCompat(ua) {
|
|
|
564
1204
|
};
|
|
565
1205
|
return { incompatible: false };
|
|
566
1206
|
}
|
|
567
|
-
const DEFAULT_CONFIG$
|
|
1207
|
+
const DEFAULT_CONFIG$6 = { enabled: true };
|
|
568
1208
|
/**
|
|
569
1209
|
* CompatPlugin(P1)· 浏览器兼容性检测
|
|
570
1210
|
*
|
|
@@ -584,11 +1224,11 @@ var CompatPlugin = class extends xgplayer.BasePlugin {
|
|
|
584
1224
|
return "compat";
|
|
585
1225
|
}
|
|
586
1226
|
static get defaultConfig() {
|
|
587
|
-
return { ...DEFAULT_CONFIG$
|
|
1227
|
+
return { ...DEFAULT_CONFIG$6 };
|
|
588
1228
|
}
|
|
589
1229
|
get compatConfig() {
|
|
590
1230
|
return {
|
|
591
|
-
...DEFAULT_CONFIG$
|
|
1231
|
+
...DEFAULT_CONFIG$6,
|
|
592
1232
|
...this.config
|
|
593
1233
|
};
|
|
594
1234
|
}
|
|
@@ -606,121 +1246,8 @@ var CompatPlugin = class extends xgplayer.BasePlugin {
|
|
|
606
1246
|
}
|
|
607
1247
|
};
|
|
608
1248
|
//#endregion
|
|
609
|
-
//#region src/plugins/error-recovery.ts
|
|
610
|
-
const DEFAULT_CONFIG$7 = {
|
|
611
|
-
enabled: true,
|
|
612
|
-
maxRecoveries: 3
|
|
613
|
-
};
|
|
614
|
-
/** 原生 decode 卡死时向前跳过的秒数,跳过坏区间 */
|
|
615
|
-
const SKIP_SECONDS = .5;
|
|
616
|
-
/** 本插件专管的媒体错误码:坏 ts(#17)/ MP4 abort(#18) */
|
|
617
|
-
const RECOVERABLE_CODES = /* @__PURE__ */ new Set(["E_MEDIA_DECODE", "E_MEDIA_ABORTED"]);
|
|
618
|
-
/**
|
|
619
|
-
* ErrorRecoveryPlugin(P2)· 媒体错误自愈
|
|
620
|
-
*
|
|
621
|
-
* 解决的 pitfall:**#17**(点播 HLS 中间某个 ts 片段坏了,整段播放中断)、
|
|
622
|
-
* **#18**(MP4 中途网络 abort 无恢复)。
|
|
623
|
-
*
|
|
624
|
-
* 与 ReconnectPlugin 的分工(都听 `Events.ERROR`,按错误 **category** 划清):
|
|
625
|
-
* - `network` / `manifest` 类(断网、拉流失败)→ ReconnectPlugin 全量 `reload()` 重连。
|
|
626
|
-
* - `media` 类(解码 / abort)→ **本插件**做**局部**自愈,不全量 reload——坏 ts 全量 reload
|
|
627
|
-
* 会再次撞上同一个坏片段,得跳过而不是重来。
|
|
628
|
-
* Reconnect 已加守卫跳过 media 类,避免两者对同一错误重复动作(见 plugins/reconnect.ts)。
|
|
629
|
-
*
|
|
630
|
-
* 自愈手段:
|
|
631
|
-
* - `E_MEDIA_DECODE` 且是 HLS(有 hls.js 实例)→ `hls.recoverMediaError()`(hls.js 官方媒体
|
|
632
|
-
* 错误恢复,重建 buffer / 跳过坏片段)。
|
|
633
|
-
* - `E_MEDIA_ABORTED`(原生 MP4 fetch 被中断)→ `play()` 续播,恢复被中断的拉取。
|
|
634
|
-
* - `E_MEDIA_DECODE` 且无 hls(原生解码卡死)→ 小步 `seek` 前跳,跳过坏区间再 `play()`。
|
|
635
|
-
*
|
|
636
|
-
* `maxRecoveries` 次用尽后不再自愈,交给正常 error 流程(消费方展示错误 UI)。恢复成功
|
|
637
|
-
* (`playing`)清零。恢复生命周期由 create-player 注入并统一出为 `recovery` 事件。
|
|
638
|
-
*
|
|
639
|
-
* @example
|
|
640
|
-
* new Player({ el, plugins: [ErrorRecoveryPlugin], errorRecovery: { maxRecoveries: 3 } })
|
|
641
|
-
*/
|
|
642
|
-
var ErrorRecoveryPlugin = class extends xgplayer.BasePlugin {
|
|
643
|
-
static get pluginName() {
|
|
644
|
-
return "errorRecovery";
|
|
645
|
-
}
|
|
646
|
-
static get defaultConfig() {
|
|
647
|
-
return { ...DEFAULT_CONFIG$7 };
|
|
648
|
-
}
|
|
649
|
-
/** 本段已自愈次数,恢复成功清零 */
|
|
650
|
-
recoveries = 0;
|
|
651
|
-
get recoveryConfig() {
|
|
652
|
-
return {
|
|
653
|
-
...DEFAULT_CONFIG$7,
|
|
654
|
-
...this.config
|
|
655
|
-
};
|
|
656
|
-
}
|
|
657
|
-
get surface() {
|
|
658
|
-
return this.player;
|
|
659
|
-
}
|
|
660
|
-
afterCreate() {
|
|
661
|
-
if (!this.recoveryConfig.enabled) return;
|
|
662
|
-
this.on(xgplayer.Events.ERROR, this.handleError);
|
|
663
|
-
this.on(xgplayer.Events.PLAYING, this.handleResumed);
|
|
664
|
-
}
|
|
665
|
-
/** 用箭头函数保持 this,否则 off 匹配不上(见 CLAUDE.md 红线) */
|
|
666
|
-
handleError = (err) => {
|
|
667
|
-
const { code, category } = mapXgplayerError(err);
|
|
668
|
-
if (category !== "media") return;
|
|
669
|
-
if (!RECOVERABLE_CODES.has(code)) return;
|
|
670
|
-
if (this.recoveries >= this.recoveryConfig.maxRecoveries) {
|
|
671
|
-
this.recoveryConfig.onRecoveryFailed?.({ outcome: "attempts_exhausted" });
|
|
672
|
-
return;
|
|
673
|
-
}
|
|
674
|
-
this.recoveryConfig.onRecoveryDetected?.({
|
|
675
|
-
trigger: "error",
|
|
676
|
-
maxAttempts: this.recoveryConfig.maxRecoveries,
|
|
677
|
-
reason: code
|
|
678
|
-
});
|
|
679
|
-
this.recoveries += 1;
|
|
680
|
-
this.recoveryConfig.onRecoveryAttempting?.({
|
|
681
|
-
attempt: this.recoveries,
|
|
682
|
-
maxAttempts: this.recoveryConfig.maxRecoveries,
|
|
683
|
-
reason: code
|
|
684
|
-
});
|
|
685
|
-
this.recoveryConfig.onRecoveryValidating?.();
|
|
686
|
-
this.recover(code);
|
|
687
|
-
};
|
|
688
|
-
/** 恢复后重新进入播放 → 本段自愈成功,计数清零 */
|
|
689
|
-
handleResumed = () => {
|
|
690
|
-
this.recoveries = 0;
|
|
691
|
-
};
|
|
692
|
-
recover(code) {
|
|
693
|
-
const player = this.surface;
|
|
694
|
-
const hls = player.getPlugin?.("HlsJsPlugin")?.hls;
|
|
695
|
-
if (hls?.recoverMediaError) {
|
|
696
|
-
hls.recoverMediaError();
|
|
697
|
-
return;
|
|
698
|
-
}
|
|
699
|
-
if (code === "E_MEDIA_ABORTED") {
|
|
700
|
-
this.resumePlay();
|
|
701
|
-
return;
|
|
702
|
-
}
|
|
703
|
-
const t = numberOr$3(player.currentTime, 0);
|
|
704
|
-
player.seek?.(t + SKIP_SECONDS);
|
|
705
|
-
this.resumePlay();
|
|
706
|
-
}
|
|
707
|
-
resumePlay() {
|
|
708
|
-
const played = this.surface.play?.();
|
|
709
|
-
if (played && typeof played.catch === "function") played.catch(() => {});
|
|
710
|
-
}
|
|
711
|
-
destroy() {
|
|
712
|
-
this.off(xgplayer.Events.ERROR, this.handleError);
|
|
713
|
-
this.off(xgplayer.Events.PLAYING, this.handleResumed);
|
|
714
|
-
super.destroy?.();
|
|
715
|
-
}
|
|
716
|
-
};
|
|
717
|
-
/** 有限数值否则回退 */
|
|
718
|
-
function numberOr$3(value, fallback) {
|
|
719
|
-
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
720
|
-
}
|
|
721
|
-
//#endregion
|
|
722
1249
|
//#region src/plugins/fullscreen-guard.ts
|
|
723
|
-
const DEFAULT_CONFIG$
|
|
1250
|
+
const DEFAULT_CONFIG$5 = {
|
|
724
1251
|
enabled: true,
|
|
725
1252
|
isIOSWeChat: false
|
|
726
1253
|
};
|
|
@@ -747,13 +1274,13 @@ var FullscreenGuardPlugin = class extends xgplayer.BasePlugin {
|
|
|
747
1274
|
return "fullscreenGuard";
|
|
748
1275
|
}
|
|
749
1276
|
static get defaultConfig() {
|
|
750
|
-
return { ...DEFAULT_CONFIG$
|
|
1277
|
+
return { ...DEFAULT_CONFIG$5 };
|
|
751
1278
|
}
|
|
752
1279
|
patchedVideo = null;
|
|
753
1280
|
originalEnter;
|
|
754
1281
|
get guardConfig() {
|
|
755
1282
|
return {
|
|
756
|
-
...DEFAULT_CONFIG$
|
|
1283
|
+
...DEFAULT_CONFIG$5,
|
|
757
1284
|
...this.config
|
|
758
1285
|
};
|
|
759
1286
|
}
|
|
@@ -775,7 +1302,7 @@ var FullscreenGuardPlugin = class extends xgplayer.BasePlugin {
|
|
|
775
1302
|
};
|
|
776
1303
|
//#endregion
|
|
777
1304
|
//#region src/plugins/health-monitor.ts
|
|
778
|
-
const DEFAULT_CONFIG$
|
|
1305
|
+
const DEFAULT_CONFIG$4 = {
|
|
779
1306
|
enabled: true,
|
|
780
1307
|
stallThresholdMs: 2e3,
|
|
781
1308
|
pollIntervalMs: 1e3
|
|
@@ -826,7 +1353,7 @@ var HealthMonitorPlugin = class extends xgplayer.BasePlugin {
|
|
|
826
1353
|
return "healthMonitor";
|
|
827
1354
|
}
|
|
828
1355
|
static get defaultConfig() {
|
|
829
|
-
return { ...DEFAULT_CONFIG$
|
|
1356
|
+
return { ...DEFAULT_CONFIG$4 };
|
|
830
1357
|
}
|
|
831
1358
|
stalling = false;
|
|
832
1359
|
stallStartAt = 0;
|
|
@@ -857,7 +1384,7 @@ var HealthMonitorPlugin = class extends xgplayer.BasePlugin {
|
|
|
857
1384
|
pollTimer = null;
|
|
858
1385
|
get monitorConfig() {
|
|
859
1386
|
return {
|
|
860
|
-
...DEFAULT_CONFIG$
|
|
1387
|
+
...DEFAULT_CONFIG$4,
|
|
861
1388
|
...this.config
|
|
862
1389
|
};
|
|
863
1390
|
}
|
|
@@ -1152,7 +1679,7 @@ var MediaSessionPlugin = class extends xgplayer.BasePlugin {
|
|
|
1152
1679
|
};
|
|
1153
1680
|
//#endregion
|
|
1154
1681
|
//#region src/plugins/playable-state.ts
|
|
1155
|
-
const DEFAULT_CONFIG$
|
|
1682
|
+
const DEFAULT_CONFIG$3 = {
|
|
1156
1683
|
enabled: true,
|
|
1157
1684
|
bufferingDebounceMs: 300,
|
|
1158
1685
|
droppedRateThreshold: 15,
|
|
@@ -1253,7 +1780,7 @@ var PlayableStatePlugin = class extends xgplayer.BasePlugin {
|
|
|
1253
1780
|
return "playableState";
|
|
1254
1781
|
}
|
|
1255
1782
|
static get defaultConfig() {
|
|
1256
|
-
return { ...DEFAULT_CONFIG$
|
|
1783
|
+
return { ...DEFAULT_CONFIG$3 };
|
|
1257
1784
|
}
|
|
1258
1785
|
errored = false;
|
|
1259
1786
|
autoplayBlocked = false;
|
|
@@ -1262,6 +1789,7 @@ var PlayableStatePlugin = class extends xgplayer.BasePlugin {
|
|
|
1262
1789
|
buffering = false;
|
|
1263
1790
|
initialized = false;
|
|
1264
1791
|
degraded = false;
|
|
1792
|
+
recovery = null;
|
|
1265
1793
|
bufferingTimer = null;
|
|
1266
1794
|
pollTimer = null;
|
|
1267
1795
|
/** 上次采样看到的累计帧数 —— 用来算**窗口内**掉帧率,见 sampleFrames */
|
|
@@ -1270,9 +1798,10 @@ var PlayableStatePlugin = class extends xgplayer.BasePlugin {
|
|
|
1270
1798
|
overThresholdRuns = 0;
|
|
1271
1799
|
/** 上次回调出去的结论。只在变化时回调,靠它去重 */
|
|
1272
1800
|
lastReason = null;
|
|
1801
|
+
lastAction;
|
|
1273
1802
|
get stateConfig() {
|
|
1274
1803
|
return {
|
|
1275
|
-
...DEFAULT_CONFIG$
|
|
1804
|
+
...DEFAULT_CONFIG$3,
|
|
1276
1805
|
...this.config
|
|
1277
1806
|
};
|
|
1278
1807
|
}
|
|
@@ -1312,6 +1841,13 @@ var PlayableStatePlugin = class extends xgplayer.BasePlugin {
|
|
|
1312
1841
|
this.errored = value;
|
|
1313
1842
|
this.publish();
|
|
1314
1843
|
}
|
|
1844
|
+
/** 原子更新恢复与终态;强证据由恢复协调器判定。 */
|
|
1845
|
+
setRecovery(state) {
|
|
1846
|
+
this.recovery = { ...state };
|
|
1847
|
+
this.errored = state.failed;
|
|
1848
|
+
this.reconnecting = state.recovering;
|
|
1849
|
+
this.publish();
|
|
1850
|
+
}
|
|
1315
1851
|
/** 用箭头函数保持 this,否则 off 匹配不上(见 CLAUDE.md 红线) */
|
|
1316
1852
|
handleLoadedData = () => {
|
|
1317
1853
|
this.initialized = true;
|
|
@@ -1371,6 +1907,8 @@ var PlayableStatePlugin = class extends xgplayer.BasePlugin {
|
|
|
1371
1907
|
};
|
|
1372
1908
|
/** 当前命中的最严重的那一支 */
|
|
1373
1909
|
resolve() {
|
|
1910
|
+
if (this.recovery?.recovering) return "reconnecting";
|
|
1911
|
+
if (this.recovery?.failed) return "error";
|
|
1374
1912
|
const hit = {
|
|
1375
1913
|
error: this.errored,
|
|
1376
1914
|
frame_disconnected: false,
|
|
@@ -1386,11 +1924,14 @@ var PlayableStatePlugin = class extends xgplayer.BasePlugin {
|
|
|
1386
1924
|
}
|
|
1387
1925
|
publish() {
|
|
1388
1926
|
const reason = this.resolve();
|
|
1389
|
-
|
|
1927
|
+
const action = reason === "autoplay_blocked" ? "play" : this.recovery?.action;
|
|
1928
|
+
if (reason === this.lastReason && action === this.lastAction) return;
|
|
1390
1929
|
this.lastReason = reason;
|
|
1930
|
+
this.lastAction = action;
|
|
1391
1931
|
this.stateConfig.onPlayableChange?.({
|
|
1392
1932
|
reason,
|
|
1393
|
-
...REASON_TABLE[reason]
|
|
1933
|
+
...REASON_TABLE[reason],
|
|
1934
|
+
...action !== void 0 ? { action } : {}
|
|
1394
1935
|
});
|
|
1395
1936
|
}
|
|
1396
1937
|
clearBufferingTimer() {
|
|
@@ -1416,236 +1957,6 @@ function numberOr$1(value, fallback) {
|
|
|
1416
1957
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
1417
1958
|
}
|
|
1418
1959
|
//#endregion
|
|
1419
|
-
//#region src/plugins/reload-stream.ts
|
|
1420
|
-
/**
|
|
1421
|
-
* 让播放器重新拉一遍当前的流。
|
|
1422
|
-
*
|
|
1423
|
-
* ⚠️ **返回的 promise 要等** —— 如果调用方在重新拉流之后还要做别的事(典型是补一次 `play()`)。
|
|
1424
|
-
*
|
|
1425
|
-
* `reload()` 是同步拆重建,紧跟着 `play()` 能落到新的媒体上;
|
|
1426
|
-
* **`switchURL` 是异步的**,紧跟着调 `play()` 会被新的 load 打断
|
|
1427
|
-
*(chromium 报 `AbortError: The play() request was interrupted by a new load request`),
|
|
1428
|
-
* 而打断之后**没有人会再播一次**,播放器就停在 paused 上。
|
|
1429
|
-
*
|
|
1430
|
-
* 这不是推演:VisibilityPlugin 从 `reload()` 换到 `reloadStream()` 的第一版就是同步紧跟着
|
|
1431
|
-
* `play()`,单跑全绿,**跑满 worker 的全量里三次挂两次** —— 竞态按负载翻脸(#359)。
|
|
1432
|
-
*
|
|
1433
|
-
* @returns 重新拉流这件事**做完**之后 resolve;**永不 reject**(失败交给调用方的
|
|
1434
|
-
* error 事件 / 看门狗,那才是判定恢复与否的地方)
|
|
1435
|
-
*/
|
|
1436
|
-
function reloadStream(player) {
|
|
1437
|
-
const url = player?.config?.url;
|
|
1438
|
-
if (player?.switchURL && typeof url === "string" && url && !url.startsWith("blob:")) return player.switchURL(url).then(() => "switchURL", () => "switchURL");
|
|
1439
|
-
if (player?.reload) {
|
|
1440
|
-
player.reload();
|
|
1441
|
-
return Promise.resolve("reload");
|
|
1442
|
-
}
|
|
1443
|
-
return Promise.resolve("none");
|
|
1444
|
-
}
|
|
1445
|
-
//#endregion
|
|
1446
|
-
//#region src/plugins/reconnect.ts
|
|
1447
|
-
const DEFAULT_CONFIG$3 = {
|
|
1448
|
-
enabled: true,
|
|
1449
|
-
maxRetries: 3,
|
|
1450
|
-
baseDelayMs: 1e3,
|
|
1451
|
-
maxDelayMs: 15e3,
|
|
1452
|
-
resumeTimeoutMs: 15e3
|
|
1453
|
-
};
|
|
1454
|
-
/**
|
|
1455
|
-
* ReconnectPlugin(P0)· 断网 / 拉流失败自动重连
|
|
1456
|
-
*
|
|
1457
|
-
* 解决的 pitfall:**#1**(断网播放中断,无自动重连)、**#2**(换源失败无重试)。
|
|
1458
|
-
*
|
|
1459
|
-
* 状态机(ARCHITECTURE § 10.3):`idle → retrying → exhausted`,**防止无限重连**。
|
|
1460
|
-
* - 播放中遇到**可重试**的错误(网络类,`mapXgplayerError().retryable === true`)→
|
|
1461
|
-
* 进入 retrying,按指数退避 reload,每次发 `reconnectstart`。
|
|
1462
|
-
* - reload 后恢复播放(`playing`/`canplay`)→ 发 `reconnectsuccess`,回 idle,计数清零。
|
|
1463
|
-
* - reload 后 `resumeTimeoutMs` 内既没恢复也没报错(静默挂住)→ 判本轮失败,继续往前推。
|
|
1464
|
-
* 断网时的 MSE 路径就是这个形态(hls.js 的网络错误被吞),没有这一条状态机会永远停在 retrying。
|
|
1465
|
-
* - 重试用满 `maxRetries` 仍失败 → 进入 exhausted,发 `reconnectfailed`,停止自动重连。
|
|
1466
|
-
* - 手动 `reconnect({ resetCounter })` 可从 exhausted 重新拿满 maxRetries 次机会。
|
|
1467
|
-
*
|
|
1468
|
-
* 不可重试的错误(如 `E_MEDIA_NOT_SUPPORTED`)不触发重连,交给正常 error 流程。
|
|
1469
|
-
*
|
|
1470
|
-
* @example
|
|
1471
|
-
* new Player({
|
|
1472
|
-
* el, plugins: [ReconnectPlugin],
|
|
1473
|
-
* reconnect: { maxRetries: 3, onReconnectStart, onReconnectSuccess, onReconnectFailed },
|
|
1474
|
-
* })
|
|
1475
|
-
*/
|
|
1476
|
-
var ReconnectPlugin = class extends xgplayer.BasePlugin {
|
|
1477
|
-
static get pluginName() {
|
|
1478
|
-
return "reconnect";
|
|
1479
|
-
}
|
|
1480
|
-
static get defaultConfig() {
|
|
1481
|
-
return { ...DEFAULT_CONFIG$3 };
|
|
1482
|
-
}
|
|
1483
|
-
state = "idle";
|
|
1484
|
-
/** 当前这轮已经发起的重试次数(1..maxRetries)。回 idle / 成功时清零 */
|
|
1485
|
-
attempt = 0;
|
|
1486
|
-
reloadTimer = null;
|
|
1487
|
-
/** reload 发出后的看门狗:恢复信号迟迟不来就把这一轮判失败(见 resumeTimeoutMs) */
|
|
1488
|
-
resumeTimer = null;
|
|
1489
|
-
lastReason;
|
|
1490
|
-
get reconnectConfig() {
|
|
1491
|
-
return {
|
|
1492
|
-
...DEFAULT_CONFIG$3,
|
|
1493
|
-
...this.config
|
|
1494
|
-
};
|
|
1495
|
-
}
|
|
1496
|
-
afterCreate() {
|
|
1497
|
-
if (!this.reconnectConfig.enabled) return;
|
|
1498
|
-
this.on(xgplayer.Events.ERROR, this.handleError);
|
|
1499
|
-
this.on(xgplayer.Events.PLAYING, this.handleResumed);
|
|
1500
|
-
this.on(xgplayer.Events.CANPLAY, this.handleResumed);
|
|
1501
|
-
}
|
|
1502
|
-
/** 可重试错误 → 驱动状态机往前走一步 */
|
|
1503
|
-
handleError = (err) => {
|
|
1504
|
-
const mapped = mapXgplayerError(err);
|
|
1505
|
-
if (!mapped.retryable) return;
|
|
1506
|
-
if (mapped.category === "media") return;
|
|
1507
|
-
if (this.state === "exhausted") return;
|
|
1508
|
-
this.lastReason = mapped.code;
|
|
1509
|
-
if (this.state === "idle") this.reconnectConfig.onRecoveryDetected?.({
|
|
1510
|
-
trigger: "error",
|
|
1511
|
-
maxAttempts: this.reconnectConfig.maxRetries,
|
|
1512
|
-
reason: mapped.code
|
|
1513
|
-
});
|
|
1514
|
-
this.advance(mapped.code);
|
|
1515
|
-
};
|
|
1516
|
-
/** reload 后恢复播放 → 本轮重连成功 */
|
|
1517
|
-
handleResumed = () => {
|
|
1518
|
-
if (this.state !== "retrying") return;
|
|
1519
|
-
const attempts = this.attempt;
|
|
1520
|
-
this.clearReloadTimer();
|
|
1521
|
-
this.clearResumeTimer();
|
|
1522
|
-
this.state = "idle";
|
|
1523
|
-
this.attempt = 0;
|
|
1524
|
-
this.reconnectConfig.onReconnectSuccess?.({ attempts });
|
|
1525
|
-
};
|
|
1526
|
-
/**
|
|
1527
|
-
* 状态机前进一步:要么再发起一次退避 reload,要么用尽 → exhausted。
|
|
1528
|
-
* @param reason 触发本次的错误码;手动重连时为 undefined
|
|
1529
|
-
*/
|
|
1530
|
-
advance(reason, terminalOutcome = "attempts_exhausted") {
|
|
1531
|
-
const { maxRetries, baseDelayMs, maxDelayMs } = this.reconnectConfig;
|
|
1532
|
-
if (this.attempt >= maxRetries) {
|
|
1533
|
-
this.clearReloadTimer();
|
|
1534
|
-
this.clearResumeTimer();
|
|
1535
|
-
this.state = "exhausted";
|
|
1536
|
-
this.reconnectConfig.onRecoveryFailed?.({ outcome: terminalOutcome });
|
|
1537
|
-
this.reconnectConfig.onReconnectFailed?.({
|
|
1538
|
-
attempts: this.attempt,
|
|
1539
|
-
reason
|
|
1540
|
-
});
|
|
1541
|
-
return;
|
|
1542
|
-
}
|
|
1543
|
-
this.attempt += 1;
|
|
1544
|
-
this.state = "retrying";
|
|
1545
|
-
const nextDelayMs = Math.min(baseDelayMs * 2 ** (this.attempt - 1), maxDelayMs);
|
|
1546
|
-
this.reconnectConfig.onReconnectStart?.({
|
|
1547
|
-
attempt: this.attempt,
|
|
1548
|
-
maxAttempts: maxRetries,
|
|
1549
|
-
reason,
|
|
1550
|
-
nextDelayMs
|
|
1551
|
-
});
|
|
1552
|
-
this.reconnectConfig.onRecoveryAttempting?.({
|
|
1553
|
-
attempt: this.attempt,
|
|
1554
|
-
maxAttempts: maxRetries,
|
|
1555
|
-
reason
|
|
1556
|
-
});
|
|
1557
|
-
this.clearReloadTimer();
|
|
1558
|
-
this.clearResumeTimer();
|
|
1559
|
-
this.reloadTimer = setTimeout(() => {
|
|
1560
|
-
this.reloadTimer = null;
|
|
1561
|
-
this.reload();
|
|
1562
|
-
this.reconnectConfig.onRecoveryValidating?.({ timeoutMs: this.reconnectConfig.resumeTimeoutMs });
|
|
1563
|
-
this.resumeTimer = setTimeout(() => {
|
|
1564
|
-
this.resumeTimer = null;
|
|
1565
|
-
if (this.state !== "retrying") return;
|
|
1566
|
-
this.advance(this.lastReason, "timeout");
|
|
1567
|
-
}, this.reconnectConfig.resumeTimeoutMs);
|
|
1568
|
-
}, nextDelayMs);
|
|
1569
|
-
}
|
|
1570
|
-
/**
|
|
1571
|
-
* 触发 xgplayer 重新拉流。恢复与否由后续 playing / error 事件决定。
|
|
1572
|
-
*
|
|
1573
|
-
* 走 `switchURL` 还是 `reload` 由 {@link reloadStream} 决定 —— #192 的那条经验**抽出去了**,
|
|
1574
|
-
* 因为它被漏掉过一次:`VisibilityPlugin` 接着用 `reload()` 用了几个月(#359)。
|
|
1575
|
-
*/
|
|
1576
|
-
reload() {
|
|
1577
|
-
reloadStream(this.player);
|
|
1578
|
-
}
|
|
1579
|
-
/**
|
|
1580
|
-
* 手动重连(命令 `reconnect`)。create-player 的 handle.reconnect 调它。
|
|
1581
|
-
* @param resetCounter true = 计数清零,重新拿满 maxRetries 次机会(用户点"重试"的语义)
|
|
1582
|
-
*/
|
|
1583
|
-
manualReconnect(resetCounter = false) {
|
|
1584
|
-
if (!this.reconnectConfig.enabled) return;
|
|
1585
|
-
if (resetCounter) {
|
|
1586
|
-
this.attempt = 0;
|
|
1587
|
-
this.state = "idle";
|
|
1588
|
-
} else if (this.state === "exhausted") return;
|
|
1589
|
-
if (this.state === "idle") this.reconnectConfig.onRecoveryDetected?.({
|
|
1590
|
-
trigger: "manual",
|
|
1591
|
-
maxAttempts: this.reconnectConfig.maxRetries
|
|
1592
|
-
});
|
|
1593
|
-
this.advance(void 0);
|
|
1594
|
-
}
|
|
1595
|
-
/**
|
|
1596
|
-
* 换源复位:把状态机整个归零,**但不发起任何重连**。由 `create-player` 的 `load()` 调。
|
|
1597
|
-
*
|
|
1598
|
-
* ─── 为什么必须有这个方法 ─────────────────────────
|
|
1599
|
-
*
|
|
1600
|
-
* `load()` 已经在 ADR-074 的名义下把换源定义成「新会话」,并复位了 `sessionId` /
|
|
1601
|
-
* `qualityLevel` / `recoveryEvidence` / prematureVodEnd / MediaSession ——
|
|
1602
|
-
* **重连计数曾经是这份清单里唯一漏掉的一项**。后果是永久且完全静默的:
|
|
1603
|
-
*
|
|
1604
|
-
* 源 A 重试用尽 → `state = 'exhausted'` → 消费方按契约语义(`recoverable: false`)
|
|
1605
|
-
* 换一个可用地址 → 新源播得好好的,但 `handleResumed` 的第一行是
|
|
1606
|
-
* `if (this.state !== 'retrying') return`,**`exhausted` 走不到** → 状态机原地不动 →
|
|
1607
|
-
* 此后源 B 再断流,`handleError` 在 `exhausted` 分支直接 return:
|
|
1608
|
-
* **不重连、不发 `reconnectstart`、不发 `reconnectfailed`**。
|
|
1609
|
-
*
|
|
1610
|
-
* ─── 为什么不复用 `manualReconnect(true)` ─────────
|
|
1611
|
-
*
|
|
1612
|
-
* 那个方法复位完会**立刻 `advance()` 发起一次重连** —— 那是「用户点了重试」的语义。
|
|
1613
|
-
* 换源时新地址已经由 `switchURL` 送出去了,再重连一次是多余的一次拉流。
|
|
1614
|
-
*
|
|
1615
|
-
* ─── 为什么要清掉两个定时器 ───────────────────────
|
|
1616
|
-
*
|
|
1617
|
-
* 换源那一刻可能正有一轮退避 / 看门狗在途。它们属于**上一个源**的那次故障,
|
|
1618
|
-
* 留着会在新源上凭空推进一次状态机(甚至直接判失败)。
|
|
1619
|
-
*/
|
|
1620
|
-
resetForNewSource() {
|
|
1621
|
-
this.clearReloadTimer();
|
|
1622
|
-
this.clearResumeTimer();
|
|
1623
|
-
this.state = "idle";
|
|
1624
|
-
this.attempt = 0;
|
|
1625
|
-
this.lastReason = void 0;
|
|
1626
|
-
}
|
|
1627
|
-
clearReloadTimer() {
|
|
1628
|
-
if (this.reloadTimer !== null) {
|
|
1629
|
-
clearTimeout(this.reloadTimer);
|
|
1630
|
-
this.reloadTimer = null;
|
|
1631
|
-
}
|
|
1632
|
-
}
|
|
1633
|
-
clearResumeTimer() {
|
|
1634
|
-
if (this.resumeTimer !== null) {
|
|
1635
|
-
clearTimeout(this.resumeTimer);
|
|
1636
|
-
this.resumeTimer = null;
|
|
1637
|
-
}
|
|
1638
|
-
}
|
|
1639
|
-
destroy() {
|
|
1640
|
-
this.clearReloadTimer();
|
|
1641
|
-
this.clearResumeTimer();
|
|
1642
|
-
this.off(xgplayer.Events.ERROR, this.handleError);
|
|
1643
|
-
this.off(xgplayer.Events.PLAYING, this.handleResumed);
|
|
1644
|
-
this.off(xgplayer.Events.CANPLAY, this.handleResumed);
|
|
1645
|
-
super.destroy?.();
|
|
1646
|
-
}
|
|
1647
|
-
};
|
|
1648
|
-
//#endregion
|
|
1649
1960
|
//#region src/plugins/safe-destroy.ts
|
|
1650
1961
|
const DEFAULT_CONFIG$2 = { restoreRootStyle: true };
|
|
1651
1962
|
/**
|
|
@@ -1797,6 +2108,39 @@ function routeSource(source, env) {
|
|
|
1797
2108
|
throwPlayerError("E_MEDIA_NOT_SUPPORTED", "候选源为空,或类型都不受支持");
|
|
1798
2109
|
}
|
|
1799
2110
|
//#endregion
|
|
2111
|
+
//#region src/plugins/reload-stream.ts
|
|
2112
|
+
/**
|
|
2113
|
+
* 让播放器重新拉一遍当前的流。
|
|
2114
|
+
*
|
|
2115
|
+
* ⚠️ **返回的 promise 要等** —— 如果调用方在重新拉流之后还要做别的事(典型是补一次 `play()`)。
|
|
2116
|
+
*
|
|
2117
|
+
* `reload()` 是同步拆重建,紧跟着 `play()` 能落到新的媒体上;
|
|
2118
|
+
* **`switchURL` 是异步的**,紧跟着调 `play()` 会被新的 load 打断
|
|
2119
|
+
*(chromium 报 `AbortError: The play() request was interrupted by a new load request`),
|
|
2120
|
+
* 而打断之后**没有人会再播一次**,播放器就停在 paused 上。
|
|
2121
|
+
*
|
|
2122
|
+
* 这不是推演:VisibilityPlugin 从 `reload()` 换到 `reloadStream()` 的第一版就是同步紧跟着
|
|
2123
|
+
* `play()`,单跑全绿,**跑满 worker 的全量里三次挂两次** —— 竞态按负载翻脸(#359)。
|
|
2124
|
+
*
|
|
2125
|
+
* @returns 重新拉流这件事**做完**之后 resolve;**永不 reject**(失败交给调用方的
|
|
2126
|
+
* error 事件 / 看门狗,那才是判定恢复与否的地方)
|
|
2127
|
+
*/
|
|
2128
|
+
function reloadStream(player) {
|
|
2129
|
+
const url = player?.config?.url;
|
|
2130
|
+
if (player?.switchURL && typeof url === "string" && url && !url.startsWith("blob:")) try {
|
|
2131
|
+
return player.switchURL(url).then(() => "switchURL", () => "switchURL");
|
|
2132
|
+
} catch {
|
|
2133
|
+
return Promise.resolve("switchURL");
|
|
2134
|
+
}
|
|
2135
|
+
if (player?.reload) {
|
|
2136
|
+
try {
|
|
2137
|
+
player.reload();
|
|
2138
|
+
} catch {}
|
|
2139
|
+
return Promise.resolve("reload");
|
|
2140
|
+
}
|
|
2141
|
+
return Promise.resolve("none");
|
|
2142
|
+
}
|
|
2143
|
+
//#endregion
|
|
1800
2144
|
//#region src/plugins/visibility.ts
|
|
1801
2145
|
const DEFAULT_CONFIG$1 = {
|
|
1802
2146
|
enabled: true,
|
|
@@ -1892,6 +2236,10 @@ var VisibilityPlugin = class extends xgplayer.BasePlugin {
|
|
|
1892
2236
|
/** 探测:currentTime 没推进 或 仍 paused → 判定被系统冻死 → 重新拉流自愈 */
|
|
1893
2237
|
recoverIfStalled() {
|
|
1894
2238
|
if (numberOr(this.surface.currentTime, 0) > this.lastTime && this.surface.paused === false) return;
|
|
2239
|
+
if (this.visibilityConfig.requestRecovery) {
|
|
2240
|
+
this.visibilityConfig.requestRecovery();
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
1895
2243
|
this.visibilityConfig.onRecoveryDetected?.({
|
|
1896
2244
|
trigger: "visibility",
|
|
1897
2245
|
maxAttempts: 1
|
|
@@ -2066,111 +2414,86 @@ var ZIndexGuardPlugin = class extends xgplayer.BasePlugin {
|
|
|
2066
2414
|
}
|
|
2067
2415
|
};
|
|
2068
2416
|
//#endregion
|
|
2069
|
-
//#region src/recovery-
|
|
2417
|
+
//#region src/recovery-playback.ts
|
|
2070
2418
|
/**
|
|
2071
|
-
*
|
|
2072
|
-
*
|
|
2073
|
-
* 它不改变插件的重试或播放策略;只在 `playing` 后又观测到位置推进时记录恢复成功。
|
|
2419
|
+
* 观察一次恢复动作的强播放证据。仅 playing 后正常位置推进算成功;
|
|
2420
|
+
* seek、暂停和等待会重置基准。调用方负责把成功关联到当前动作凭据。
|
|
2074
2421
|
*/
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
this.player = player;
|
|
2083
|
-
this.emit = emit;
|
|
2084
|
-
player.on("playing", this.onPlaying);
|
|
2085
|
-
player.on("timeupdate", this.onTimeUpdate);
|
|
2086
|
-
}
|
|
2087
|
-
detected(input) {
|
|
2088
|
-
if (this.active) {
|
|
2089
|
-
if (this.active.strategy === input.strategy && this.active.trigger === input.trigger) return;
|
|
2090
|
-
this.cancel("superseded");
|
|
2091
|
-
}
|
|
2092
|
-
this.active = {
|
|
2093
|
-
recoveryId: this.nextRecoveryId++,
|
|
2094
|
-
...input,
|
|
2095
|
-
attempt: 1,
|
|
2096
|
-
maxAttempts: input.maxAttempts,
|
|
2097
|
-
validatingFrom: null,
|
|
2098
|
-
sawPlaying: false
|
|
2099
|
-
};
|
|
2100
|
-
this.emitPhase("detected");
|
|
2101
|
-
}
|
|
2102
|
-
attempting(strategy, input) {
|
|
2103
|
-
if (!this.active || this.active.strategy !== strategy) return;
|
|
2104
|
-
this.clearValidationTimer();
|
|
2105
|
-
this.active.attempt = input.attempt;
|
|
2106
|
-
this.active.maxAttempts = input.maxAttempts;
|
|
2107
|
-
this.active.reason = input.reason;
|
|
2108
|
-
this.active.validatingFrom = null;
|
|
2109
|
-
this.active.sawPlaying = false;
|
|
2110
|
-
this.emitPhase("attempting");
|
|
2111
|
-
}
|
|
2112
|
-
validating(strategy, timeoutMs) {
|
|
2113
|
-
if (!this.active || this.active.strategy !== strategy) return;
|
|
2114
|
-
this.clearValidationTimer();
|
|
2115
|
-
this.active.validatingFrom = this.player.currentTime;
|
|
2116
|
-
this.active.sawPlaying = false;
|
|
2117
|
-
this.emitPhase("validating");
|
|
2118
|
-
if (timeoutMs === void 0) return;
|
|
2119
|
-
this.validationTimer = setTimeout(() => this.failed(strategy, "timeout"), timeoutMs);
|
|
2120
|
-
}
|
|
2121
|
-
failed(strategy, outcome) {
|
|
2122
|
-
if (!this.active || this.active.strategy !== strategy) return;
|
|
2123
|
-
this.clearValidationTimer();
|
|
2124
|
-
this.emitPhase("failed", { outcome });
|
|
2125
|
-
this.active = null;
|
|
2126
|
-
}
|
|
2127
|
-
cancel(outcome) {
|
|
2128
|
-
if (!this.active) return;
|
|
2129
|
-
this.clearValidationTimer();
|
|
2130
|
-
this.emitPhase("cancelled", { outcome });
|
|
2131
|
-
this.active = null;
|
|
2132
|
-
}
|
|
2133
|
-
dispose() {
|
|
2134
|
-
this.cancel("destroyed");
|
|
2135
|
-
this.player.off("playing", this.onPlaying);
|
|
2136
|
-
this.player.off("timeupdate", this.onTimeUpdate);
|
|
2137
|
-
}
|
|
2138
|
-
onPlaying = () => {
|
|
2139
|
-
const active = this.active;
|
|
2140
|
-
if (!active || active.validatingFrom === null) return;
|
|
2141
|
-
active.sawPlaying = true;
|
|
2142
|
-
};
|
|
2143
|
-
onTimeUpdate = () => {
|
|
2144
|
-
const active = this.active;
|
|
2145
|
-
if (!active?.sawPlaying || active.validatingFrom === null) return;
|
|
2146
|
-
if (this.player.currentTime <= active.validatingFrom) return;
|
|
2147
|
-
this.clearValidationTimer();
|
|
2148
|
-
this.emitPhase("recovered", { validatedBy: "playing_position_advance" });
|
|
2149
|
-
this.active = null;
|
|
2422
|
+
function observeRecoveryPlayback(media, signal, recovered) {
|
|
2423
|
+
let baseline = null;
|
|
2424
|
+
let playingSeen = false;
|
|
2425
|
+
let disposed = false;
|
|
2426
|
+
const reset = () => {
|
|
2427
|
+
baseline = null;
|
|
2428
|
+
playingSeen = false;
|
|
2150
2429
|
};
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2430
|
+
const playing = () => {
|
|
2431
|
+
playingSeen = true;
|
|
2432
|
+
baseline = Number.isFinite(media.currentTime) ? media.currentTime : null;
|
|
2433
|
+
};
|
|
2434
|
+
const seeking = () => {
|
|
2435
|
+
baseline = null;
|
|
2436
|
+
};
|
|
2437
|
+
const seeked = () => {
|
|
2438
|
+
if (playingSeen && !media.paused && !media.seeking && media.readyState >= 2) baseline = Number.isFinite(media.currentTime) ? media.currentTime : null;
|
|
2439
|
+
};
|
|
2440
|
+
const resets = [
|
|
2441
|
+
"pause",
|
|
2442
|
+
"waiting",
|
|
2443
|
+
"emptied",
|
|
2444
|
+
"ended"
|
|
2445
|
+
];
|
|
2446
|
+
const dispose = () => {
|
|
2447
|
+
if (disposed) return;
|
|
2448
|
+
disposed = true;
|
|
2449
|
+
media.removeEventListener("playing", playing);
|
|
2450
|
+
media.removeEventListener("timeupdate", progress);
|
|
2451
|
+
media.removeEventListener("seeking", seeking);
|
|
2452
|
+
media.removeEventListener("seeked", seeked);
|
|
2453
|
+
for (const event of resets) media.removeEventListener(event, reset);
|
|
2454
|
+
signal.removeEventListener("abort", dispose);
|
|
2455
|
+
};
|
|
2456
|
+
const progress = () => {
|
|
2457
|
+
if (disposed || baseline === null || media.paused || media.seeking || media.readyState < 2) return;
|
|
2458
|
+
if (!Number.isFinite(media.currentTime) || media.currentTime <= baseline) return;
|
|
2459
|
+
dispose();
|
|
2460
|
+
recovered();
|
|
2461
|
+
};
|
|
2462
|
+
if (signal.aborted) return dispose;
|
|
2463
|
+
media.addEventListener("playing", playing);
|
|
2464
|
+
media.addEventListener("timeupdate", progress);
|
|
2465
|
+
media.addEventListener("seeking", seeking);
|
|
2466
|
+
media.addEventListener("seeked", seeked);
|
|
2467
|
+
for (const event of resets) media.addEventListener(event, reset);
|
|
2468
|
+
signal.addEventListener("abort", dispose, { once: true });
|
|
2469
|
+
return dispose;
|
|
2470
|
+
}
|
|
2471
|
+
/** 媒体修复会重新挂载 MSE;先订阅 canplay,避免对尚未挂载的元素提前 play。 */
|
|
2472
|
+
function waitForMediaRepair(media, signal, repair) {
|
|
2473
|
+
if (signal.aborted) return Promise.resolve(false);
|
|
2474
|
+
return new Promise((resolve, reject) => {
|
|
2475
|
+
const cleanup = () => {
|
|
2476
|
+
media.removeEventListener("canplay", ready);
|
|
2477
|
+
signal.removeEventListener("abort", abort);
|
|
2478
|
+
};
|
|
2479
|
+
const ready = () => {
|
|
2480
|
+
cleanup();
|
|
2481
|
+
resolve(true);
|
|
2482
|
+
};
|
|
2483
|
+
const abort = () => {
|
|
2484
|
+
cleanup();
|
|
2485
|
+
resolve(false);
|
|
2486
|
+
};
|
|
2487
|
+
media.addEventListener("canplay", ready, { once: true });
|
|
2488
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
2489
|
+
try {
|
|
2490
|
+
if (!repair()) abort();
|
|
2491
|
+
} catch (error) {
|
|
2492
|
+
cleanup();
|
|
2493
|
+
reject(error);
|
|
2494
|
+
}
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2174
2497
|
//#endregion
|
|
2175
2498
|
//#region src/runtime-profile.ts
|
|
2176
2499
|
/**
|
|
@@ -2349,7 +2672,7 @@ function observeMediaPlayRejection(player) {
|
|
|
2349
2672
|
};
|
|
2350
2673
|
}
|
|
2351
2674
|
/**
|
|
2352
|
-
* 拿 PlayableStatePlugin 实例。走 `getPlugin(pluginName)`,同 `handle.
|
|
2675
|
+
* 拿 PlayableStatePlugin 实例。走 `getPlugin(pluginName)`,同 `handle.retry` 的做法。
|
|
2353
2676
|
*
|
|
2354
2677
|
* 返回 undefined 的情况都是良性的:player 还没构造好、插件被 `enabled:false` 关掉、
|
|
2355
2678
|
* 或 xgplayer 换了 API。调用方一律用可选链,喂不进去就等于这一路信号没有,
|
|
@@ -2444,7 +2767,7 @@ function unsupportedRoutePayload(sessionId, source, env) {
|
|
|
2444
2767
|
};
|
|
2445
2768
|
}
|
|
2446
2769
|
function kernelPlugin(kernel) {
|
|
2447
|
-
if (kernel === "hls.js") return
|
|
2770
|
+
if (kernel === "hls.js") return OwnedHlsPlugin;
|
|
2448
2771
|
if (kernel === "flv.js") return xgplayer_flv_js.default;
|
|
2449
2772
|
return null;
|
|
2450
2773
|
}
|
|
@@ -2476,36 +2799,162 @@ function createPlayer(options) {
|
|
|
2476
2799
|
const config = (0, _video_lab_protocol.resolvePreset)(parsed.data.preset, parsed.data);
|
|
2477
2800
|
const normalized = normalizeSource(config.source);
|
|
2478
2801
|
let sessionId = newSessionId();
|
|
2802
|
+
let deliverySequence = 0;
|
|
2803
|
+
const makeDelivered = (event) => {
|
|
2804
|
+
deliverySequence += 1;
|
|
2805
|
+
return {
|
|
2806
|
+
event,
|
|
2807
|
+
producerSessionId: sessionId,
|
|
2808
|
+
deliveryId: `${sessionId}:${deliverySequence}`,
|
|
2809
|
+
occurredAtMs: Date.now(),
|
|
2810
|
+
sequence: deliverySequence
|
|
2811
|
+
};
|
|
2812
|
+
};
|
|
2813
|
+
const notify = (delivered) => {
|
|
2814
|
+
try {
|
|
2815
|
+
options.onEvent?.(delivered.event);
|
|
2816
|
+
} catch {}
|
|
2817
|
+
try {
|
|
2818
|
+
options.onDeliveredEvent?.(delivered);
|
|
2819
|
+
} catch {}
|
|
2820
|
+
};
|
|
2479
2821
|
let routed;
|
|
2480
2822
|
try {
|
|
2481
2823
|
routed = routeSource(normalized, env);
|
|
2482
2824
|
} catch (error) {
|
|
2483
|
-
if (error instanceof SentinelError && error.playerError.code === "E_MEDIA_NOT_SUPPORTED")
|
|
2825
|
+
if (error instanceof SentinelError && error.playerError.code === "E_MEDIA_NOT_SUPPORTED") notify(makeDelivered({
|
|
2484
2826
|
event: "sourceroute",
|
|
2485
2827
|
payload: unsupportedRoutePayload(sessionId, normalized, env)
|
|
2486
|
-
});
|
|
2828
|
+
}));
|
|
2487
2829
|
throw error;
|
|
2488
2830
|
}
|
|
2489
2831
|
let destroyed = false;
|
|
2832
|
+
let loadRevision = 0;
|
|
2833
|
+
let recovery = null;
|
|
2490
2834
|
let eventStreamReady = false;
|
|
2491
2835
|
const deferredEvents = [];
|
|
2836
|
+
const reportRecoveryFault = (event, eventSession) => {
|
|
2837
|
+
if (event.event === "error" && !destroyed && eventSession === sessionId && event.payload.code !== "E_SUBTITLE_LOAD_FAILED" && event.payload.code !== "E_DANMAKU_SEND_FAILED" && event.payload.code !== "E_METHOD_NOT_SUPPORTED") recovery?.fault(event.payload, eventSession);
|
|
2838
|
+
};
|
|
2492
2839
|
const emit = (event) => {
|
|
2493
2840
|
if (destroyed) return;
|
|
2841
|
+
const delivered = makeDelivered(event);
|
|
2494
2842
|
if (!eventStreamReady) {
|
|
2495
|
-
deferredEvents.push(
|
|
2843
|
+
deferredEvents.push(delivered);
|
|
2496
2844
|
return;
|
|
2497
2845
|
}
|
|
2498
|
-
|
|
2846
|
+
const eventSession = sessionId;
|
|
2847
|
+
notify(delivered);
|
|
2848
|
+
reportRecoveryFault(event, eventSession);
|
|
2499
2849
|
};
|
|
2500
2850
|
let playerRef = null;
|
|
2501
2851
|
const playableState = () => getPlayableState(playerRef);
|
|
2502
|
-
let recoveryEvidence = null;
|
|
2503
2852
|
const player = new xgplayer.default(buildXgplayerConfig({
|
|
2504
2853
|
...options,
|
|
2505
2854
|
config
|
|
2506
|
-
}, routed, normalized, emit, env, playableState, () =>
|
|
2855
|
+
}, routed, normalized, emit, env, playableState, () => recovery));
|
|
2856
|
+
let pageQueue = Promise.resolve();
|
|
2857
|
+
let pageRevision = 0;
|
|
2858
|
+
let applyingPageState = false;
|
|
2859
|
+
let pageExpired = false;
|
|
2860
|
+
const applyPageState = (active) => {
|
|
2861
|
+
applyingPageState = true;
|
|
2862
|
+
try {
|
|
2863
|
+
pageFullscreen.setActive(active);
|
|
2864
|
+
} finally {
|
|
2865
|
+
applyingPageState = false;
|
|
2866
|
+
}
|
|
2867
|
+
};
|
|
2868
|
+
const setPageFullscreen = (active) => {
|
|
2869
|
+
const revision = ++pageRevision;
|
|
2870
|
+
const operation = pageQueue.then(async () => {
|
|
2871
|
+
if (destroyed) throw new Error(_video_lab_protocol.PLAYER_DESTROYED_MESSAGE);
|
|
2872
|
+
if (pageExpired) throw new Error("网页全屏控制器已失效");
|
|
2873
|
+
let timer;
|
|
2874
|
+
const layout = Promise.resolve().then(() => options.pageFullscreen?.setActive(active)).then(() => {
|
|
2875
|
+
if (destroyed || pageExpired) {
|
|
2876
|
+
options.pageFullscreen?.dispose();
|
|
2877
|
+
throw new Error("网页全屏请求已失效");
|
|
2878
|
+
}
|
|
2879
|
+
});
|
|
2880
|
+
try {
|
|
2881
|
+
await Promise.race([layout, new Promise((_, reject) => {
|
|
2882
|
+
timer = setTimeout(() => {
|
|
2883
|
+
pageExpired = true;
|
|
2884
|
+
reject(/* @__PURE__ */ new Error("网页全屏确认超时"));
|
|
2885
|
+
}, 1e4);
|
|
2886
|
+
})]);
|
|
2887
|
+
if (revision === pageRevision) applyPageState(active);
|
|
2888
|
+
} catch (error) {
|
|
2889
|
+
options.pageFullscreen?.dispose();
|
|
2890
|
+
if (!destroyed) applyPageState(false);
|
|
2891
|
+
throw error;
|
|
2892
|
+
} finally {
|
|
2893
|
+
clearTimeout(timer);
|
|
2894
|
+
}
|
|
2895
|
+
});
|
|
2896
|
+
pageQueue = operation.catch(() => {});
|
|
2897
|
+
return operation;
|
|
2898
|
+
};
|
|
2899
|
+
const pageFullscreen = attachPageFullscreen(player, {
|
|
2900
|
+
...options.pageFullscreen ? { onRequest: (active) => {
|
|
2901
|
+
setPageFullscreen(active).catch(() => {});
|
|
2902
|
+
} } : {},
|
|
2903
|
+
onChange: (active) => {
|
|
2904
|
+
emit({
|
|
2905
|
+
event: "pagefullscreenchange",
|
|
2906
|
+
payload: { active }
|
|
2907
|
+
});
|
|
2908
|
+
if (!active && !applyingPageState && options.pageFullscreen && !options.pageFullscreenPending) setPageFullscreen(false).catch(() => {});
|
|
2909
|
+
}
|
|
2910
|
+
});
|
|
2911
|
+
const onPageEscape = (event) => {
|
|
2912
|
+
if (event.key === "Escape" && player.isCssfullScreen) setPageFullscreen(false).catch(() => {});
|
|
2913
|
+
};
|
|
2914
|
+
if (options.pageFullscreen && !options.pageFullscreenPending) {
|
|
2915
|
+
pageFullscreen.setAvailable(true);
|
|
2916
|
+
options.el.ownerDocument.addEventListener("keydown", onPageEscape);
|
|
2917
|
+
}
|
|
2507
2918
|
playerRef = player;
|
|
2508
|
-
|
|
2919
|
+
let desiredPlayback = config.autoplay ?? false;
|
|
2920
|
+
let recoveryCleanup = null;
|
|
2921
|
+
const stopRecoveryLoad = () => {
|
|
2922
|
+
if (routed.kernel === "hls.js") player.getPlugin("HlsJsPlugin")?.stopLoading();
|
|
2923
|
+
else if (routed.kernel === "flv.js") player.__flv__?.unload();
|
|
2924
|
+
else {
|
|
2925
|
+
player.pause();
|
|
2926
|
+
if (player.video instanceof HTMLMediaElement) {
|
|
2927
|
+
player.video.removeAttribute("src");
|
|
2928
|
+
player.video.load();
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
};
|
|
2932
|
+
recovery = new PlaybackRecovery(sessionId, async (token, signal) => {
|
|
2933
|
+
recoveryCleanup?.();
|
|
2934
|
+
const media = player.video;
|
|
2935
|
+
if (!(media instanceof HTMLMediaElement)) throw new Error("Recovery requires a media element");
|
|
2936
|
+
const cleanup = () => {
|
|
2937
|
+
unobserve();
|
|
2938
|
+
signal.removeEventListener("abort", abort);
|
|
2939
|
+
if (recoveryCleanup === cleanup) recoveryCleanup = null;
|
|
2940
|
+
};
|
|
2941
|
+
const abort = () => {
|
|
2942
|
+
if (recoveryCleanup !== cleanup) return;
|
|
2943
|
+
cleanup();
|
|
2944
|
+
stopRecoveryLoad();
|
|
2945
|
+
};
|
|
2946
|
+
const unobserve = observeRecoveryPlayback(media, signal, () => {
|
|
2947
|
+
cleanup();
|
|
2948
|
+
recovery?.recovered(token);
|
|
2949
|
+
});
|
|
2950
|
+
recoveryCleanup = cleanup;
|
|
2951
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
2952
|
+
const repaired = token.strategy === "media_recovery" && token.attempt === 1 && routed.kernel === "hls.js" && await waitForMediaRepair(media, signal, () => player.getPlugin("HlsJsPlugin")?.recoverMediaError() ?? false);
|
|
2953
|
+
if (signal.aborted || destroyed) return;
|
|
2954
|
+
if (!repaired) await player.switchURL(currentSrcUrl);
|
|
2955
|
+
if (!signal.aborted && !destroyed && recovery?.playingIntent) await player.play();
|
|
2956
|
+
}, emit, (state) => playableState()?.setRecovery(state));
|
|
2957
|
+
recovery.setPlayingIntent(desiredPlayback);
|
|
2509
2958
|
const listeners = [];
|
|
2510
2959
|
const listen = (name, handler) => {
|
|
2511
2960
|
listeners.push([name, handler]);
|
|
@@ -2520,7 +2969,11 @@ function createPlayer(options) {
|
|
|
2520
2969
|
const foreground = typeof document === "undefined" || document.visibilityState !== "hidden";
|
|
2521
2970
|
flvAudioHealth?.setActive(flvPlaybackActive && foreground);
|
|
2522
2971
|
};
|
|
2523
|
-
const onVisibilityChange = () =>
|
|
2972
|
+
const onVisibilityChange = () => {
|
|
2973
|
+
syncFlvAudioHealthActivity();
|
|
2974
|
+
recovery?.setSuspended(document.hidden);
|
|
2975
|
+
armStartupDeadline();
|
|
2976
|
+
};
|
|
2524
2977
|
if (typeof document !== "undefined") {
|
|
2525
2978
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
2526
2979
|
disposers.push(() => document.removeEventListener("visibilitychange", onVisibilityChange));
|
|
@@ -2591,15 +3044,19 @@ function createPlayer(options) {
|
|
|
2591
3044
|
payload: selectedRoutePayload(sessionId, normalized, routed, env)
|
|
2592
3045
|
});
|
|
2593
3046
|
emitContextChange();
|
|
2594
|
-
for (const
|
|
3047
|
+
for (const delivered of deferredEvents) {
|
|
3048
|
+
notify(delivered);
|
|
3049
|
+
reportRecoveryFault(delivered.event, delivered.producerSessionId);
|
|
3050
|
+
}
|
|
2595
3051
|
deferredEvents.length = 0;
|
|
2596
3052
|
const attachQualityListener = () => {
|
|
2597
3053
|
if (destroyed) return;
|
|
2598
3054
|
const hls = getHlsInstance(player);
|
|
2599
|
-
if (!hls?.on || !(hls.levels && hls.levels.length >= 2)) return;
|
|
2600
3055
|
if (hls === qualityListenerTarget) return;
|
|
2601
3056
|
detachQualityListener?.();
|
|
3057
|
+
if (!hls?.on || !(hls.levels && hls.levels.length >= 2)) return;
|
|
2602
3058
|
const onSwitched = (...args) => {
|
|
3059
|
+
if (destroyed || getHlsInstance(player) !== hls) return;
|
|
2603
3060
|
const data = args.at(-1);
|
|
2604
3061
|
const level = typeof data?.level === "number" ? data.level : hls.currentLevel;
|
|
2605
3062
|
if (level < 0) return;
|
|
@@ -2622,6 +3079,19 @@ function createPlayer(options) {
|
|
|
2622
3079
|
};
|
|
2623
3080
|
};
|
|
2624
3081
|
hlsCleanups.push(() => detachQualityListener?.());
|
|
3082
|
+
let naturalProof = null;
|
|
3083
|
+
const observeNaturalPlayback = () => {
|
|
3084
|
+
naturalProof?.abort();
|
|
3085
|
+
naturalProof = null;
|
|
3086
|
+
const media = player.video;
|
|
3087
|
+
if (!(media instanceof HTMLMediaElement) || destroyed) return;
|
|
3088
|
+
naturalProof = new AbortController();
|
|
3089
|
+
observeRecoveryPlayback(media, naturalProof.signal, () => {
|
|
3090
|
+
recovery?.naturalRecovered();
|
|
3091
|
+
observeNaturalPlayback();
|
|
3092
|
+
});
|
|
3093
|
+
};
|
|
3094
|
+
disposers.push(() => naturalProof?.abort());
|
|
2625
3095
|
let sawFirstFrame = false;
|
|
2626
3096
|
let sawPlaybackStart = false;
|
|
2627
3097
|
let sawPlaying = false;
|
|
@@ -2630,6 +3100,27 @@ function createPlayer(options) {
|
|
|
2630
3100
|
let lastPlaybackPosition = null;
|
|
2631
3101
|
let seeking = false;
|
|
2632
3102
|
let prematureVodEnd = null;
|
|
3103
|
+
let startupTimer = null;
|
|
3104
|
+
const clearStartupDeadline = () => {
|
|
3105
|
+
if (startupTimer !== null) clearTimeout(startupTimer);
|
|
3106
|
+
startupTimer = null;
|
|
3107
|
+
};
|
|
3108
|
+
const armStartupDeadline = () => {
|
|
3109
|
+
clearStartupDeadline();
|
|
3110
|
+
if (!desiredPlayback || sawFirstFrame || seeking || document.hidden || destroyed) return;
|
|
3111
|
+
startupTimer = setTimeout(() => {
|
|
3112
|
+
startupTimer = null;
|
|
3113
|
+
recovery?.requestRecovery("startup_timeout");
|
|
3114
|
+
}, 15e3);
|
|
3115
|
+
};
|
|
3116
|
+
const setPlayingIntent = (playing) => {
|
|
3117
|
+
desiredPlayback = playing;
|
|
3118
|
+
recovery?.setPlayingIntent(playing);
|
|
3119
|
+
armStartupDeadline();
|
|
3120
|
+
};
|
|
3121
|
+
disposers.push(clearStartupDeadline);
|
|
3122
|
+
recovery.setSuspended(document.hidden);
|
|
3123
|
+
armStartupDeadline();
|
|
2633
3124
|
const resetPrematureVodEndState = () => {
|
|
2634
3125
|
sawFirstFrame = false;
|
|
2635
3126
|
sawPlaybackStart = false;
|
|
@@ -2663,6 +3154,7 @@ function createPlayer(options) {
|
|
|
2663
3154
|
});
|
|
2664
3155
|
});
|
|
2665
3156
|
listen("play", () => {
|
|
3157
|
+
observeNaturalPlayback();
|
|
2666
3158
|
sawPlaybackStart = true;
|
|
2667
3159
|
flvPlaybackActive = true;
|
|
2668
3160
|
syncFlvAudioHealthActivity();
|
|
@@ -2686,25 +3178,25 @@ function createPlayer(options) {
|
|
|
2686
3178
|
});
|
|
2687
3179
|
});
|
|
2688
3180
|
listen("ended", () => {
|
|
2689
|
-
if (prematureVodEnd !== null) {
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
3181
|
+
if (prematureVodEnd !== null) emit({
|
|
3182
|
+
event: "error",
|
|
3183
|
+
payload: (0, _video_lab_protocol.makePlayerError)("E_MEDIA_DECODE", "点播媒体在达到结尾前异常跳转并结束", {
|
|
3184
|
+
kind: "premature_vod_end",
|
|
3185
|
+
...prematureVodEnd
|
|
3186
|
+
})
|
|
3187
|
+
});
|
|
3188
|
+
else if (!normalized.live && routed.kernel === "native" && safeNumber(player.duration) > 0 && sawPlaybackStart && sawStartupWaiting && !sawPlaying && !sawFirstFrame && !sawNativeError) emit({
|
|
3189
|
+
event: "error",
|
|
3190
|
+
payload: (0, _video_lab_protocol.makePlayerError)("E_MEDIA_DECODE", "点播媒体未解出首帧即结束", {
|
|
3191
|
+
kind: "initial_frame_decode_failure",
|
|
3192
|
+
duration: safeNumber(player.duration),
|
|
3193
|
+
endTime: safeNumber(player.currentTime)
|
|
3194
|
+
})
|
|
3195
|
+
});
|
|
3196
|
+
else if (sawFirstFrame && !normalized.live && !config.loop) {
|
|
3197
|
+
desiredPlayback = false;
|
|
3198
|
+
clearStartupDeadline();
|
|
3199
|
+
recovery?.endPlayback();
|
|
2708
3200
|
}
|
|
2709
3201
|
emit({
|
|
2710
3202
|
event: "ended",
|
|
@@ -2745,6 +3237,7 @@ function createPlayer(options) {
|
|
|
2745
3237
|
});
|
|
2746
3238
|
listen("seeking", () => {
|
|
2747
3239
|
seeking = true;
|
|
3240
|
+
clearStartupDeadline();
|
|
2748
3241
|
lastPlaybackPosition = safeNumber(player.currentTime);
|
|
2749
3242
|
prematureVodEnd = null;
|
|
2750
3243
|
emit({
|
|
@@ -2753,6 +3246,8 @@ function createPlayer(options) {
|
|
|
2753
3246
|
});
|
|
2754
3247
|
});
|
|
2755
3248
|
listen("seeked", () => {
|
|
3249
|
+
seeking = false;
|
|
3250
|
+
armStartupDeadline();
|
|
2756
3251
|
emit({
|
|
2757
3252
|
event: "seeked",
|
|
2758
3253
|
payload: { time: safeNumber(player.currentTime) }
|
|
@@ -2765,6 +3260,7 @@ function createPlayer(options) {
|
|
|
2765
3260
|
const fvt = log.fvt;
|
|
2766
3261
|
if (typeof fvt !== "number" || !Number.isFinite(fvt)) return;
|
|
2767
3262
|
sawFirstFrame = true;
|
|
3263
|
+
clearStartupDeadline();
|
|
2768
3264
|
emit({
|
|
2769
3265
|
event: "firstframe",
|
|
2770
3266
|
payload: { fvt }
|
|
@@ -2781,6 +3277,7 @@ function createPlayer(options) {
|
|
|
2781
3277
|
listen("user_action", (raw) => {
|
|
2782
3278
|
const payload = (0, _video_lab_protocol.normalizeUserAction)(raw);
|
|
2783
3279
|
if (payload === null) return;
|
|
3280
|
+
if (payload.action === "switch_play_pause" && typeof payload.to === "boolean") setPlayingIntent(!payload.to);
|
|
2784
3281
|
emit({
|
|
2785
3282
|
event: "useraction",
|
|
2786
3283
|
payload
|
|
@@ -2794,7 +3291,6 @@ function createPlayer(options) {
|
|
|
2794
3291
|
event: "error",
|
|
2795
3292
|
payload: mapped
|
|
2796
3293
|
});
|
|
2797
|
-
playableState()?.setError(true);
|
|
2798
3294
|
});
|
|
2799
3295
|
/**
|
|
2800
3296
|
* FLV 内核诊断 → 契约 error 流(#403)。
|
|
@@ -2874,12 +3370,11 @@ function createPlayer(options) {
|
|
|
2874
3370
|
event: "error",
|
|
2875
3371
|
payload: mapped
|
|
2876
3372
|
});
|
|
2877
|
-
playableState()?.setError(true);
|
|
2878
3373
|
};
|
|
2879
3374
|
target.on("error", onError);
|
|
2880
3375
|
detachFlvDiagnostics = () => {
|
|
2881
|
-
target.off?.("error", onError);
|
|
2882
3376
|
detachFlvDiagnostics = null;
|
|
3377
|
+
if (target._emitter !== null) target.off?.("error", onError);
|
|
2883
3378
|
};
|
|
2884
3379
|
};
|
|
2885
3380
|
hlsCleanups.push(() => detachFlvDiagnostics?.());
|
|
@@ -2975,14 +3470,15 @@ function createPlayer(options) {
|
|
|
2975
3470
|
};
|
|
2976
3471
|
target.on("media_info", onMediaInfo);
|
|
2977
3472
|
detachFlvAudioHealth = () => {
|
|
2978
|
-
|
|
3473
|
+
detachFlvAudioHealth = () => {};
|
|
3474
|
+
if (target._emitter !== null) target.off?.("media_info", onMediaInfo);
|
|
2979
3475
|
for (const cleanup of sourceBufferCleanups) cleanup();
|
|
2980
3476
|
sourceBufferCleanups.length = 0;
|
|
2981
3477
|
flvAudioHealth?.dispose();
|
|
2982
3478
|
flvAudioHealth = null;
|
|
2983
3479
|
};
|
|
2984
|
-
hlsCleanups.push(() => detachFlvAudioHealth());
|
|
2985
3480
|
};
|
|
3481
|
+
hlsCleanups.push(() => detachFlvAudioHealth());
|
|
2986
3482
|
{
|
|
2987
3483
|
const holder = player;
|
|
2988
3484
|
let current = holder.__flv__;
|
|
@@ -2995,6 +3491,10 @@ function createPlayer(options) {
|
|
|
2995
3491
|
get: () => current,
|
|
2996
3492
|
set: (v) => {
|
|
2997
3493
|
current = v;
|
|
3494
|
+
if (v === void 0 || v === null) {
|
|
3495
|
+
detachFlvDiagnostics?.();
|
|
3496
|
+
detachFlvAudioHealth();
|
|
3497
|
+
}
|
|
2998
3498
|
if (v !== void 0 && v !== null) {
|
|
2999
3499
|
attachFlvDiagnostics(v);
|
|
3000
3500
|
attachFlvAudioHealth(v);
|
|
@@ -3063,7 +3563,6 @@ function createPlayer(options) {
|
|
|
3063
3563
|
event: "error",
|
|
3064
3564
|
payload: mapped
|
|
3065
3565
|
});
|
|
3066
|
-
playableState()?.setError(true);
|
|
3067
3566
|
});
|
|
3068
3567
|
return {
|
|
3069
3568
|
get destroyed() {
|
|
@@ -3071,6 +3570,7 @@ function createPlayer(options) {
|
|
|
3071
3570
|
},
|
|
3072
3571
|
async play() {
|
|
3073
3572
|
if (destroyed) return;
|
|
3573
|
+
setPlayingIntent(true);
|
|
3074
3574
|
try {
|
|
3075
3575
|
await player.play();
|
|
3076
3576
|
} catch (err) {
|
|
@@ -3079,6 +3579,7 @@ function createPlayer(options) {
|
|
|
3079
3579
|
},
|
|
3080
3580
|
pause() {
|
|
3081
3581
|
if (destroyed) return;
|
|
3582
|
+
setPlayingIntent(false);
|
|
3082
3583
|
player.pause();
|
|
3083
3584
|
},
|
|
3084
3585
|
seek(time) {
|
|
@@ -3097,6 +3598,12 @@ function createPlayer(options) {
|
|
|
3097
3598
|
if (destroyed) return;
|
|
3098
3599
|
player.playbackRate = clamp(rate, .25, 4);
|
|
3099
3600
|
},
|
|
3601
|
+
setPageFullscreen,
|
|
3602
|
+
syncPageFullscreen(active, available) {
|
|
3603
|
+
pageRevision += 1;
|
|
3604
|
+
pageFullscreen.setAvailable(available);
|
|
3605
|
+
applyPageState(active);
|
|
3606
|
+
},
|
|
3100
3607
|
async enterFullscreen() {
|
|
3101
3608
|
if (destroyed) return;
|
|
3102
3609
|
await player.getFullscreen();
|
|
@@ -3180,9 +3687,20 @@ function createPlayer(options) {
|
|
|
3180
3687
|
if (nextRouted.kernel !== routed.kernel) throwPlayerError("E_METHOD_NOT_SUPPORTED", `换源需要从 ${routed.kernel} 内核切到 ${nextRouted.kernel},运行时无法切换内核。请销毁当前 player 并用新的 source 重建。`);
|
|
3181
3688
|
const constructionChanges = sourceConstructionChanges(normalized, nextNormalized);
|
|
3182
3689
|
if (constructionChanges.length > 0) throwPlayerError("E_METHOD_NOT_SUPPORTED", `换源不能改变构造期 source 配置(${constructionChanges.join("、")})。播放器仍使用创建时的直播、HLS 与字幕插件配置。请销毁当前 player 并用新的 source 重建。`);
|
|
3183
|
-
|
|
3690
|
+
const revision = ++loadRevision;
|
|
3691
|
+
naturalProof?.abort();
|
|
3692
|
+
recovery?.setSession(nextSessionId);
|
|
3693
|
+
if (destroyed || revision !== loadRevision) return;
|
|
3694
|
+
playableState()?.setRecovery({
|
|
3695
|
+
recovering: false,
|
|
3696
|
+
failed: false,
|
|
3697
|
+
action: "none"
|
|
3698
|
+
});
|
|
3699
|
+
if (destroyed || revision !== loadRevision) return;
|
|
3184
3700
|
resetPrematureVodEndState();
|
|
3185
3701
|
sessionId = nextSessionId;
|
|
3702
|
+
deliverySequence = 0;
|
|
3703
|
+
armStartupDeadline();
|
|
3186
3704
|
currentSrcUrl = nextRouted.chosen.url;
|
|
3187
3705
|
currentMediaType = nextRouted.chosen.type;
|
|
3188
3706
|
currentQualityLevel = null;
|
|
@@ -3190,14 +3708,16 @@ function createPlayer(options) {
|
|
|
3190
3708
|
event: "sourceroute",
|
|
3191
3709
|
payload: selectedRoutePayload(sessionId, nextNormalized, nextRouted, env)
|
|
3192
3710
|
});
|
|
3711
|
+
if (destroyed || revision !== loadRevision) return;
|
|
3193
3712
|
emitContextChange();
|
|
3713
|
+
if (destroyed || revision !== loadRevision) return;
|
|
3194
3714
|
await player.switchURL(nextRouted.chosen.url);
|
|
3715
|
+
if (destroyed || revision !== loadRevision) return;
|
|
3195
3716
|
getMediaSession(player)?.setMetadata(nextNormalized.metadata);
|
|
3196
|
-
getReconnectPlugin(player)?.resetForNewSource?.();
|
|
3197
3717
|
},
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3718
|
+
async retry() {
|
|
3719
|
+
clearStartupDeadline();
|
|
3720
|
+
await recovery?.retry();
|
|
3201
3721
|
},
|
|
3202
3722
|
getPlaybackContext() {
|
|
3203
3723
|
return readPlaybackContext();
|
|
@@ -3215,8 +3735,12 @@ function createPlayer(options) {
|
|
|
3215
3735
|
*/
|
|
3216
3736
|
destroy() {
|
|
3217
3737
|
if (destroyed) return;
|
|
3218
|
-
|
|
3738
|
+
recovery?.destroy();
|
|
3739
|
+
recoveryCleanup?.();
|
|
3219
3740
|
destroyed = true;
|
|
3741
|
+
options.pageFullscreen?.dispose();
|
|
3742
|
+
pageFullscreen.destroy();
|
|
3743
|
+
options.el.ownerDocument.removeEventListener("keydown", onPageEscape);
|
|
3220
3744
|
for (const [name, handler] of listeners) player.off(name, handler);
|
|
3221
3745
|
listeners.length = 0;
|
|
3222
3746
|
for (const cleanup of hlsCleanups) cleanup();
|
|
@@ -3280,14 +3804,12 @@ function extendInstanceI18n(player, locale, lang) {
|
|
|
3280
3804
|
if (!instanceI18n) return;
|
|
3281
3805
|
xgplayer.I18N.extend(entries, instanceI18n);
|
|
3282
3806
|
}
|
|
3283
|
-
function buildXgplayerConfig(options, routed, normalized, emit, env, playableState,
|
|
3807
|
+
function buildXgplayerConfig(options, routed, normalized, emit, env, playableState, recovery) {
|
|
3284
3808
|
const { config } = options;
|
|
3285
3809
|
const live = normalized.live;
|
|
3286
3810
|
const plugins = [
|
|
3287
3811
|
SafeDestroyPlugin,
|
|
3288
3812
|
AutoplayGuardPlugin,
|
|
3289
|
-
ReconnectPlugin,
|
|
3290
|
-
ErrorRecoveryPlugin,
|
|
3291
3813
|
VisibilityPlugin,
|
|
3292
3814
|
CompatPlugin,
|
|
3293
3815
|
WakeLockPlugin,
|
|
@@ -3308,7 +3830,12 @@ function buildXgplayerConfig(options, routed, normalized, emit, env, playableSta
|
|
|
3308
3830
|
el: options.el,
|
|
3309
3831
|
url: routed.chosen.url,
|
|
3310
3832
|
isLive: live,
|
|
3311
|
-
ignores: [
|
|
3833
|
+
ignores: [
|
|
3834
|
+
"loading",
|
|
3835
|
+
"enter",
|
|
3836
|
+
...config.controlVisibility?.volume === false ? ["volume"] : [],
|
|
3837
|
+
...config.controlVisibility?.time === false ? ["time"] : []
|
|
3838
|
+
],
|
|
3312
3839
|
...toXgLang(config.locale) ? { lang: toXgLang(config.locale) } : {},
|
|
3313
3840
|
...(() => {
|
|
3314
3841
|
const lang = toXgLang(config.locale);
|
|
@@ -3332,65 +3859,16 @@ function buildXgplayerConfig(options, routed, normalized, emit, env, playableSta
|
|
|
3332
3859
|
preferManagedMediaSource: false
|
|
3333
3860
|
} } } : {},
|
|
3334
3861
|
autoplayGuard: { onAutoplayBlocked: () => {
|
|
3862
|
+
recovery()?.setPlayingIntent(false);
|
|
3335
3863
|
emit({
|
|
3336
3864
|
event: "autoplayblocked",
|
|
3337
3865
|
payload: {}
|
|
3338
3866
|
});
|
|
3339
3867
|
playableState()?.setAutoplayBlocked(true);
|
|
3340
3868
|
} },
|
|
3341
|
-
reconnect: {
|
|
3342
|
-
onReconnectStart: (payload) => {
|
|
3343
|
-
emit({
|
|
3344
|
-
event: "reconnectstart",
|
|
3345
|
-
payload
|
|
3346
|
-
});
|
|
3347
|
-
playableState()?.setReconnecting(true);
|
|
3348
|
-
},
|
|
3349
|
-
onReconnectSuccess: (payload) => {
|
|
3350
|
-
emit({
|
|
3351
|
-
event: "reconnectsuccess",
|
|
3352
|
-
payload
|
|
3353
|
-
});
|
|
3354
|
-
playableState()?.setReconnecting(false);
|
|
3355
|
-
},
|
|
3356
|
-
onReconnectFailed: (payload) => {
|
|
3357
|
-
emit({
|
|
3358
|
-
event: "reconnectfailed",
|
|
3359
|
-
payload
|
|
3360
|
-
});
|
|
3361
|
-
playableState()?.setReconnecting(false);
|
|
3362
|
-
playableState()?.setError(true);
|
|
3363
|
-
},
|
|
3364
|
-
onRecoveryDetected: ({ trigger, maxAttempts, reason }) => recoveryEvidence()?.detected({
|
|
3365
|
-
strategy: "reconnect",
|
|
3366
|
-
trigger,
|
|
3367
|
-
maxAttempts,
|
|
3368
|
-
reason
|
|
3369
|
-
}),
|
|
3370
|
-
onRecoveryAttempting: (payload) => recoveryEvidence()?.attempting("reconnect", payload),
|
|
3371
|
-
onRecoveryValidating: ({ timeoutMs }) => recoveryEvidence()?.validating("reconnect", timeoutMs),
|
|
3372
|
-
onRecoveryFailed: ({ outcome }) => recoveryEvidence()?.failed("reconnect", outcome)
|
|
3373
|
-
},
|
|
3374
|
-
errorRecovery: {
|
|
3375
|
-
onRecoveryDetected: ({ trigger, maxAttempts, reason }) => recoveryEvidence()?.detected({
|
|
3376
|
-
strategy: "media_recovery",
|
|
3377
|
-
trigger,
|
|
3378
|
-
maxAttempts,
|
|
3379
|
-
reason
|
|
3380
|
-
}),
|
|
3381
|
-
onRecoveryAttempting: (payload) => recoveryEvidence()?.attempting("media_recovery", payload),
|
|
3382
|
-
onRecoveryValidating: () => recoveryEvidence()?.validating("media_recovery", 15e3),
|
|
3383
|
-
onRecoveryFailed: ({ outcome }) => recoveryEvidence()?.failed("media_recovery", outcome)
|
|
3384
|
-
},
|
|
3385
3869
|
visibility: {
|
|
3386
3870
|
isIOS: env.isIOS,
|
|
3387
|
-
|
|
3388
|
-
strategy: "visibility_reload",
|
|
3389
|
-
trigger,
|
|
3390
|
-
maxAttempts
|
|
3391
|
-
}),
|
|
3392
|
-
onRecoveryAttempting: (payload) => recoveryEvidence()?.attempting("visibility_reload", payload),
|
|
3393
|
-
onRecoveryValidating: () => recoveryEvidence()?.validating("visibility_reload", 15e3)
|
|
3871
|
+
requestRecovery: () => recovery()?.requestRecovery("visibility")
|
|
3394
3872
|
},
|
|
3395
3873
|
compat: {
|
|
3396
3874
|
ua: env.ua,
|
|
@@ -3407,6 +3885,7 @@ function buildXgplayerConfig(options, routed, normalized, emit, env, playableSta
|
|
|
3407
3885
|
payload
|
|
3408
3886
|
});
|
|
3409
3887
|
playableState()?.setStalled(payload.phase === "start");
|
|
3888
|
+
if (payload.phase === "start" && payload.kind === "playback") recovery()?.requestRecovery("stall");
|
|
3410
3889
|
},
|
|
3411
3890
|
onBufferHealth: (payload) => {
|
|
3412
3891
|
emit({
|
|
@@ -3430,19 +3909,15 @@ function buildXgplayerConfig(options, routed, normalized, emit, env, playableSta
|
|
|
3430
3909
|
...config.startTime === void 0 ? {} : { startTime: config.startTime },
|
|
3431
3910
|
...config.poster === void 0 ? {} : { poster: typeof config.poster === "string" ? config.poster : config.poster.url },
|
|
3432
3911
|
controls: config.controls !== false,
|
|
3433
|
-
|
|
3912
|
+
...config.controlVisibility?.play === false ? { play: { disable: true } } : {},
|
|
3913
|
+
...config.controlVisibility?.progress === false ? { progress: { disable: true } } : {},
|
|
3914
|
+
...config.controlVisibility?.fullscreen === false ? { fullscreen: { disable: true } } : {},
|
|
3915
|
+
...config.controlVisibility?.cssFullscreen === false ? { cssFullscreen: { disable: true } } : {},
|
|
3916
|
+
...config.controlVisibility?.playbackRate === false ? { playbackRate: { isShowIcon: false } } : {},
|
|
3917
|
+
pip: config.controlVisibility?.pip !== false
|
|
3434
3918
|
};
|
|
3435
3919
|
}
|
|
3436
3920
|
/**
|
|
3437
|
-
* 取 ReconnectPlugin 实例。`getPlugin` 用的是 pluginName(`'reconnect'`)。
|
|
3438
|
-
*
|
|
3439
|
-
* **收成一个函数**:`handle.reconnect` 和 `load()` 的换源复位都要拿它,
|
|
3440
|
-
* 两处各写一遍 `as unknown as { getPlugin?… }` 就是两份会漂的类型断言。
|
|
3441
|
-
*/
|
|
3442
|
-
function getReconnectPlugin(player) {
|
|
3443
|
-
return player.getPlugin?.("reconnect");
|
|
3444
|
-
}
|
|
3445
|
-
/**
|
|
3446
3921
|
* 取 xgplayer-hls.js 插件持有的 hls.js 实例。非 HLS 源(MP4 走原生、FLV 走 flv.js)
|
|
3447
3922
|
* 拿不到,返回 undefined —— 上层据此把多码率相关能力降级成 no-op / 空档位。
|
|
3448
3923
|
*/
|