@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/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 xgplayer_hls_js = require("xgplayer-hls.js");
30
- xgplayer_hls_js = __toESM(xgplayer_hls_js, 1);
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$8 = { enabled: true };
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$8 };
1227
+ return { ...DEFAULT_CONFIG$6 };
588
1228
  }
589
1229
  get compatConfig() {
590
1230
  return {
591
- ...DEFAULT_CONFIG$8,
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$6 = {
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$6 };
1277
+ return { ...DEFAULT_CONFIG$5 };
751
1278
  }
752
1279
  patchedVideo = null;
753
1280
  originalEnter;
754
1281
  get guardConfig() {
755
1282
  return {
756
- ...DEFAULT_CONFIG$6,
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$5 = {
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$5 };
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$5,
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$4 = {
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$4 };
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$4,
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
- if (reason === this.lastReason) return;
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-evidence.ts
2417
+ //#region src/recovery-playback.ts
2070
2418
  /**
2071
- * 将各恢复策略的意图变成同一条、可验证的遥测生命周期(ADR-079)。
2072
- *
2073
- * 它不改变插件的重试或播放策略;只在 `playing` 后又观测到位置推进时记录恢复成功。
2419
+ * 观察一次恢复动作的强播放证据。仅 playing 后正常位置推进算成功;
2420
+ * seek、暂停和等待会重置基准。调用方负责把成功关联到当前动作凭据。
2074
2421
  */
2075
- var RecoveryEvidenceCoordinator = class {
2076
- player;
2077
- emit;
2078
- nextRecoveryId = 1;
2079
- active = null;
2080
- validationTimer = null;
2081
- constructor(player, emit) {
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
- clearValidationTimer() {
2152
- if (this.validationTimer === null) return;
2153
- clearTimeout(this.validationTimer);
2154
- this.validationTimer = null;
2155
- }
2156
- emitPhase(phase, terminal = {}) {
2157
- const active = this.active;
2158
- if (!active) return;
2159
- this.emit({
2160
- event: "recovery",
2161
- payload: {
2162
- recoveryId: active.recoveryId,
2163
- phase,
2164
- strategy: active.strategy,
2165
- trigger: active.trigger,
2166
- attempt: active.attempt,
2167
- maxAttempts: active.maxAttempts,
2168
- ...active.reason ? { reason: active.reason } : {},
2169
- ...terminal
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.reconnect` 的做法。
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 xgplayer_hls_js.default;
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") options.onEvent?.({
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(event);
2843
+ deferredEvents.push(delivered);
2496
2844
  return;
2497
2845
  }
2498
- options.onEvent?.(event);
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, () => recoveryEvidence));
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
- recoveryEvidence = new RecoveryEvidenceCoordinator(player, emit);
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 = () => syncFlvAudioHealthActivity();
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 event of deferredEvents) emit(event);
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
- emit({
2691
- event: "error",
2692
- payload: (0, _video_lab_protocol.makePlayerError)("E_MEDIA_DECODE", "点播媒体在达到结尾前异常跳转并结束", {
2693
- kind: "premature_vod_end",
2694
- ...prematureVodEnd
2695
- })
2696
- });
2697
- playableState()?.setError(true);
2698
- } else if (!normalized.live && routed.kernel === "native" && safeNumber(player.duration) > 0 && sawPlaybackStart && sawStartupWaiting && !sawPlaying && !sawFirstFrame && !sawNativeError) {
2699
- emit({
2700
- event: "error",
2701
- payload: (0, _video_lab_protocol.makePlayerError)("E_MEDIA_DECODE", "点播媒体未解出首帧即结束", {
2702
- kind: "initial_frame_decode_failure",
2703
- duration: safeNumber(player.duration),
2704
- endTime: safeNumber(player.currentTime)
2705
- })
2706
- });
2707
- playableState()?.setError(true);
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
- target.off?.("media_info", onMediaInfo);
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
- recoveryEvidence?.cancel("source_changed");
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
- reconnect(options) {
3199
- if (destroyed) return;
3200
- getReconnectPlugin(player)?.manualReconnect?.(options?.resetCounter ?? false);
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
- recoveryEvidence?.dispose();
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, recoveryEvidence) {
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: ["loading"],
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
- onRecoveryDetected: ({ trigger, maxAttempts }) => recoveryEvidence()?.detected({
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
- pip: true
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
  */