@video-lab/player-core 1.0.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { ERROR_META, MediaSourceSchema, PlayerConfigSchema, makePlayerError, normalizeFrameFreeze, normalizeUserAction, redactSourceUrl, resolveLocaleMessages, resolvePreset } from "@video-lab/protocol";
1
+ import { ERROR_META, MediaSourceSchema, PLAYER_DESTROYED_MESSAGE, PlayerConfigSchema, RecoveryPayloadSchema, makePlayerError, normalizeFrameFreeze, normalizeUserAction, redactSourceUrl, resolveLocaleMessages, resolvePreset } from "@video-lab/protocol";
2
2
  import Player, { BasePlugin, Danmu, Events, I18N, TextTrack } from "xgplayer";
3
3
  import FlvPlugin from "xgplayer-flv.js";
4
- import HlsPlugin from "xgplayer-hls.js";
4
+ import HlsLight from "hls.js/light";
5
5
  /**
6
6
  * 只判断 FLV 的**数据轨**连续性,不是内容声音检测。
7
7
  *
@@ -472,6 +472,646 @@ function createKernelHealthAggregator(options) {
472
472
  };
473
473
  }
474
474
  //#endregion
475
+ //#region src/kernels/hls-adapter.ts
476
+ /** 内部 HLS 生命周期适配器;恢复预算由调用者持有,不属于公开导出面。 */
477
+ var HlsAdapter = class {
478
+ media;
479
+ onError;
480
+ config;
481
+ create;
482
+ current = null;
483
+ generation = 0;
484
+ destroyed = false;
485
+ repair = null;
486
+ constructor(media, onError, config = {}, create = (options) => new HlsLight(options)) {
487
+ this.media = media;
488
+ this.onError = onError;
489
+ this.config = config;
490
+ this.create = create;
491
+ }
492
+ /** 供现有清晰度、音轨和诊断接线访问当前内核;替换后旧实例不得再使用。 */
493
+ get kernel() {
494
+ return this.current;
495
+ }
496
+ /** 创建一次显式加载动作;不重建媒体元素,不在错误回调里自动重试。 */
497
+ start(url) {
498
+ if (this.destroyed) throw new Error("HlsAdapter is destroyed");
499
+ this.stop();
500
+ const generation = this.generation;
501
+ const hls = this.create({
502
+ ...this.config,
503
+ autoStartLoad: false
504
+ });
505
+ this.current = hls;
506
+ let running = true;
507
+ let sourceLoaded = false;
508
+ let repairPosition = null;
509
+ const valid = () => this.current === hls && this.generation === generation && running;
510
+ hls.on(HlsLight.Events.MEDIA_ATTACHED, () => {
511
+ if (!valid()) return;
512
+ if (!sourceLoaded) {
513
+ sourceLoaded = true;
514
+ hls.loadSource(url);
515
+ if (valid()) hls.startLoad();
516
+ } else if (repairPosition !== null) {
517
+ const position = repairPosition;
518
+ repairPosition = null;
519
+ hls.startLoad(position);
520
+ }
521
+ });
522
+ this.repair = () => {
523
+ if (this.current !== hls || this.generation !== generation) return;
524
+ running = true;
525
+ repairPosition = this.media.currentTime;
526
+ hls.recoverMediaError();
527
+ };
528
+ hls.on(HlsLight.Events.ERROR, (_event, error) => {
529
+ if (!valid()) return;
530
+ if (error.fatal) {
531
+ running = false;
532
+ hls.stopLoad();
533
+ }
534
+ this.onError(error);
535
+ });
536
+ try {
537
+ hls.attachMedia(this.media);
538
+ } catch (error) {
539
+ if (this.current === hls && this.generation === generation) this.stop();
540
+ throw error;
541
+ }
542
+ }
543
+ /** 仅由调度器发出的显式媒体修复动作;无内核时由调用方重拉。 */
544
+ recoverMediaError() {
545
+ if (this.destroyed || !this.current || !this.repair) return false;
546
+ this.repair();
547
+ return true;
548
+ }
549
+ /** 先使回调失效再销毁内核;媒体元素由播放器持有。 */
550
+ stop() {
551
+ this.generation += 1;
552
+ const hls = this.current;
553
+ this.current = null;
554
+ this.repair = null;
555
+ hls?.destroy();
556
+ }
557
+ /** 终止生命周期;重复销毁无副作用。 */
558
+ destroy() {
559
+ this.destroyed = true;
560
+ this.stop();
561
+ }
562
+ };
563
+ //#endregion
564
+ //#region src/kernels/owned-hls-plugin.ts
565
+ /** 生产 HLS 内部装配层;恢复预算由统一调度器持有,不作为宿主扩展口导出。 */
566
+ var OwnedHlsPlugin = class extends BasePlugin {
567
+ static get pluginName() {
568
+ return "HlsJsPlugin";
569
+ }
570
+ static get defaultConfig() {
571
+ return { hlsOpts: {} };
572
+ }
573
+ adapter = null;
574
+ statisticsTimer = null;
575
+ disposed = false;
576
+ urlDescriptor;
577
+ constructor(args) {
578
+ super(args);
579
+ this.player.handleSource = false;
580
+ }
581
+ /** 保留现有 core 对 HlsJsPlugin.hls 的访问方式。 */
582
+ get hls() {
583
+ return this.adapter?.kernel ?? void 0;
584
+ }
585
+ afterCreate() {
586
+ this.urlDescriptor = Object.getOwnPropertyDescriptor(this.player, "url");
587
+ Object.defineProperty(this.player, "url", {
588
+ configurable: true,
589
+ get: () => {
590
+ const media = this.player.video;
591
+ return media instanceof HTMLMediaElement ? media.src : void 0;
592
+ }
593
+ });
594
+ this.on(Events.URL_CHANGE, this.onUrlChange);
595
+ }
596
+ beforePlayerInit() {
597
+ this.register(this.playerConfig.url);
598
+ }
599
+ onUrlChange = (url) => {
600
+ if (url.startsWith("blob:")) return;
601
+ this.playerConfig.url = url;
602
+ this.register(url);
603
+ };
604
+ register(url) {
605
+ if (this.disposed || typeof url !== "string" || !url) return;
606
+ if (!this.adapter) {
607
+ const media = this.player.video;
608
+ if (!(media instanceof HTMLMediaElement)) return;
609
+ const hlsOpts = { ...this.config.hlsOpts };
610
+ if (hlsOpts.startPosition === void 0 && typeof this.playerConfig.startTime === "number") hlsOpts.startPosition = this.playerConfig.startTime;
611
+ this.adapter = new HlsAdapter(media, (error) => {
612
+ if (this.disposed) return;
613
+ this.player.emit("HLS_ERROR", {
614
+ errorType: error.type,
615
+ errorDetails: error.details,
616
+ errorFatal: error.fatal
617
+ });
618
+ }, hlsOpts);
619
+ }
620
+ this.clearStatistics();
621
+ this.adapter.start(url);
622
+ this.attachStatistics();
623
+ }
624
+ attachStatistics() {
625
+ const hls = this.hls;
626
+ if (!hls) return;
627
+ const valid = () => !this.disposed && this.hls === hls;
628
+ let speed = 0;
629
+ hls.on(HlsLight.Events.FRAG_LOADED, (_event, data) => {
630
+ if (valid()) speed = data.frag.stats.loaded / 1e3;
631
+ });
632
+ hls.on(HlsLight.Events.BUFFER_CODECS, (_event, tracks) => {
633
+ if (!valid()) return;
634
+ const audio = tracks.audio;
635
+ const video = tracks.video ?? tracks.audiovideo;
636
+ const info = {
637
+ videoDataRate: 0,
638
+ audioDataRate: 0,
639
+ hasAudio: Boolean(audio || tracks.audiovideo),
640
+ hasVideo: Boolean(video),
641
+ audioChannelCount: audio?.metadata?.channelCount ?? 0,
642
+ audioCodec: audio?.codec,
643
+ videoCodec: video?.codec,
644
+ width: video?.metadata?.width,
645
+ height: video?.metadata?.height
646
+ };
647
+ Object.assign(this.player, { mediainfo: info });
648
+ this.player.emit("media_info", info);
649
+ });
650
+ this.statisticsTimer = setInterval(() => {
651
+ if (!valid()) return;
652
+ this.player.emit("statistics_info", {
653
+ speed,
654
+ playerType: "HlsPlayer"
655
+ });
656
+ speed = 0;
657
+ }, 1e3);
658
+ }
659
+ clearStatistics() {
660
+ if (this.statisticsTimer !== null) clearInterval(this.statisticsTimer);
661
+ this.statisticsTimer = null;
662
+ }
663
+ /** 调度器停止当前内核,保留插件、播放器及媒体元素。 */
664
+ stopLoading() {
665
+ this.clearStatistics();
666
+ this.adapter?.stop();
667
+ }
668
+ /** 显式媒体修复;无可修复内核时返回 false,供调度器选择重拉。 */
669
+ recoverMediaError() {
670
+ return this.adapter?.recoverMediaError() ?? false;
671
+ }
672
+ destroy() {
673
+ if (this.disposed) return;
674
+ this.disposed = true;
675
+ this.clearStatistics();
676
+ this.off(Events.URL_CHANGE, this.onUrlChange);
677
+ const adapter = this.adapter;
678
+ this.adapter = null;
679
+ if (this.urlDescriptor) Object.defineProperty(this.player, "url", this.urlDescriptor);
680
+ else Reflect.deleteProperty(this.player, "url");
681
+ adapter?.destroy();
682
+ }
683
+ };
684
+ //#endregion
685
+ //#region src/page-fullscreen.ts
686
+ /**
687
+ * 接管锁定的 xgplayer 3.0.26 网页全屏按钮与 Esc;宿主确认后才应用本地状态。
688
+ * 不替换 CSS 全屏方法,以保留原生/旋转全屏内部退出 CSS 全屏的清理路径。
689
+ * 无 onRequest 时保留 inline 的本地行为。播放器插件初始化完成后调用。
690
+ */
691
+ function attachPageFullscreen(player, options) {
692
+ const managed = options.onRequest !== void 0;
693
+ let available = !managed;
694
+ let destroyed = false;
695
+ const cssPlugin = player.getPlugin("cssFullscreen");
696
+ const keyboard = player.getPlugin("keyboard");
697
+ const escapeKey = keyboard?.keyCodeMap?.esc;
698
+ const originalEscape = escapeKey?.action;
699
+ const originalDisplay = cssPlugin?.root?.style.display ?? "";
700
+ const onChange = (active) => options.onChange(active);
701
+ player.on("cssFullscreen_change", onChange);
702
+ const request = (active) => {
703
+ if (!destroyed && available) options.onRequest?.(active);
704
+ };
705
+ const onButton = (_plugin, event) => {
706
+ if (available) cssPlugin?.emitUserAction?.(event, "switch_cssfullscreen", { cssfullscreen: player.isCssfullScreen });
707
+ request(!player.isCssfullScreen);
708
+ return false;
709
+ };
710
+ const onEscape = () => {
711
+ if (player.fullscreen) {
712
+ keyboard?.emitUserAction?.("keyup", "switch_fullscreen", {
713
+ prop: "fullscreen",
714
+ from: true,
715
+ to: false
716
+ });
717
+ player.exitFullscreen();
718
+ }
719
+ if (player.isCssfullScreen) {
720
+ keyboard?.emitUserAction?.("keyup", "switch_css_fullscreen", {
721
+ prop: "cssfullscreen",
722
+ from: true,
723
+ to: false
724
+ });
725
+ request(false);
726
+ }
727
+ };
728
+ const setAvailable = (next) => {
729
+ if (destroyed) return;
730
+ available = next;
731
+ if (managed && cssPlugin?.root) cssPlugin.root.style.display = next ? originalDisplay : "none";
732
+ };
733
+ if (managed) {
734
+ if (cssPlugin && !cssPlugin.config?.disable) player.usePluginHooks("cssFullscreen", "cssFullscreen_change", onButton);
735
+ if (escapeKey) escapeKey.action = onEscape;
736
+ setAvailable(false);
737
+ }
738
+ return {
739
+ /** 显式协商成功后开放按钮;撤销能力不伪造实际状态。 */
740
+ setAvailable,
741
+ /** 应用已确认状态;重复确认不重复操作 xgplayer。 */
742
+ setActive(active) {
743
+ if (destroyed || player.isCssfullScreen === active) return;
744
+ if (active) player.getCssFullscreen();
745
+ else player.exitCssFullscreen();
746
+ },
747
+ /** 移除本适配器的入口与监听,恢复插件原行为。 */
748
+ destroy() {
749
+ if (destroyed) return;
750
+ destroyed = true;
751
+ player.off("cssFullscreen_change", onChange);
752
+ if (managed) {
753
+ if (cssPlugin && !cssPlugin.config?.disable) player.removePluginHooks("cssFullscreen", "cssFullscreen_change", onButton);
754
+ if (escapeKey && escapeKey.action === onEscape && originalEscape !== void 0) escapeKey.action = originalEscape;
755
+ if (cssPlugin?.root) cssPlugin.root.style.display = originalDisplay;
756
+ }
757
+ }
758
+ };
759
+ }
760
+ //#endregion
761
+ //#region src/recovery-controller.ts
762
+ const BACKOFF = [
763
+ 1e3,
764
+ 2e3,
765
+ 4e3
766
+ ];
767
+ /**
768
+ * 实例级内部调度器。执行器须在 abort 时同步停止旧动作;Promise 完成仅代表命令结束。
769
+ * 强播放证据由调用方判定后交 recovered;生产装配层负责映射公开事件。
770
+ */
771
+ var RecoveryController = class {
772
+ execute;
773
+ onTransition;
774
+ active = null;
775
+ sequence = 0;
776
+ disposed = false;
777
+ suspended = false;
778
+ timer = null;
779
+ deadlineTimer = null;
780
+ events = [];
781
+ publishing = false;
782
+ actionDepth = 0;
783
+ constructor(execute, onTransition) {
784
+ this.execute = execute;
785
+ this.onTransition = onTransition;
786
+ }
787
+ /** 供装配层识别同步重入后的新 episode,不是公开播放器查询。 */
788
+ get activeEpisodeId() {
789
+ return this.active?.id ?? null;
790
+ }
791
+ /** 在途请求合并;切换会话前由调用方显式 cancel。 */
792
+ request(sessionId, input = {}) {
793
+ if (this.disposed) return null;
794
+ if (this.active) {
795
+ if (!this.active.finishing && this.active.sessionId === sessionId) {
796
+ if (input.strategy) this.active.strategy = input.strategy;
797
+ if (input.reason) this.active.reason = input.reason;
798
+ }
799
+ return this.active.id;
800
+ }
801
+ const episode = {
802
+ id: ++this.sequence,
803
+ sessionId,
804
+ deadline: performance.now() + 6e4,
805
+ startedAt: performance.now(),
806
+ strategy: input.strategy ?? "reconnect",
807
+ trigger: input.trigger ?? "error",
808
+ reason: input.reason,
809
+ attempt: 0,
810
+ token: null,
811
+ abort: null,
812
+ finishing: false
813
+ };
814
+ this.active = episode;
815
+ this.deadlineTimer = setTimeout(() => this.finish(episode, "failed"), 6e4);
816
+ this.publish(episode, "detected");
817
+ this.schedule(episode);
818
+ return episode.id;
819
+ }
820
+ /** 只接受当前动作的强证据,过期证据不得覆盖预算终态。 */
821
+ recovered(token) {
822
+ const episode = this.active;
823
+ if (!episode || episode.token !== token) return;
824
+ this.finish(episode, performance.now() >= episode.deadline ? "failed" : "recovered");
825
+ }
826
+ /** 第一动作尚未发出时原播放自行恢复,不制造一次成功动作。 */
827
+ naturalRecovered() {
828
+ if (this.active?.attempt === 0) this.finish(this.active, "cancelled", "natural_recovery");
829
+ }
830
+ /** 换源、用户暂停等取消原因由上层记录;取消不等同于预算耗尽。 */
831
+ cancel(outcome = "superseded") {
832
+ if (this.active) this.finish(this.active, "cancelled", outcome);
833
+ }
834
+ /** 后台暂停发动作,保留总截止时间和已消耗预算。 */
835
+ setSuspended(suspended) {
836
+ if (this.disposed || this.suspended === suspended) return;
837
+ this.suspended = suspended;
838
+ const episode = this.active;
839
+ if (!episode) return;
840
+ if (suspended) {
841
+ if (this.timer !== null) clearTimeout(this.timer);
842
+ this.timer = null;
843
+ episode.token = null;
844
+ const abort = episode.abort;
845
+ episode.abort = null;
846
+ abort?.abort();
847
+ } else this.schedule(episode);
848
+ }
849
+ /** 销毁后不再接受新恢复请求。 */
850
+ destroy() {
851
+ this.disposed = true;
852
+ this.cancel("destroyed");
853
+ }
854
+ schedule(episode) {
855
+ if (this.active !== episode) return;
856
+ const delay = BACKOFF[episode.attempt];
857
+ const remaining = episode.deadline - performance.now();
858
+ if (delay === void 0 || remaining <= 0) {
859
+ this.finish(episode, "failed");
860
+ return;
861
+ }
862
+ if (!this.suspended) this.timer = setTimeout(() => this.begin(episode), Math.min(delay, remaining));
863
+ this.publish(episode, "backoff");
864
+ }
865
+ begin(episode) {
866
+ if (this.active !== episode) return;
867
+ this.timer = null;
868
+ const remaining = episode.deadline - performance.now();
869
+ if (remaining <= 0) {
870
+ this.finish(episode, "failed");
871
+ return;
872
+ }
873
+ const token = Object.freeze({
874
+ episode: episode.id,
875
+ sessionId: episode.sessionId,
876
+ attempt: ++episode.attempt,
877
+ strategy: episode.strategy
878
+ });
879
+ const abort = new AbortController();
880
+ episode.issuedStrategy = token.strategy;
881
+ episode.token = token;
882
+ episode.abort = abort;
883
+ this.timer = setTimeout(() => this.failAttempt(episode, token), Math.min(15e3, remaining));
884
+ this.actionDepth += 1;
885
+ this.publish(episode, "attempting");
886
+ try {
887
+ const result = this.execute(token, abort.signal);
888
+ if (this.active === episode && episode.token === token) this.publish(episode, "validating");
889
+ Promise.resolve(result).catch(() => this.failAttempt(episode, token));
890
+ } catch {
891
+ this.failAttempt(episode, token);
892
+ } finally {
893
+ this.actionDepth -= 1;
894
+ this.flushEvents();
895
+ }
896
+ }
897
+ failAttempt(episode, token) {
898
+ if (this.active !== episode || episode.token !== token) return;
899
+ if (this.timer !== null) clearTimeout(this.timer);
900
+ this.timer = null;
901
+ episode.token = null;
902
+ const abort = episode.abort;
903
+ episode.abort = null;
904
+ abort?.abort();
905
+ this.schedule(episode);
906
+ }
907
+ finish(episode, phase, outcome) {
908
+ if (this.active !== episode || episode.finishing) return;
909
+ episode.finishing = true;
910
+ if (this.timer !== null) clearTimeout(this.timer);
911
+ if (this.deadlineTimer !== null) clearTimeout(this.deadlineTimer);
912
+ this.timer = null;
913
+ this.deadlineTimer = null;
914
+ episode.token = null;
915
+ if (phase !== "recovered") episode.abort?.abort();
916
+ this.active = null;
917
+ this.publish(episode, phase, outcome ?? (phase === "failed" ? performance.now() >= episode.deadline ? "timeout" : "attempts_exhausted" : void 0));
918
+ }
919
+ publish(episode, phase, outcome) {
920
+ this.events.push(Object.freeze({
921
+ episode: episode.id,
922
+ sessionId: episode.sessionId,
923
+ attempt: episode.attempt,
924
+ phase,
925
+ elapsedMs: Math.max(0, performance.now() - episode.startedAt),
926
+ maxAttempts: BACKOFF.length,
927
+ strategy: phase === "detected" || phase === "backoff" ? episode.strategy : episode.issuedStrategy ?? episode.strategy,
928
+ trigger: episode.trigger,
929
+ ...episode.reason ? { reason: episode.reason } : {},
930
+ ...outcome ? { outcome } : {}
931
+ }));
932
+ this.flushEvents();
933
+ }
934
+ flushEvents() {
935
+ if (this.publishing || this.actionDepth > 0) return;
936
+ this.publishing = true;
937
+ try {
938
+ while (this.events.length > 0) {
939
+ const event = this.events.shift();
940
+ if (event) try {
941
+ this.onTransition(event);
942
+ } catch {}
943
+ }
944
+ } finally {
945
+ this.publishing = false;
946
+ }
947
+ }
948
+ };
949
+ //#endregion
950
+ //#region src/playback-recovery.ts
951
+ /** 播放意图、故障终态与调度预算之间的实例级协调;尚未公开导出。 */
952
+ var PlaybackRecovery = class {
953
+ sessionId;
954
+ emit;
955
+ display;
956
+ controller;
957
+ error = null;
958
+ desiredPlayback = false;
959
+ exhausted = false;
960
+ disposed = false;
961
+ revision = 0;
962
+ constructor(sessionId, execute, emit, display) {
963
+ this.sessionId = sessionId;
964
+ this.emit = emit;
965
+ this.display = display;
966
+ this.controller = new RecoveryController(execute, (event) => this.transition(event));
967
+ }
968
+ /** 执行器完成异步换源后复查最新意图,避免恢复用户已暂停的播放。 */
969
+ get playingIntent() {
970
+ return this.desiredPlayback && !this.disposed;
971
+ }
972
+ /** 已归一诊断进入此处;终态后的重复诊断不重新分配预算。 */
973
+ fault(error, sessionId = this.sessionId) {
974
+ if (this.disposed || sessionId !== this.sessionId) return;
975
+ const revision = ++this.revision;
976
+ this.error = error;
977
+ if (!error.retryable) {
978
+ this.controller.cancel();
979
+ if (this.disposed || revision !== this.revision) return;
980
+ this.display({
981
+ recovering: false,
982
+ failed: true,
983
+ action: this.action
984
+ });
985
+ } else if (this.desiredPlayback && !this.exhausted) this.request("error");
986
+ else this.display({
987
+ recovering: false,
988
+ failed: true,
989
+ action: this.action
990
+ });
991
+ }
992
+ /** 仅接显式播放/暂停意图;内核 stop 产生的 pause 不调用此方法。 */
993
+ setPlayingIntent(playing) {
994
+ if (this.disposed) return;
995
+ this.revision += 1;
996
+ this.desiredPlayback = playing;
997
+ if (!playing) {
998
+ this.exhausted = false;
999
+ this.controller.cancel("user_paused");
1000
+ } else if (this.error?.retryable && !this.exhausted) this.request("manual");
1001
+ }
1002
+ /** 正常播放结束后不再接受自动恢复意图;不伪报用户暂停或恢复成功。 */
1003
+ endPlayback() {
1004
+ if (this.disposed) return;
1005
+ const revision = ++this.revision;
1006
+ this.desiredPlayback = false;
1007
+ this.exhausted = false;
1008
+ this.error = null;
1009
+ this.controller.cancel("superseded");
1010
+ if (this.disposed || revision !== this.revision) return;
1011
+ this.display({
1012
+ recovering: false,
1013
+ failed: false,
1014
+ action: "none"
1015
+ });
1016
+ }
1017
+ /** 接受/合并手动重试;播放成功仍以当前动作的强证据为准。 */
1018
+ async retry() {
1019
+ if (this.disposed) throw new SentinelError(makePlayerError("E_PLAYER_DESTROYED", PLAYER_DESTROYED_MESSAGE));
1020
+ if (this.error && !this.error.retryable) throw new SentinelError(makePlayerError("E_METHOD_NOT_SUPPORTED", "当前源不能直接重试,请按动作提示处理"));
1021
+ this.desiredPlayback = true;
1022
+ this.revision += 1;
1023
+ this.exhausted = false;
1024
+ this.request("manual");
1025
+ }
1026
+ /** 接收测量或可见性产生的恢复意图,不要求捏造 raw error。 */
1027
+ requestRecovery(trigger) {
1028
+ if (this.disposed || !this.desiredPlayback || this.exhausted) return;
1029
+ if (this.error && !this.error.retryable) return;
1030
+ this.request(trigger);
1031
+ }
1032
+ /** 执行器提交当前动作的播放证据。 */
1033
+ recovered(token) {
1034
+ this.controller.recovered(token);
1035
+ }
1036
+ /** 原播放的强证据:无动作时结束等待,有在途动作时交其专属凭据判定。 */
1037
+ naturalRecovered() {
1038
+ if (this.disposed) return;
1039
+ if (this.controller.activeEpisodeId !== null) {
1040
+ this.controller.naturalRecovered();
1041
+ return;
1042
+ }
1043
+ this.error = null;
1044
+ this.exhausted = false;
1045
+ this.display({
1046
+ recovering: false,
1047
+ failed: false,
1048
+ action: "none"
1049
+ });
1050
+ }
1051
+ /** 页面隐藏只暂停动作,不暂停总预算。 */
1052
+ setSuspended(hidden) {
1053
+ this.controller.setSuspended(hidden);
1054
+ }
1055
+ /** 换源先使新会话生效,再取消旧动作,防止旧回调覆盖新展示。 */
1056
+ setSession(sessionId) {
1057
+ if (this.disposed) return;
1058
+ this.revision += 1;
1059
+ this.sessionId = sessionId;
1060
+ this.error = null;
1061
+ this.exhausted = false;
1062
+ this.controller.cancel("source_changed");
1063
+ }
1064
+ /** 销毁后所有重试命令明确拒绝。 */
1065
+ destroy() {
1066
+ if (this.disposed) return;
1067
+ this.disposed = true;
1068
+ this.controller.destroy();
1069
+ }
1070
+ get action() {
1071
+ if (!this.error || this.error.retryable) return "retry";
1072
+ if (this.error.category === "autoplay") return "play";
1073
+ return this.error.category === "env" ? "none" : "replace-source";
1074
+ }
1075
+ request(trigger) {
1076
+ this.controller.request(this.sessionId, {
1077
+ trigger,
1078
+ strategy: trigger === "visibility" ? "visibility_reload" : this.error?.category === "media" ? "media_recovery" : "reconnect",
1079
+ ...this.error ? { reason: this.error.code } : {}
1080
+ });
1081
+ }
1082
+ transition(event) {
1083
+ const { episode, ...fact } = event;
1084
+ const revision = this.revision;
1085
+ if (event.sessionId === this.sessionId && event.phase === "failed") this.exhausted = true;
1086
+ if (event.sessionId === this.sessionId && (event.phase === "recovered" || event.outcome === "natural_recovery")) {
1087
+ this.error = null;
1088
+ this.exhausted = false;
1089
+ }
1090
+ this.emit({
1091
+ event: "recovery",
1092
+ payload: RecoveryPayloadSchema.parse({
1093
+ ...fact,
1094
+ recoveryId: episode,
1095
+ ...event.phase === "recovered" ? { validatedBy: "playing_position_advance" } : {}
1096
+ })
1097
+ });
1098
+ if (this.disposed || event.sessionId !== this.sessionId || revision !== this.revision) return;
1099
+ const current = this.controller.activeEpisodeId;
1100
+ if (current !== null && current !== episode) return;
1101
+ const recovering = [
1102
+ "detected",
1103
+ "backoff",
1104
+ "attempting",
1105
+ "validating"
1106
+ ].includes(event.phase);
1107
+ this.display({
1108
+ recovering,
1109
+ failed: !recovering && (this.exhausted || this.error !== null),
1110
+ action: recovering || !this.exhausted && !this.error ? "none" : this.action
1111
+ });
1112
+ }
1113
+ };
1114
+ //#endregion
475
1115
  //#region src/plugins/autoplay-guard.ts
