@video-lab/player-core 2.0.0 → 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/README.md +16 -1
- package/dist/index.cjs +1044 -569
- package/dist/index.d.cts +17 -6
- package/dist/index.d.mts +17 -6
- package/dist/index.mjs +1044 -569
- package/package.json +4 -4
package/dist/index.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
|
|
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$
|
|
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$
|
|
1201
|
+
return { ...DEFAULT_CONFIG$6 };
|
|
562
1202
|
}
|
|
563
1203
|
get compatConfig() {
|
|
564
1204
|
return {
|
|
565
|
-
...DEFAULT_CONFIG$
|
|
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$
|
|
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$
|
|
1251
|
+
return { ...DEFAULT_CONFIG$5 };
|
|
725
1252
|
}
|
|
726
1253
|
patchedVideo = null;
|
|
727
1254
|
originalEnter;
|
|
728
1255
|
get guardConfig() {
|
|
729
1256
|
return {
|
|
730
|
-
...DEFAULT_CONFIG$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
-
|
|
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-
|
|
2391
|
+
//#region src/recovery-playback.ts
|
|
2044
2392
|
/**
|
|
2045
|
-
*
|
|
2046
|
-
*
|
|
2047
|
-
* 它不改变插件的重试或播放策略;只在 `playing` 后又观测到位置推进时记录恢复成功。
|
|
2393
|
+
* 观察一次恢复动作的强播放证据。仅 playing 后正常位置推进算成功;
|
|
2394
|
+
* seek、暂停和等待会重置基准。调用方负责把成功关联到当前动作凭据。
|
|
2048
2395
|
*/
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
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
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
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.
|
|
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
|
|
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")
|
|
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(
|
|
2817
|
+
deferredEvents.push(delivered);
|
|
2470
2818
|
return;
|
|
2471
2819
|
}
|
|
2472
|
-
|
|
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, () =>
|
|
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
|
-
|
|
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 = () =>
|
|
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
|
|
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
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
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
|
-
|
|
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,
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*/
|