476
1116
  /**
477
1117
  * AutoplayGuard(P0)· 检测浏览器 autoplay 拒绝并告知业务
@@ -538,7 +1178,7 @@ function detectCompat(ua) {
538
1178
  };
539
1179
  return { incompatible: false };
540
1180
  }
541
- const DEFAULT_CONFIG$8 = { enabled: true };
1181
+ const DEFAULT_CONFIG$6 = { enabled: true };
542
1182
  /**
543
1183
  * CompatPlugin(P1)· 浏览器兼容性检测
544
1184
  *
@@ -558,11 +1198,11 @@ var CompatPlugin = class extends BasePlugin {
558
1198
  return "compat";
559
1199
  }
560
1200
  static get defaultConfig() {
561
- return { ...DEFAULT_CONFIG$8 };
1201
+ return { ...DEFAULT_CONFIG$6 };
562
1202
  }
563
1203
  get compatConfig() {
564
1204
  return {
565
- ...DEFAULT_CONFIG$8,
1205
+ ...DEFAULT_CONFIG$6,
566
1206
  ...this.config
567
1207
  };
568
1208
  }
@@ -580,121 +1220,8 @@ var CompatPlugin = class extends BasePlugin {
580
1220
  }
581
1221
  };
582
1222
  //#endregion
583
- //#region src/plugins/error-recovery.ts
584
- const DEFAULT_CONFIG$7 = {
585
- enabled: true,
586
- maxRecoveries: 3
587
- };
588
- /** 原生 decode 卡死时向前跳过的秒数,跳过坏区间 */
589
- const SKIP_SECONDS = .5;
590
- /** 本插件专管的媒体错误码:坏 ts(#17)/ MP4 abort(#18) */
591
- const RECOVERABLE_CODES = /* @__PURE__ */ new Set(["E_MEDIA_DECODE", "E_MEDIA_ABORTED"]);
592
- /**
593
- * ErrorRecoveryPlugin(P2)· 媒体错误自愈
594
- *
595
- * 解决的 pitfall:**#17**(点播 HLS 中间某个 ts 片段坏了,整段播放中断)、
596
- * **#18**(MP4 中途网络 abort 无恢复)。
597
- *
598
- * 与 ReconnectPlugin 的分工(都听 `Events.ERROR`,按错误 **category** 划清):
599
- * - `network` / `manifest` 类(断网、拉流失败)→ ReconnectPlugin 全量 `reload()` 重连。
600
- * - `media` 类(解码 / abort)→ **本插件**做**局部**自愈,不全量 reload——坏 ts 全量 reload
601
- * 会再次撞上同一个坏片段,得跳过而不是重来。
602
- * Reconnect 已加守卫跳过 media 类,避免两者对同一错误重复动作(见 plugins/reconnect.ts)。
603
- *
604
- * 自愈手段:
605
- * - `E_MEDIA_DECODE` 且是 HLS(有 hls.js 实例)→ `hls.recoverMediaError()`(hls.js 官方媒体
606
- * 错误恢复,重建 buffer / 跳过坏片段)。
607
- * - `E_MEDIA_ABORTED`(原生 MP4 fetch 被中断)→ `play()` 续播,恢复被中断的拉取。
608
- * - `E_MEDIA_DECODE` 且无 hls(原生解码卡死)→ 小步 `seek` 前跳,跳过坏区间再 `play()`。
609
- *
610
- * `maxRecoveries` 次用尽后不再自愈,交给正常 error 流程(消费方展示错误 UI)。恢复成功
611
- * (`playing`)清零。恢复生命周期由 create-player 注入并统一出为 `recovery` 事件。
612
- *
613
- * @example
614
- * new Player({ el, plugins: [ErrorRecoveryPlugin], errorRecovery: { maxRecoveries: 3 } })
615
- */
616
- var ErrorRecoveryPlugin = class extends BasePlugin {
617
- static get pluginName() {
618
- return "errorRecovery";
619
- }
620
- static get defaultConfig() {
621
- return { ...DEFAULT_CONFIG$7 };
622
- }
623
- /** 本段已自愈次数,恢复成功清零 */
624
- recoveries = 0;
625
- get recoveryConfig() {
626
- return {
627
- ...DEFAULT_CONFIG$7,
628
- ...this.config
629
- };
630
- }
631
- get surface() {
632
- return this.player;
633
- }
634
- afterCreate() {
635
- if (!this.recoveryConfig.enabled) return;
636
- this.on(Events.ERROR, this.handleError);
637
- this.on(Events.PLAYING, this.handleResumed);
638
- }
639
- /** 用箭头函数保持 this,否则 off 匹配不上(见 CLAUDE.md 红线) */
640
- handleError = (err) => {
641
- const { code, category } = mapXgplayerError(err);
642
- if (category !== "media") return;
643
- if (!RECOVERABLE_CODES.has(code)) return;
644
- if (this.recoveries >= this.recoveryConfig.maxRecoveries) {
645
- this.recoveryConfig.onRecoveryFailed?.({ outcome: "attempts_exhausted" });
646
- return;
647
- }
648
- this.recoveryConfig.onRecoveryDetected?.({
649
- trigger: "error",
650
- maxAttempts: this.recoveryConfig.maxRecoveries,
651
- reason: code
652
- });
653
- this.recoveries += 1;
654
- this.recoveryConfig.onRecoveryAttempting?.({
655
- attempt: this.recoveries,
656
- maxAttempts: this.recoveryConfig.maxRecoveries,
657
- reason: code
658
- });
659
- this.recoveryConfig.onRecoveryValidating?.();
660
- this.recover(code);
661
- };
662
- /** 恢复后重新进入播放 → 本段自愈成功,计数清零 */
663
- handleResumed = () => {
664
- this.recoveries = 0;
665
- };
666
- recover(code) {
667
- const player = this.surface;
668
- const hls = player.getPlugin?.("HlsJsPlugin")?.hls;
669
- if (hls?.recoverMediaError) {
670
- hls.recoverMediaError();
671
- return;
672
- }
673
- if (code === "E_MEDIA_ABORTED") {
674
- this.resumePlay();
675
- return;
676
- }
677
- const t = numberOr$3(player.currentTime, 0);
678
- player.seek?.(t + SKIP_SECONDS);
679
- this.resumePlay();
680
- }
681
- resumePlay() {
682
- const played = this.surface.play?.();
683
- if (played && typeof played.catch === "function") played.catch(() => {});
684
- }
685
- destroy() {
686
- this.off(Events.ERROR, this.handleError);
687
- this.off(Events.PLAYING, this.handleResumed);
688
- super.destroy?.();
689
- }
690
- };
691
- /** 有限数值否则回退 */
692
- function numberOr$3(value, fallback) {
693
- return typeof value === "number" && Number.isFinite(value) ? value : fallback;
694
- }
695
- //#endregion
696
1223
  //#region src/plugins/fullscreen-guard.ts
697
- const DEFAULT_CONFIG$6 = {
1224
+ const DEFAULT_CONFIG$5 = {
698
1225
  enabled: true,
699
1226
  isIOSWeChat: false
700
1227
  };
@@ -721,13 +1248,13 @@ var FullscreenGuardPlugin = class extends BasePlugin {
721
1248
  return "fullscreenGuard";
722
1249
  }
723
1250
  static get defaultConfig() {
724
- return { ...DEFAULT_CONFIG$6 };
1251
+ return { ...DEFAULT_CONFIG$5 };
725
1252
  }
726
1253
  patchedVideo = null;
727
1254
  originalEnter;
728
1255
  get guardConfig() {
729
1256
  return {
730
- ...DEFAULT_CONFIG$6,
1257
+ ...DEFAULT_CONFIG$5,
731
1258
  ...this.config
732
1259
  };
733
1260
  }
@@ -749,7 +1276,7 @@ var FullscreenGuardPlugin = class extends BasePlugin {
749
1276
  };
750
1277
  //#endregion
751
1278
  //#region src/plugins/health-monitor.ts
752
- const DEFAULT_CONFIG$5 = {
1279
+ const DEFAULT_CONFIG$4 = {
753
1280
  enabled: true,
754
1281
  stallThresholdMs: 2e3,
755
1282
  pollIntervalMs: 1e3
@@ -800,7 +1327,7 @@ var HealthMonitorPlugin = class extends BasePlugin {
800
1327
  return "healthMonitor";
801
1328
  }
802
1329
  static get defaultConfig() {
803
- return { ...DEFAULT_CONFIG$5 };
1330
+ return { ...DEFAULT_CONFIG$4 };
804
1331
  }
805
1332
  stalling = false;
806
1333
  stallStartAt = 0;
@@ -831,7 +1358,7 @@ var HealthMonitorPlugin = class extends BasePlugin {
831
1358
  pollTimer = null;
832
1359
  get monitorConfig() {
833
1360
  return {
834
- ...DEFAULT_CONFIG$5,
1361
+ ...DEFAULT_CONFIG$4,
835
1362
  ...this.config
836
1363
  };
837
1364
  }
@@ -1126,7 +1653,7 @@ var MediaSessionPlugin = class extends BasePlugin {
1126
1653
  };
1127
1654
  //#endregion
1128
1655
  //#region src/plugins/playable-state.ts
1129
- const DEFAULT_CONFIG$4 = {
1656
+ const DEFAULT_CONFIG$3 = {
1130
1657
  enabled: true,
1131
1658
  bufferingDebounceMs: 300,
1132
1659
  droppedRateThreshold: 15,
@@ -1227,7 +1754,7 @@ var PlayableStatePlugin = class extends BasePlugin {
1227
1754
  return "playableState";
1228
1755
  }
1229
1756
  static get defaultConfig() {
1230
- return { ...DEFAULT_CONFIG$4 };
1757
+ return { ...DEFAULT_CONFIG$3 };
1231
1758
  }
1232
1759
  errored = false;
1233
1760
  autoplayBlocked = false;
@@ -1236,6 +1763,7 @@ var PlayableStatePlugin = class extends BasePlugin {
1236
1763
  buffering = false;
1237
1764
  initialized = false;
1238
1765
  degraded = false;
1766
+ recovery = null;
1239
1767
  bufferingTimer = null;
1240
1768
  pollTimer = null;
1241
1769
  /** 上次采样看到的累计帧数 —— 用来算**窗口内**掉帧率,见 sampleFrames */
@@ -1244,9 +1772,10 @@ var PlayableStatePlugin = class extends BasePlugin {
1244
1772
  overThresholdRuns = 0;
1245
1773
  /** 上次回调出去的结论。只在变化时回调,靠它去重 */
1246
1774
  lastReason = null;
1775
+ lastAction;
1247
1776
  get stateConfig() {
1248
1777
  return {
1249
- ...DEFAULT_CONFIG$4,
1778
+ ...DEFAULT_CONFIG$3,
1250
1779
  ...this.config
1251
1780
  };
1252
1781
  }
@@ -1286,6 +1815,13 @@ var PlayableStatePlugin = class extends BasePlugin {
1286
1815
  this.errored = value;
1287
1816
  this.publish();
1288
1817
  }
1818
+ /** 原子更新恢复与终态;强证据由恢复协调器判定。 */
1819
+ setRecovery(state) {
1820
+ this.recovery = { ...state };
1821
+ this.errored = state.failed;
1822
+ this.reconnecting = state.recovering;
1823
+ this.publish();
1824
+ }
1289
1825
  /** 用箭头函数保持 this,否则 off 匹配不上(见 CLAUDE.md 红线) */
1290
1826
  handleLoadedData = () => {
1291
1827
  this.initialized = true;
@@ -1345,6 +1881,8 @@ var PlayableStatePlugin = class extends BasePlugin {
1345
1881
  };
1346
1882
  /** 当前命中的最严重的那一支 */
1347
1883
  resolve() {
1884
+ if (this.recovery?.recovering) return "reconnecting";
1885
+ if (this.recovery?.failed) return "error";
1348
1886
  const hit = {
1349
1887
  error: this.errored,
1350
1888
  frame_disconnected: false,
@@ -1360,11 +1898,14 @@ var PlayableStatePlugin = class extends BasePlugin {
1360
1898
  }
1361
1899
  publish() {
1362
1900
  const reason = this.resolve();
1363
- if (reason === this.lastReason) return;
1901
+ const action = reason === "autoplay_blocked" ? "play" : this.recovery?.action;
1902
+ if (reason === this.lastReason && action === this.lastAction) return;
1364
1903
  this.lastReason = reason;
1904
+ this.lastAction = action;
1365
1905
  this.stateConfig.onPlayableChange?.({
1366
1906
  reason,
1367
- ...REASON_TABLE[reason]
1907
+ ...REASON_TABLE[reason],
1908
+ ...action !== void 0 ? { action } : {}
1368
1909
  });
1369
1910
  }
1370
1911
  clearBufferingTimer() {
@@ -1390,236 +1931,6 @@ function numberOr$1(value, fallback) {
1390
1931
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
1391
1932
  }
1392
1933
  //#endregion
1393
- //#region src/plugins/reload-stream.ts
1394
- /**
1395
- * 让播放器重新拉一遍当前的流。
1396
- *
1397
- * ⚠️ **返回的 promise 要等** —— 如果调用方在重新拉流之后还要做别的事(典型是补一次 `play()`)。
1398
- *
1399
- * `reload()` 是同步拆重建,紧跟着 `play()` 能落到新的媒体上;
1400
- * **`switchURL` 是异步的**,紧跟着调 `play()` 会被新的 load 打断
1401
- *(chromium 报 `AbortError: The play() request was interrupted by a new load request`),
1402
- * 而打断之后**没有人会再播一次**,播放器就停在 paused 上。
1403
- *
1404
- * 这不是推演:VisibilityPlugin 从 `reload()` 换到 `reloadStream()` 的第一版就是同步紧跟着
1405
- * `play()`,单跑全绿,**跑满 worker 的全量里三次挂两次** —— 竞态按负载翻脸(#359)。
1406
- *
1407
- * @returns 重新拉流这件事**做完**之后 resolve;**永不 reject**(失败交给调用方的
1408
- * error 事件 / 看门狗,那才是判定恢复与否的地方)
1409
- */
1410
- function reloadStream(player) {
1411
- const url = player?.config?.url;
1412
- if (player?.switchURL && typeof url === "string" && url && !url.startsWith("blob:")) return player.switchURL(url).then(() => "switchURL", () => "switchURL");
1413
- if (player?.reload) {
1414
- player.reload();
1415
- return Promise.resolve("reload");
1416
- }
1417
- return Promise.resolve("none");
1418
- }
1419
- //#endregion
1420
- //#region src/plugins/reconnect.ts
1421
- const DEFAULT_CONFIG$3 = {
1422
- enabled: true,
1423
- maxRetries: 3,
1424
- baseDelayMs: 1e3,
1425
- maxDelayMs: 15e3,
1426
- resumeTimeoutMs: 15e3
1427
- };
1428
- /**
1429
- * ReconnectPlugin(P0)· 断网 / 拉流失败自动重连
1430
- *
1431
- * 解决的 pitfall:**#1**(断网播放中断,无自动重连)、**#2**(换源失败无重试)。
1432
- *
1433
- * 状态机(ARCHITECTURE § 10.3):`idle → retrying → exhausted`,**防止无限重连**。
1434
- * - 播放中遇到**可重试**的错误(网络类,`mapXgplayerError().retryable === true`)→
1435
- * 进入 retrying,按指数退避 reload,每次发 `reconnectstart`。
1436
- * - reload 后恢复播放(`playing`/`canplay`)→ 发 `reconnectsuccess`,回 idle,计数清零。
1437
- * - reload 后 `resumeTimeoutMs` 内既没恢复也没报错(静默挂住)→ 判本轮失败,继续往前推。
1438
- * 断网时的 MSE 路径就是这个形态(hls.js 的网络错误被吞),没有这一条状态机会永远停在 retrying。
1439
- * - 重试用满 `maxRetries` 仍失败 → 进入 exhausted,发 `reconnectfailed`,停止自动重连。
1440
- * - 手动 `reconnect({ resetCounter })` 可从 exhausted 重新拿满 maxRetries 次机会。
1441
- *
1442
- * 不可重试的错误(如 `E_MEDIA_NOT_SUPPORTED`)不触发重连,交给正常 error 流程。
1443
- *
1444
- * @example
1445
- * new Player({
1446
- * el, plugins: [ReconnectPlugin],
1447
- * reconnect: { maxRetries: 3, onReconnectStart, onReconnectSuccess, onReconnectFailed },
1448
- * })
1449
- */
1450
- var ReconnectPlugin = class extends BasePlugin {
1451
- static get pluginName() {
1452
- return "reconnect";
1453
- }
1454
- static get defaultConfig() {
1455
- return { ...DEFAULT_CONFIG$3 };
1456
- }
1457
- state = "idle";
1458
- /** 当前这轮已经发起的重试次数(1..maxRetries)。回 idle / 成功时清零 */
1459
- attempt = 0;
1460
- reloadTimer = null;
1461
- /** reload 发出后的看门狗:恢复信号迟迟不来就把这一轮判失败(见 resumeTimeoutMs) */
1462
- resumeTimer = null;
1463
- lastReason;
1464
- get reconnectConfig() {
1465
- return {
1466
- ...DEFAULT_CONFIG$3,
1467
- ...this.config
1468
- };
1469
- }
1470
- afterCreate() {
1471
- if (!this.reconnectConfig.enabled) return;
1472
- this.on(Events.ERROR, this.handleError);
1473
- this.on(Events.PLAYING, this.handleResumed);
1474
- this.on(Events.CANPLAY, this.handleResumed);
1475
- }
1476
- /** 可重试错误 → 驱动状态机往前走一步 */
1477
- handleError = (err) => {
1478
- const mapped = mapXgplayerError(err);
1479
- if (!mapped.retryable) return;
1480
- if (mapped.category === "media") return;
1481
- if (this.state === "exhausted") return;
1482
- this.lastReason = mapped.code;
1483
- if (this.state === "idle") this.reconnectConfig.onRecoveryDetected?.({
1484
- trigger: "error",
1485
- maxAttempts: this.reconnectConfig.maxRetries,
1486
- reason: mapped.code
1487
- });
1488
- this.advance(mapped.code);
1489
- };
1490
- /** reload 后恢复播放 → 本轮重连成功 */
1491
- handleResumed = () => {
1492
- if (this.state !== "retrying") return;
1493
- const attempts = this.attempt;
1494
- this.clearReloadTimer();
1495
- this.clearResumeTimer();
1496
- this.state = "idle";
1497
- this.attempt = 0;
1498
- this.reconnectConfig.onReconnectSuccess?.({ attempts });
1499
- };
1500
- /**
1501
- * 状态机前进一步:要么再发起一次退避 reload,要么用尽 → exhausted。
1502
- * @param reason 触发本次的错误码;手动重连时为 undefined
1503
- */
1504
- advance(reason, terminalOutcome = "attempts_exhausted") {
1505
- const { maxRetries, baseDelayMs, maxDelayMs } = this.reconnectConfig;
1506
- if (this.attempt >= maxRetries) {
1507
- this.clearReloadTimer();
1508
- this.clearResumeTimer();
1509
- this.state = "exhausted";
1510
- this.reconnectConfig.onRecoveryFailed?.({ outcome: terminalOutcome });
1511
- this.reconnectConfig.onReconnectFailed?.({
1512
- attempts: this.attempt,
1513
- reason
1514
- });
1515
- return;
1516
- }
1517
- this.attempt += 1;
1518
- this.state = "retrying";
1519
- const nextDelayMs = Math.min(baseDelayMs * 2 ** (this.attempt - 1), maxDelayMs);
1520
- this.reconnectConfig.onReconnectStart?.({
1521
- attempt: this.attempt,
1522
- maxAttempts: maxRetries,
1523
- reason,
1524
- nextDelayMs
1525
- });
1526
- this.reconnectConfig.onRecoveryAttempting?.({
1527
- attempt: this.attempt,
1528
- maxAttempts: maxRetries,
1529
- reason
1530
- });
1531
- this.clearReloadTimer();
1532
- this.clearResumeTimer();
1533
- this.reloadTimer = setTimeout(() => {
1534
- this.reloadTimer = null;
1535
- this.reload();
1536
- this.reconnectConfig.onRecoveryValidating?.({ timeoutMs: this.reconnectConfig.resumeTimeoutMs });
1537
- this.resumeTimer = setTimeout(() => {
1538
- this.resumeTimer = null;
1539
- if (this.state !== "retrying") return;
1540
- this.advance(this.lastReason, "timeout");
1541
- }, this.reconnectConfig.resumeTimeoutMs);
1542
- }, nextDelayMs);
1543
- }
1544
- /**
1545
- * 触发 xgplayer 重新拉流。恢复与否由后续 playing / error 事件决定。
1546
- *
1547
- * 走 `switchURL` 还是 `reload` 由 {@link reloadStream} 决定 —— #192 的那条经验**抽出去了**,
1548
- * 因为它被漏掉过一次:`VisibilityPlugin` 接着用 `reload()` 用了几个月(#359)。
1549
- */
1550
- reload() {
1551
- reloadStream(this.player);
1552
- }
1553
- /**
1554
- * 手动重连(命令 `reconnect`)。create-player 的 handle.reconnect 调它。
1555
- * @param resetCounter true = 计数清零,重新拿满 maxRetries 次机会(用户点"重试"的语义)
1556
- */
1557
- manualReconnect(resetCounter = false) {
1558
- if (!this.reconnectConfig.enabled) return;
1559
- if (resetCounter) {
1560
- this.attempt = 0;
1561
- this.state = "idle";
1562
- } else if (this.state === "exhausted") return;
1563
- if (this.state === "idle") this.reconnectConfig.onRecoveryDetected?.({
1564
- trigger: "manual",
1565
- maxAttempts: this.reconnectConfig.maxRetries
1566
- });
1567
- this.advance(void 0);
1568
- }
1569
- /**
1570
- * 换源复位:把状态机整个归零,**但不发起任何重连**。由 `create-player` 的 `load()` 调。
1571
- *
1572
- * ─── 为什么必须有这个方法 ─────────────────────────
1573
- *
1574
- * `load()` 已经在 ADR-074 的名义下把换源定义成「新会话」,并复位了 `sessionId` /
1575
- * `qualityLevel` / `recoveryEvidence` / prematureVodEnd / MediaSession ——
1576
- * **重连计数曾经是这份清单里唯一漏掉的一项**。后果是永久且完全静默的:
1577
- *
1578
- * 源 A 重试用尽 → `state = 'exhausted'` → 消费方按契约语义(`recoverable: false`)
1579
- * 换一个可用地址 → 新源播得好好的,但 `handleResumed` 的第一行是
1580
- * `if (this.state !== 'retrying') return`,**`exhausted` 走不到** → 状态机原地不动 →
1581
- * 此后源 B 再断流,`handleError` 在 `exhausted` 分支直接 return:
1582
- * **不重连、不发 `reconnectstart`、不发 `reconnectfailed`**。
1583
- *
1584
- * ─── 为什么不复用 `manualReconnect(true)` ─────────
1585
- *
1586
- * 那个方法复位完会**立刻 `advance()` 发起一次重连** —— 那是「用户点了重试」的语义。
1587
- * 换源时新地址已经由 `switchURL` 送出去了,再重连一次是多余的一次拉流。
1588
- *
1589
- * ─── 为什么要清掉两个定时器 ───────────────────────
1590
- *
1591
- * 换源那一刻可能正有一轮退避 / 看门狗在途。它们属于**上一个源**的那次故障,
1592
- * 留着会在新源上凭空推进一次状态机(甚至直接判失败)。
1593
- */
1594
- resetForNewSource() {
1595
- this.clearReloadTimer();
1596
- this.clearResumeTimer();
1597
- this.state = "idle";
1598
- this.attempt = 0;
1599
- this.lastReason = void 0;
1600
- }
1601
- clearReloadTimer() {
1602
- if (this.reloadTimer !== null) {
1603
- clearTimeout(this.reloadTimer);
1604
- this.reloadTimer = null;
1605
- }
1606
- }
1607
- clearResumeTimer() {
1608
- if (this.resumeTimer !== null) {
1609
- clearTimeout(this.resumeTimer);
1610
- this.resumeTimer = null;
1611
- }
1612
- }
1613
- destroy() {
1614
- this.clearReloadTimer();
1615
- this.clearResumeTimer();
1616
- this.off(Events.ERROR, this.handleError);
1617
- this.off(Events.PLAYING, this.handleResumed);
1618
- this.off(Events.CANPLAY, this.handleResumed);
1619
- super.destroy?.();
1620
- }
1621
- };
1622
- //#endregion
1623
1934
  //#region src/plugins/safe-destroy.ts
1624
1935
  const DEFAULT_CONFIG$2 = { restoreRootStyle: true };
1625
1936
  /**
@@ -1771,6 +2082,39 @@ function routeSource(source, env) {
1771
2082
  throwPlayerError("E_MEDIA_NOT_SUPPORTED", "候选源为空,或类型都不受支持");
1772
2083
  }
1773
2084
  //#endregion
2085
+ //#region src/plugins/reload-stream.ts
2086
+ /**
2087
+ * 让播放器重新拉一遍当前的流。
2088
+ *
2089
+ * ⚠️ **返回的 promise 要等** —— 如果调用方在重新拉流之后还要做别的事(典型是补一次 `play()`)。
2090
+ *
2091
+ * `reload()` 是同步拆重建,紧跟着 `play()` 能落到新的媒体上;
2092
+ * **`switchURL` 是异步的**,紧跟着调 `play()` 会被新的 load 打断
2093
+ *(chromium 报 `AbortError: The play() request was interrupted by a new load request`),
2094
+ * 而打断之后**没有人会再播一次**,播放器就停在 paused 上。
2095
+ *
2096
+ * 这不是推演:VisibilityPlugin 从 `reload()` 换到 `reloadStream()` 的第一版就是同步紧跟着
2097
+ * `play()`,单跑全绿,**跑满 worker 的全量里三次挂两次** —— 竞态按负载翻脸(#359)。
2098
+ *
2099
+ * @returns 重新拉流这件事**做完**之后 resolve;**永不 reject**(失败交给调用方的
2100
+ * error 事件 / 看门狗,那才是判定恢复与否的地方)
2101
+ */
2102
+ function reloadStream(player) {
2103
+ const url = player?.config?.url;
2104
+ if (player?.switchURL && typeof url === "string" && url && !url.startsWith("blob:")) try {
2105
+ return player.switchURL(url).then(() => "switchURL", () => "switchURL");
2106
+ } catch {
2107
+ return Promise.resolve("switchURL");
2108
+ }
2109
+ if (player?.reload) {
2110
+ try {
2111
+ player.reload();
2112
+ } catch {}
2113
+ return Promise.resolve("reload");
2114
+ }
2115
+ return Promise.resolve("none");
2116
+ }
2117
+ //#endregion
1774
2118
  //#region src/plugins/visibility.ts
1775
2119
  const DEFAULT_CONFIG$1 = {
1776
2120
  enabled: true,
@@ -1866,6 +2210,10 @@ var VisibilityPlugin = class extends BasePlugin {
1866
2210
  /** 探测:currentTime 没推进 或 仍 paused → 判定被系统冻死 → 重新拉流自愈 */
1867
2211
  recoverIfStalled() {
1868
2212
  if (numberOr(this.surface.currentTime, 0) > this.lastTime && this.surface.paused === false) return;
2213
+ if (this.visibilityConfig.requestRecovery) {
2214
+ this.visibilityConfig.requestRecovery();
2215
+ return;
2216
+ }
1869
2217
  this.visibilityConfig.onRecoveryDetected?.({
1870
2218
  trigger: "visibility",
1871
2219
  maxAttempts: 1
@@ -2040,111 +2388,86 @@ var ZIndexGuardPlugin = class extends BasePlugin {
2040
2388
  }
2041
2389
  };
2042
2390
  //#endregion
2043
- //#region src/recovery-evidence.ts
2391
+ //#region src/recovery-playback.ts
2044
2392
  /**
2045
- * 将各恢复策略的意图变成同一条、可验证的遥测生命周期(ADR-079)。
2046
- *
2047
- * 它不改变插件的重试或播放策略;只在 `playing` 后又观测到位置推进时记录恢复成功。
2393
+ * 观察一次恢复动作的强播放证据。仅 playing 后正常位置推进算成功;
2394
+ * seek、暂停和等待会重置基准。调用方负责把成功关联到当前动作凭据。
2048
2395
  */
2049
- var RecoveryEvidenceCoordinator = class {
2050
- player;
2051
- emit;
2052
- nextRecoveryId = 1;
2053
- active = null;
2054
- validationTimer = null;
2055
- constructor(player, emit) {
2056
- this.player = player;
2057
- this.emit = emit;
2058
- player.on("playing", this.onPlaying);
2059
- player.on("timeupdate", this.onTimeUpdate);
2060
- }
2061
- detected(input) {
2062
- if (this.active) {
2063
- if (this.active.strategy === input.strategy && this.active.trigger === input.trigger) return;
2064
- this.cancel("superseded");
2065
- }
2066
- this.active = {
2067
- recoveryId: this.nextRecoveryId++,
2068
- ...input,
2069
- attempt: 1,
2070
- maxAttempts: input.maxAttempts,
2071
- validatingFrom: null,
2072
- sawPlaying: false
2073
- };
2074
- this.emitPhase("detected");
2075
- }
2076
- attempting(strategy, input) {
2077
- if (!this.active || this.active.strategy !== strategy) return;
2078
- this.clearValidationTimer();
2079
- this.active.attempt = input.attempt;
2080
- this.active.maxAttempts = input.maxAttempts;
2081
- this.active.reason = input.reason;
2082
- this.active.validatingFrom = null;
2083
- this.active.sawPlaying = false;
2084
- this.emitPhase("attempting");
2085
- }
2086
- validating(strategy, timeoutMs) {
2087
- if (!this.active || this.active.strategy !== strategy) return;
2088
- this.clearValidationTimer();
2089
- this.active.validatingFrom = this.player.currentTime;
2090
- this.active.sawPlaying = false;
2091
- this.emitPhase("validating");
2092
- if (timeoutMs === void 0) return;
2093
- this.validationTimer = setTimeout(() => this.failed(strategy, "timeout"), timeoutMs);
2094
- }
2095
- failed(strategy, outcome) {
2096
- if (!this.active || this.active.strategy !== strategy) return;
2097
- this.clearValidationTimer();
2098
- this.emitPhase("failed", { outcome });
2099
- this.active = null;
2100
- }
2101
- cancel(outcome) {
2102
- if (!this.active) return;
2103
- this.clearValidationTimer();
2104
- this.emitPhase("cancelled", { outcome });
2105
- this.active = null;
2106
- }
2107
- dispose() {
2108
- this.cancel("destroyed");
2109
- this.player.off("playing", this.onPlaying);
2110
- this.player.off("timeupdate", this.onTimeUpdate);
2111
- }
2112
- onPlaying = () => {
2113
- const active = this.active;
2114
- if (!active || active.validatingFrom === null) return;
2115
- active.sawPlaying = true;
2116
- };
2117
- onTimeUpdate = () => {
2118
- const active = this.active;
2119
- if (!active?.sawPlaying || active.validatingFrom === null) return;
2120
- if (this.player.currentTime <= active.validatingFrom) return;
2121
- this.clearValidationTimer();
2122
- this.emitPhase("recovered", { validatedBy: "playing_position_advance" });
2123
- this.active = null;
2396
+ function observeRecoveryPlayback(media, signal, recovered) {
2397
+ let baseline = null;
2398
+ let playingSeen = false;
2399
+ let disposed = false;
2400
+ const reset = () => {
2401
+ baseline = null;
2402
+ playingSeen = false;
2124
2403
  };
2125
- clearValidationTimer() {
2126
- if (this.validationTimer === null) return;
2127
- clearTimeout(this.validationTimer);
2128
- this.validationTimer = null;
2129
- }
2130
- emitPhase(phase, terminal = {}) {
2131
- const active = this.active;
2132
- if (!active) return;
2133
- this.emit({
2134
- event: "recovery",
2135
- payload: {
2136
- recoveryId: active.recoveryId,
2137
- phase,
2138
- strategy: active.strategy,
2139
- trigger: active.trigger,
2140
- attempt: active.attempt,
2141
- maxAttempts: active.maxAttempts,
2142
- ...active.reason ? { reason: active.reason } : {},
2143
- ...terminal
2144
- }
2145
- });
2146
- }
2147
- };
2404
+ const playing = () => {
2405
+ playingSeen = true;
2406
+ baseline = Number.isFinite(media.currentTime) ? media.currentTime : null;
2407
+ };
2408
+ const seeking = () => {
2409
+ baseline = null;
2410
+ };
2411
+ const seeked = () => {
2412
+ if (playingSeen && !media.paused && !media.seeking && media.readyState >= 2) baseline = Number.isFinite(media.currentTime) ? media.currentTime : null;
2413
+ };
2414
+ const resets = [
2415
+ "pause",
2416
+ "waiting",
2417
+ "emptied",
2418
+ "ended"
2419
+ ];
2420
+ const dispose = () => {
2421
+ if (disposed) return;
2422
+ disposed = true;
2423
+ media.removeEventListener("playing", playing);
2424
+ media.removeEventListener("timeupdate", progress);
2425
+ media.removeEventListener("seeking", seeking);
2426
+ media.removeEventListener("seeked", seeked);
2427
+ for (const event of resets) media.removeEventListener(event, reset);
2428
+ signal.removeEventListener("abort", dispose);
2429
+ };
2430
+ const progress = () => {
2431
+ if (disposed || baseline === null || media.paused || media.seeking || media.readyState < 2) return;
2432
+ if (!Number.isFinite(media.currentTime) || media.currentTime <= baseline) return;
2433
+ dispose();
2434
+ recovered();
2435
+ };
2436
+ if (signal.aborted) return dispose;
2437
+ media.addEventListener("playing", playing);
2438
+ media.addEventListener("timeupdate", progress);
2439
+ media.addEventListener("seeking", seeking);
2440
+ media.addEventListener("seeked", seeked);
2441
+ for (const event of resets) media.addEventListener(event, reset);
2442
+ signal.addEventListener("abort", dispose, { once: true });
2443
+ return dispose;
2444
+ }
2445
+ /** 媒体修复会重新挂载 MSE;先订阅 canplay,避免对尚未挂载的元素提前 play。 */
2446
+ function waitForMediaRepair(media, signal, repair) {
2447
+ if (signal.aborted) return Promise.resolve(false);
2448
+ return new Promise((resolve, reject) => {
2449
+ const cleanup = () => {
2450
+ media.removeEventListener("canplay", ready);
2451
+ signal.removeEventListener("abort", abort);
2452
+ };
2453
+ const ready = () => {
2454
+ cleanup();
2455
+ resolve(true);
2456
+ };
2457
+ const abort = () => {
2458
+ cleanup();
2459
+ resolve(false);
2460
+ };
2461
+ media.addEventListener("canplay", ready, { once: true });
2462
+ signal.addEventListener("abort", abort, { once: true });
2463
+ try {
2464
+ if (!repair()) abort();
2465
+ } catch (error) {
2466
+ cleanup();
2467
+ reject(error);
2468
+ }
2469
+ });
2470
+ }
2148
2471
  //#endregion
2149
2472
  //#region src/runtime-profile.ts
2150
2473
  /**
@@ -2323,7 +2646,7 @@ function observeMediaPlayRejection(player) {
2323
2646
  };
2324
2647
  }
2325
2648
  /**
2326
- * 拿 PlayableStatePlugin 实例。走 `getPlugin(pluginName)`,同 `handle.reconnect` 的做法。
2649
+ * 拿 PlayableStatePlugin 实例。走 `getPlugin(pluginName)`,同 `handle.retry` 的做法。
2327
2650
  *
2328
2651
  * 返回 undefined 的情况都是良性的:player 还没构造好、插件被 `enabled:false` 关掉、
2329
2652
  * 或 xgplayer 换了 API。调用方一律用可选链,喂不进去就等于这一路信号没有,
@@ -2418,7 +2741,7 @@ function unsupportedRoutePayload(sessionId, source, env) {
2418
2741
  };
2419
2742
  }
2420
2743
  function kernelPlugin(kernel) {
2421
- if (kernel === "hls.js") return HlsPlugin;
2744
+ if (kernel === "hls.js") return OwnedHlsPlugin;
2422
2745
  if (kernel === "flv.js") return FlvPlugin;
2423
2746
  return null;
2424
2747
  }
@@ -2450,36 +2773,162 @@ function createPlayer(options) {
2450
2773
  const config = resolvePreset(parsed.data.preset, parsed.data);
2451
2774
  const normalized = normalizeSource(config.source);
2452
2775
  let sessionId = newSessionId();
2776
+ let deliverySequence = 0;
2777
+ const makeDelivered = (event) => {
2778
+ deliverySequence += 1;
2779
+ return {
2780
+ event,
2781
+ producerSessionId: sessionId,
2782
+ deliveryId: `${sessionId}:${deliverySequence}`,
2783
+ occurredAtMs: Date.now(),
2784
+ sequence: deliverySequence
2785
+ };
2786
+ };
2787
+ const notify = (delivered) => {
2788
+ try {
2789
+ options.onEvent?.(delivered.event);
2790
+ } catch {}
2791
+ try {
2792
+ options.onDeliveredEvent?.(delivered);
2793
+ } catch {}
2794
+ };
2453
2795
  let routed;
2454
2796
  try {
2455
2797
  routed = routeSource(normalized, env);
2456
2798
  } catch (error) {
2457
- if (error instanceof SentinelError && error.playerError.code === "E_MEDIA_NOT_SUPPORTED") options.onEvent?.({
2799
+ if (error instanceof SentinelError && error.playerError.code === "E_MEDIA_NOT_SUPPORTED") notify(makeDelivered({
2458
2800
  event: "sourceroute",
2459
2801
  payload: unsupportedRoutePayload(sessionId, normalized, env)
2460
- });
2802
+ }));
2461
2803
  throw error;
2462
2804
  }
2463
2805
  let destroyed = false;
2806
+ let loadRevision = 0;
2807
+ let recovery = null;
2464
2808
  let eventStreamReady = false;
2465
2809
  const deferredEvents = [];
2810
+ const reportRecoveryFault = (event, eventSession) => {
2811
+ 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);
2812
+ };
2466
2813
  const emit = (event) => {
2467
2814
  if (destroyed) return;
2815
+ const delivered = makeDelivered(event);
2468
2816
  if (!eventStreamReady) {
2469
- deferredEvents.push(event);
2817
+ deferredEvents.push(delivered);
2470
2818
  return;
2471
2819
  }
2472
- options.onEvent?.(event);
2820
+ const eventSession = sessionId;
2821
+ notify(delivered);
2822
+ reportRecoveryFault(event, eventSession);
2473
2823
  };
2474
2824
  let playerRef = null;
2475
2825
  const playableState = () => getPlayableState(playerRef);
2476
- let recoveryEvidence = null;
2477
2826
  const player = new Player(buildXgplayerConfig({
2478
2827
  ...options,
2479
2828
  config
2480
- }, routed, normalized, emit, env, playableState, () => recoveryEvidence));
2829
+ }, routed, normalized, emit, env, playableState, () => recovery));
2830
+ let pageQueue = Promise.resolve();
2831
+ let pageRevision = 0;
2832
+ let applyingPageState = false;
2833
+ let pageExpired = false;
2834
+ const applyPageState = (active) => {
2835
+ applyingPageState = true;
2836
+ try {
2837
+ pageFullscreen.setActive(active);
2838
+ } finally {
2839
+ applyingPageState = false;
2840
+ }
2841
+ };
2842
+ const setPageFullscreen = (active) => {
2843
+ const revision = ++pageRevision;
2844
+ const operation = pageQueue.then(async () => {
2845
+ if (destroyed) throw new Error(PLAYER_DESTROYED_MESSAGE);
2846
+ if (pageExpired) throw new Error("网页全屏控制器已失效");
2847
+ let timer;
2848
+ const layout = Promise.resolve().then(() => options.pageFullscreen?.setActive(active)).then(() => {
2849
+ if (destroyed || pageExpired) {
2850
+ options.pageFullscreen?.dispose();
2851
+ throw new Error("网页全屏请求已失效");
2852
+ }
2853
+ });
2854
+ try {
2855
+ await Promise.race([layout, new Promise((_, reject) => {
2856
+ timer = setTimeout(() => {
2857
+ pageExpired = true;
2858
+ reject(/* @__PURE__ */ new Error("网页全屏确认超时"));
2859
+ }, 1e4);
2860
+ })]);
2861
+ if (revision === pageRevision) applyPageState(active);
2862
+ } catch (error) {
2863
+ options.pageFullscreen?.dispose();
2864
+ if (!destroyed) applyPageState(false);
2865
+ throw error;
2866
+ } finally {
2867
+ clearTimeout(timer);
2868
+ }
2869
+ });
2870
+ pageQueue = operation.catch(() => {});
2871
+ return operation;
2872
+ };
2873
+ const pageFullscreen = attachPageFullscreen(player, {
2874
+ ...options.pageFullscreen ? { onRequest: (active) => {
2875
+ setPageFullscreen(active).catch(() => {});
2876
+ } } : {},
2877
+ onChange: (active) => {
2878
+ emit({
2879
+ event: "pagefullscreenchange",
2880
+ payload: { active }
2881
+ });
2882
+ if (!active && !applyingPageState && options.pageFullscreen && !options.pageFullscreenPending) setPageFullscreen(false).catch(() => {});
2883
+ }
2884
+ });
2885
+ const onPageEscape = (event) => {
2886
+ if (event.key === "Escape" && player.isCssfullScreen) setPageFullscreen(false).catch(() => {});
2887
+ };
2888
+ if (options.pageFullscreen && !options.pageFullscreenPending) {
2889
+ pageFullscreen.setAvailable(true);
2890
+ options.el.ownerDocument.addEventListener("keydown", onPageEscape);
2891
+ }
2481
2892
  playerRef = player;
2482
- recoveryEvidence = new RecoveryEvidenceCoordinator(player, emit);
2893
+ let desiredPlayback = config.autoplay ?? false;
2894
+ let recoveryCleanup = null;
2895
+ const stopRecoveryLoad = () => {
2896
+ if (routed.kernel === "hls.js") player.getPlugin("HlsJsPlugin")?.stopLoading();
2897
+ else if (routed.kernel === "flv.js") player.__flv__?.unload();
2898
+ else {
2899
+ player.pause();
2900
+ if (player.video instanceof HTMLMediaElement) {
2901
+ player.video.removeAttribute("src");
2902
+ player.video.load();
2903
+ }
2904
+ }
2905
+ };
2906
+ recovery = new PlaybackRecovery(sessionId, async (token, signal) => {
2907
+ recoveryCleanup?.();
2908
+ const media = player.video;
2909
+ if (!(media instanceof HTMLMediaElement)) throw new Error("Recovery requires a media element");
2910
+ const cleanup = () => {
2911
+ unobserve();
2912
+ signal.removeEventListener("abort", abort);
2913
+ if (recoveryCleanup === cleanup) recoveryCleanup = null;
2914
+ };
2915
+ const abort = () => {
2916
+ if (recoveryCleanup !== cleanup) return;
2917
+ cleanup();
2918
+ stopRecoveryLoad();
2919
+ };
2920
+ const unobserve = observeRecoveryPlayback(media, signal, () => {
2921
+ cleanup();
2922
+ recovery?.recovered(token);
2923
+ });
2924
+ recoveryCleanup = cleanup;
2925
+ signal.addEventListener("abort", abort, { once: true });
2926
+ const repaired = token.strategy === "media_recovery" && token.attempt === 1 && routed.kernel === "hls.js" && await waitForMediaRepair(media, signal, () => player.getPlugin("HlsJsPlugin")?.recoverMediaError() ?? false);
2927
+ if (signal.aborted || destroyed) return;
2928
+ if (!repaired) await player.switchURL(currentSrcUrl);
2929
+ if (!signal.aborted && !destroyed && recovery?.playingIntent) await player.play();
2930
+ }, emit, (state) => playableState()?.setRecovery(state));
2931
+ recovery.setPlayingIntent(desiredPlayback);
2483
2932
  const listeners = [];
2484
2933
  const listen = (name, handler) => {
2485
2934
  listeners.push([name, handler]);
@@ -2494,7 +2943,11 @@ function createPlayer(options) {
2494
2943
  const foreground = typeof document === "undefined" || document.visibilityState !== "hidden";
2495
2944
  flvAudioHealth?.setActive(flvPlaybackActive && foreground);
2496
2945
  };
2497
- const onVisibilityChange = () => syncFlvAudioHealthActivity();
2946
+ const onVisibilityChange = () => {
2947
+ syncFlvAudioHealthActivity();
2948
+ recovery?.setSuspended(document.hidden);
2949
+ armStartupDeadline();
2950
+ };
2498
2951
  if (typeof document !== "undefined") {
2499
2952
  document.addEventListener("visibilitychange", onVisibilityChange);
2500
2953
  disposers.push(() => document.removeEventListener("visibilitychange", onVisibilityChange));
@@ -2565,15 +3018,19 @@ function createPlayer(options) {
2565
3018
  payload: selectedRoutePayload(sessionId, normalized, routed, env)
2566
3019
  });
2567
3020
  emitContextChange();
2568
- for (const event of deferredEvents) emit(event);
3021
+ for (const delivered of deferredEvents) {
3022
+ notify(delivered);
3023
+ reportRecoveryFault(delivered.event, delivered.producerSessionId);
3024
+ }
2569
3025
  deferredEvents.length = 0;
2570
3026
  const attachQualityListener = () => {
2571
3027
  if (destroyed) return;
2572
3028
  const hls = getHlsInstance(player);
2573
- if (!hls?.on || !(hls.levels && hls.levels.length >= 2)) return;
2574
3029
  if (hls === qualityListenerTarget) return;
2575
3030
  detachQualityListener?.();
3031
+ if (!hls?.on || !(hls.levels && hls.levels.length >= 2)) return;
2576
3032
  const onSwitched = (...args) => {
3033
+ if (destroyed || getHlsInstance(player) !== hls) return;
2577
3034
  const data = args.at(-1);
2578
3035
  const level = typeof data?.level === "number" ? data.level : hls.currentLevel;
2579
3036
  if (level < 0) return;
@@ -2596,6 +3053,19 @@ function createPlayer(options) {
2596
3053
  };
2597
3054
  };
2598
3055
  hlsCleanups.push(() => detachQualityListener?.());
3056
+ let naturalProof = null;
3057
+ const observeNaturalPlayback = () => {
3058
+ naturalProof?.abort();
3059
+ naturalProof = null;
3060
+ const media = player.video;
3061
+ if (!(media instanceof HTMLMediaElement) || destroyed) return;
3062
+ naturalProof = new AbortController();
3063
+ observeRecoveryPlayback(media, naturalProof.signal, () => {
3064
+ recovery?.naturalRecovered();
3065
+ observeNaturalPlayback();
3066
+ });
3067
+ };
3068
+ disposers.push(() => naturalProof?.abort());
2599
3069
  let sawFirstFrame = false;
2600
3070
  let sawPlaybackStart = false;
2601
3071
  let sawPlaying = false;
@@ -2604,6 +3074,27 @@ function createPlayer(options) {
2604
3074
  let lastPlaybackPosition = null;
2605
3075
  let seeking = false;
2606
3076
  let prematureVodEnd = null;
3077
+ let startupTimer = null;
3078
+ const clearStartupDeadline = () => {
3079
+ if (startupTimer !== null) clearTimeout(startupTimer);
3080
+ startupTimer = null;
3081
+ };
3082
+ const armStartupDeadline = () => {
3083
+ clearStartupDeadline();
3084
+ if (!desiredPlayback || sawFirstFrame || seeking || document.hidden || destroyed) return;
3085
+ startupTimer = setTimeout(() => {
3086
+ startupTimer = null;
3087
+ recovery?.requestRecovery("startup_timeout");
3088
+ }, 15e3);
3089
+ };
3090
+ const setPlayingIntent = (playing) => {
3091
+ desiredPlayback = playing;
3092
+ recovery?.setPlayingIntent(playing);
3093
+ armStartupDeadline();
3094
+ };
3095
+ disposers.push(clearStartupDeadline);
3096
+ recovery.setSuspended(document.hidden);
3097
+ armStartupDeadline();
2607
3098
  const resetPrematureVodEndState = () => {
2608
3099
  sawFirstFrame = false;
2609
3100
  sawPlaybackStart = false;
@@ -2637,6 +3128,7 @@ function createPlayer(options) {
2637
3128
  });
2638
3129
  });
2639
3130
  listen("play", () => {
3131
+ observeNaturalPlayback();
2640
3132
  sawPlaybackStart = true;
2641
3133
  flvPlaybackActive = true;
2642
3134
  syncFlvAudioHealthActivity();
@@ -2660,25 +3152,25 @@ function createPlayer(options) {
2660
3152
  });
2661
3153
  });
2662
3154
  listen("ended", () => {
2663
- if (prematureVodEnd !== null) {
2664
- emit({
2665
- event: "error",
2666
- payload: makePlayerError("E_MEDIA_DECODE", "点播媒体在达到结尾前异常跳转并结束", {
2667
- kind: "premature_vod_end",
2668
- ...prematureVodEnd
2669
- })
2670
- });
2671
- playableState()?.setError(true);
2672
- } else if (!normalized.live && routed.kernel === "native" && safeNumber(player.duration) > 0 && sawPlaybackStart && sawStartupWaiting && !sawPlaying && !sawFirstFrame && !sawNativeError) {
2673
- emit({
2674
- event: "error",
2675
- payload: makePlayerError("E_MEDIA_DECODE", "点播媒体未解出首帧即结束", {
2676
- kind: "initial_frame_decode_failure",
2677
- duration: safeNumber(player.duration),
2678
- endTime: safeNumber(player.currentTime)
2679
- })
2680
- });
2681
- playableState()?.setError(true);
3155
+ if (prematureVodEnd !== null) emit({
3156
+ event: "error",
3157
+ payload: makePlayerError("E_MEDIA_DECODE", "点播媒体在达到结尾前异常跳转并结束", {
3158
+ kind: "premature_vod_end",
3159
+ ...prematureVodEnd
3160
+ })
3161
+ });
3162
+ else if (!normalized.live && routed.kernel === "native" && safeNumber(player.duration) > 0 && sawPlaybackStart && sawStartupWaiting && !sawPlaying && !sawFirstFrame && !sawNativeError) emit({
3163
+ event: "error",
3164
+ payload: makePlayerError("E_MEDIA_DECODE", "点播媒体未解出首帧即结束", {
3165
+ kind: "initial_frame_decode_failure",
3166
+ duration: safeNumber(player.duration),
3167
+ endTime: safeNumber(player.currentTime)
3168
+ })
3169
+ });
3170
+ else if (sawFirstFrame && !normalized.live && !config.loop) {
3171
+ desiredPlayback = false;
3172
+ clearStartupDeadline();
3173
+ recovery?.endPlayback();
2682
3174
  }
2683
3175
  emit({
2684
3176
  event: "ended",
@@ -2719,6 +3211,7 @@ function createPlayer(options) {
2719
3211
  });
2720
3212
  listen("seeking", () => {
2721
3213
  seeking = true;
3214
+ clearStartupDeadline();
2722
3215
  lastPlaybackPosition = safeNumber(player.currentTime);
2723
3216
  prematureVodEnd = null;
2724
3217
  emit({
@@ -2727,6 +3220,8 @@ function createPlayer(options) {
2727
3220
  });
2728
3221
  });
2729
3222
  listen("seeked", () => {
3223
+ seeking = false;
3224
+ armStartupDeadline();
2730
3225
  emit({
2731
3226
  event: "seeked",
2732
3227
  payload: { time: safeNumber(player.currentTime) }
@@ -2739,6 +3234,7 @@ function createPlayer(options) {
2739
3234
  const fvt = log.fvt;
2740
3235
  if (typeof fvt !== "number" || !Number.isFinite(fvt)) return;
2741
3236
  sawFirstFrame = true;
3237
+ clearStartupDeadline();
2742
3238
  emit({
2743
3239
  event: "firstframe",
2744
3240
  payload: { fvt }
@@ -2755,6 +3251,7 @@ function createPlayer(options) {
2755
3251
  listen("user_action", (raw) => {
2756
3252
  const payload = normalizeUserAction(raw);
2757
3253
  if (payload === null) return;
3254
+ if (payload.action === "switch_play_pause" && typeof payload.to === "boolean") setPlayingIntent(!payload.to);
2758
3255
  emit({
2759
3256
  event: "useraction",
2760
3257
  payload
@@ -2768,7 +3265,6 @@ function createPlayer(options) {
2768
3265
  event: "error",
2769
3266
  payload: mapped
2770
3267
  });
2771
- playableState()?.setError(true);
2772
3268
  });
2773
3269
  /**
2774
3270
  * FLV 内核诊断 → 契约 error 流(#403)。
@@ -2848,12 +3344,11 @@ function createPlayer(options) {
2848
3344
  event: "error",
2849
3345
  payload: mapped
2850
3346
  });
2851
- playableState()?.setError(true);
2852
3347
  };
2853
3348
  target.on("error", onError);
2854
3349
  detachFlvDiagnostics = () => {
2855
- target.off?.("error", onError);
2856
3350
  detachFlvDiagnostics = null;
3351
+ if (target._emitter !== null) target.off?.("error", onError);
2857
3352
  };
2858
3353
  };
2859
3354
  hlsCleanups.push(() => detachFlvDiagnostics?.());
@@ -2949,14 +3444,15 @@ function createPlayer(options) {
2949
3444
  };
2950
3445
  target.on("media_info", onMediaInfo);
2951
3446
  detachFlvAudioHealth = () => {
2952
- target.off?.("media_info", onMediaInfo);
3447
+ detachFlvAudioHealth = () => {};
3448
+ if (target._emitter !== null) target.off?.("media_info", onMediaInfo);
2953
3449
  for (const cleanup of sourceBufferCleanups) cleanup();
2954
3450
  sourceBufferCleanups.length = 0;
2955
3451
  flvAudioHealth?.dispose();
2956
3452
  flvAudioHealth = null;
2957
3453
  };
2958
- hlsCleanups.push(() => detachFlvAudioHealth());
2959
3454
  };
3455
+ hlsCleanups.push(() => detachFlvAudioHealth());
2960
3456
  {
2961
3457
  const holder = player;
2962
3458
  let current = holder.__flv__;
@@ -2969,6 +3465,10 @@ function createPlayer(options) {
2969
3465
  get: () => current,
2970
3466
  set: (v) => {
2971
3467
  current = v;
3468
+ if (v === void 0 || v === null) {
3469
+ detachFlvDiagnostics?.();
3470
+ detachFlvAudioHealth();
3471
+ }
2972
3472
  if (v !== void 0 && v !== null) {
2973
3473
  attachFlvDiagnostics(v);
2974
3474
  attachFlvAudioHealth(v);
@@ -3037,7 +3537,6 @@ function createPlayer(options) {
3037
3537
  event: "error",
3038
3538
  payload: mapped
3039
3539
  });
3040
- playableState()?.setError(true);
3041
3540
  });
3042
3541
  return {
3043
3542
  get destroyed() {
@@ -3045,6 +3544,7 @@ function createPlayer(options) {
3045
3544
  },
3046
3545
  async play() {
3047
3546
  if (destroyed) return;
3547
+ setPlayingIntent(true);
3048
3548
  try {
3049
3549
  await player.play();
3050
3550
  } catch (err) {
@@ -3053,6 +3553,7 @@ function createPlayer(options) {
3053
3553
  },
3054
3554
  pause() {
3055
3555
  if (destroyed) return;
3556
+ setPlayingIntent(false);
3056
3557
  player.pause();
3057
3558
  },
3058
3559
  seek(time) {
@@ -3071,6 +3572,12 @@ function createPlayer(options) {
3071
3572
  if (destroyed) return;
3072
3573
  player.playbackRate = clamp(rate, .25, 4);
3073
3574
  },
3575
+ setPageFullscreen,
3576
+ syncPageFullscreen(active, available) {
3577
+ pageRevision += 1;
3578
+ pageFullscreen.setAvailable(available);
3579
+ applyPageState(active);
3580
+ },
3074
3581
  async enterFullscreen() {
3075
3582
  if (destroyed) return;
3076
3583
  await player.getFullscreen();
@@ -3154,9 +3661,20 @@ function createPlayer(options) {
3154
3661
  if (nextRouted.kernel !== routed.kernel) throwPlayerError("E_METHOD_NOT_SUPPORTED", `换源需要从 ${routed.kernel} 内核切到 ${nextRouted.kernel},运行时无法切换内核。请销毁当前 player 并用新的 source 重建。`);
3155
3662
  const constructionChanges = sourceConstructionChanges(normalized, nextNormalized);
3156
3663
  if (constructionChanges.length > 0) throwPlayerError("E_METHOD_NOT_SUPPORTED", `换源不能改变构造期 source 配置(${constructionChanges.join("、")})。播放器仍使用创建时的直播、HLS 与字幕插件配置。请销毁当前 player 并用新的 source 重建。`);
3157
- recoveryEvidence?.cancel("source_changed");
3664
+ const revision = ++loadRevision;
3665
+ naturalProof?.abort();
3666
+ recovery?.setSession(nextSessionId);
3667
+ if (destroyed || revision !== loadRevision) return;
3668
+ playableState()?.setRecovery({
3669
+ recovering: false,
3670
+ failed: false,
3671
+ action: "none"
3672
+ });
3673
+ if (destroyed || revision !== loadRevision) return;
3158
3674
  resetPrematureVodEndState();
3159
3675
  sessionId = nextSessionId;
3676
+ deliverySequence = 0;
3677
+ armStartupDeadline();
3160
3678
  currentSrcUrl = nextRouted.chosen.url;
3161
3679
  currentMediaType = nextRouted.chosen.type;
3162
3680
  currentQualityLevel = null;
@@ -3164,14 +3682,16 @@ function createPlayer(options) {
3164
3682
  event: "sourceroute",
3165
3683
  payload: selectedRoutePayload(sessionId, nextNormalized, nextRouted, env)
3166
3684
  });
3685
+ if (destroyed || revision !== loadRevision) return;
3167
3686
  emitContextChange();
3687
+ if (destroyed || revision !== loadRevision) return;
3168
3688
  await player.switchURL(nextRouted.chosen.url);
3689
+ if (destroyed || revision !== loadRevision) return;
3169
3690
  getMediaSession(player)?.setMetadata(nextNormalized.metadata);
3170
- getReconnectPlugin(player)?.resetForNewSource?.();
3171
3691
  },
3172
- reconnect(options) {
3173
- if (destroyed) return;
3174
- getReconnectPlugin(player)?.manualReconnect?.(options?.resetCounter ?? false);
3692
+ async retry() {
3693
+ clearStartupDeadline();
3694
+ await recovery?.retry();
3175
3695
  },
3176
3696
  getPlaybackContext() {
3177
3697
  return readPlaybackContext();
@@ -3189,8 +3709,12 @@ function createPlayer(options) {
3189
3709
  */
3190
3710
  destroy() {
3191
3711
  if (destroyed) return;
3192
- recoveryEvidence?.dispose();
3712
+ recovery?.destroy();
3713
+ recoveryCleanup?.();
3193
3714
  destroyed = true;
3715
+ options.pageFullscreen?.dispose();
3716
+ pageFullscreen.destroy();
3717
+ options.el.ownerDocument.removeEventListener("keydown", onPageEscape);
3194
3718
  for (const [name, handler] of listeners) player.off(name, handler);
3195
3719
  listeners.length = 0;
3196
3720
  for (const cleanup of hlsCleanups) cleanup();
@@ -3254,14 +3778,12 @@ function extendInstanceI18n(player, locale, lang) {
3254
3778
  if (!instanceI18n) return;
3255
3779
  I18N.extend(entries, instanceI18n);
3256
3780
  }
3257
- function buildXgplayerConfig(options, routed, normalized, emit, env, playableState, recoveryEvidence) {
3781
+ function buildXgplayerConfig(options, routed, normalized, emit, env, playableState, recovery) {
3258
3782
  const { config } = options;
3259
3783
  const live = normalized.live;
3260
3784
  const plugins = [
3261
3785
  SafeDestroyPlugin,
3262
3786
  AutoplayGuardPlugin,
3263
- ReconnectPlugin,
3264
- ErrorRecoveryPlugin,
3265
3787
  VisibilityPlugin,
3266
3788
  CompatPlugin,
3267
3789
  WakeLockPlugin,
@@ -3282,7 +3804,12 @@ function buildXgplayerConfig(options, routed, normalized, emit, env, playableSta
3282
3804
  el: options.el,
3283
3805
  url: routed.chosen.url,
3284
3806
  isLive: live,
3285
- ignores: ["loading"],
3807
+ ignores: [
3808
+ "loading",
3809
+ "enter",
3810
+ ...config.controlVisibility?.volume === false ? ["volume"] : [],
3811
+ ...config.controlVisibility?.time === false ? ["time"] : []
3812
+ ],
3286
3813
  ...toXgLang(config.locale) ? { lang: toXgLang(config.locale) } : {},
3287
3814
  ...(() => {
3288
3815
  const lang = toXgLang(config.locale);
@@ -3306,65 +3833,16 @@ function buildXgplayerConfig(options, routed, normalized, emit, env, playableSta
3306
3833
  preferManagedMediaSource: false
3307
3834
  } } } : {},
3308
3835
  autoplayGuard: { onAutoplayBlocked: () => {
3836
+ recovery()?.setPlayingIntent(false);
3309
3837
  emit({
3310
3838
  event: "autoplayblocked",
3311
3839
  payload: {}
3312
3840
  });
3313
3841
  playableState()?.setAutoplayBlocked(true);
3314
3842
  } },
3315
- reconnect: {
3316
- onReconnectStart: (payload) => {
3317
- emit({
3318
- event: "reconnectstart",
3319
- payload
3320
- });
3321
- playableState()?.setReconnecting(true);
3322
- },
3323
- onReconnectSuccess: (payload) => {
3324
- emit({
3325
- event: "reconnectsuccess",
3326
- payload
3327
- });
3328
- playableState()?.setReconnecting(false);
3329
- },
3330
- onReconnectFailed: (payload) => {
3331
- emit({
3332
- event: "reconnectfailed",
3333
- payload
3334
- });
3335
- playableState()?.setReconnecting(false);
3336
- playableState()?.setError(true);
3337
- },
3338
- onRecoveryDetected: ({ trigger, maxAttempts, reason }) => recoveryEvidence()?.detected({
3339
- strategy: "reconnect",
3340
- trigger,
3341
- maxAttempts,
3342
- reason
3343
- }),
3344
- onRecoveryAttempting: (payload) => recoveryEvidence()?.attempting("reconnect", payload),
3345
- onRecoveryValidating: ({ timeoutMs }) => recoveryEvidence()?.validating("reconnect", timeoutMs),
3346
- onRecoveryFailed: ({ outcome }) => recoveryEvidence()?.failed("reconnect", outcome)
3347
- },
3348
- errorRecovery: {
3349
- onRecoveryDetected: ({ trigger, maxAttempts, reason }) => recoveryEvidence()?.detected({
3350
- strategy: "media_recovery",
3351
- trigger,
3352
- maxAttempts,
3353
- reason
3354
- }),
3355
- onRecoveryAttempting: (payload) => recoveryEvidence()?.attempting("media_recovery", payload),
3356
- onRecoveryValidating: () => recoveryEvidence()?.validating("media_recovery", 15e3),
3357
- onRecoveryFailed: ({ outcome }) => recoveryEvidence()?.failed("media_recovery", outcome)
3358
- },
3359
3843
  visibility: {
3360
3844
  isIOS: env.isIOS,
3361
- onRecoveryDetected: ({ trigger, maxAttempts }) => recoveryEvidence()?.detected({
3362
- strategy: "visibility_reload",
3363
- trigger,
3364
- maxAttempts
3365
- }),
3366
- onRecoveryAttempting: (payload) => recoveryEvidence()?.attempting("visibility_reload", payload),
3367
- onRecoveryValidating: () => recoveryEvidence()?.validating("visibility_reload", 15e3)
3845
+ requestRecovery: () => recovery()?.requestRecovery("visibility")
3368
3846
  },
3369
3847
  compat: {
3370
3848
  ua: env.ua,
@@ -3381,6 +3859,7 @@ function buildXgplayerConfig(options, routed, normalized, emit, env, playableSta
3381
3859
  payload
3382
3860
  });
3383
3861
  playableState()?.setStalled(payload.phase === "start");
3862
+ if (payload.phase === "start" && payload.kind === "playback") recovery()?.requestRecovery("stall");
3384
3863
  },
3385
3864
  onBufferHealth: (payload) => {
3386
3865
  emit({
@@ -3404,19 +3883,15 @@ function buildXgplayerConfig(options, routed, normalized, emit, env, playableSta
3404
3883
  ...config.startTime === void 0 ? {} : { startTime: config.startTime },
3405
3884
  ...config.poster === void 0 ? {} : { poster: typeof config.poster === "string" ? config.poster : config.poster.url },
3406
3885
  controls: config.controls !== false,
3407
- pip: true
3886
+ ...config.controlVisibility?.play === false ? { play: { disable: true } } : {},
3887
+ ...config.controlVisibility?.progress === false ? { progress: { disable: true } } : {},
3888
+ ...config.controlVisibility?.fullscreen === false ? { fullscreen: { disable: true } } : {},
3889
+ ...config.controlVisibility?.cssFullscreen === false ? { cssFullscreen: { disable: true } } : {},
3890
+ ...config.controlVisibility?.playbackRate === false ? { playbackRate: { isShowIcon: false } } : {},
3891
+ pip: config.controlVisibility?.pip !== false
3408
3892
  };
3409
3893
  }
3410
3894
  /**
3411
- * 取 ReconnectPlugin 实例。`getPlugin` 用的是 pluginName(`'reconnect'`)。
3412
- *
3413
- * **收成一个函数**:`handle.reconnect` 和 `load()` 的换源复位都要拿它,
3414
- * 两处各写一遍 `as unknown as { getPlugin?… }` 就是两份会漂的类型断言。
3415
- */
3416
- function getReconnectPlugin(player) {
3417
- return player.getPlugin?.("reconnect");
3418
- }
3419
- /**
3420
3895
  * 取 xgplayer-hls.js 插件持有的 hls.js 实例。非 HLS 源(MP4 走原生、FLV 走 flv.js)
3421
3896
  * 拿不到,返回 undefined —— 上层据此把多码率相关能力降级成 no-op / 空档位。
3422
3897
  */