eb-player 2.0.18 → 2.0.20
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/build/eb-player.css +9 -0
- package/dist/build/ebplayer.bundle.js +461 -42
- package/dist/build/ebplayer.bundle.js.map +1 -1
- package/dist/build/types/core/event-bus.d.ts +2 -0
- package/dist/build/types/core/event-bus.d.ts.map +1 -1
- package/dist/build/types/core/i18n.d.ts.map +1 -1
- package/dist/build/types/core/lifecycle.d.ts.map +1 -1
- package/dist/build/types/core/player-state.d.ts.map +1 -1
- package/dist/build/types/core/types.d.ts +1 -0
- package/dist/build/types/core/types.d.ts.map +1 -1
- package/dist/build/types/eb-player.d.ts.map +1 -1
- package/dist/build/types/engines/base-engine.d.ts +16 -0
- package/dist/build/types/engines/base-engine.d.ts.map +1 -1
- package/dist/build/types/engines/dash.d.ts.map +1 -1
- package/dist/build/types/engines/hls.d.ts.map +1 -1
- package/dist/build/types/engines/retry/hls.d.ts +13 -1
- package/dist/build/types/engines/retry/hls.d.ts.map +1 -1
- package/dist/build/types/integrations/ads-manager.d.ts +6 -1
- package/dist/build/types/integrations/ads-manager.d.ts.map +1 -1
- package/dist/build/types/integrations/index.d.ts +1 -0
- package/dist/build/types/integrations/index.d.ts.map +1 -1
- package/dist/build/types/integrations/intro-stream-manager.d.ts +33 -0
- package/dist/build/types/integrations/intro-stream-manager.d.ts.map +1 -0
- package/dist/build/types/skin/controllers/auto-hide.d.ts +2 -2
- package/dist/build/types/skin/controllers/auto-hide.d.ts.map +1 -1
- package/dist/build/types/skin/controllers/keyboard.d.ts +1 -1
- package/dist/build/types/skin/controls/forward-button.d.ts +1 -1
- package/dist/build/types/skin/controls/forward-button.d.ts.map +1 -1
- package/dist/build/types/skin/controls/rewind-button.d.ts +1 -1
- package/dist/build/types/skin/controls/rewind-button.d.ts.map +1 -1
- package/dist/build/types/skin/controls/seekbar.d.ts.map +1 -1
- package/dist/build/types/skin/overlays/preroll-overlay.d.ts +39 -0
- package/dist/build/types/skin/overlays/preroll-overlay.d.ts.map +1 -0
- package/dist/build/types/skin/skin-root.d.ts +8 -0
- package/dist/build/types/skin/skin-root.d.ts.map +1 -1
- package/dist/eb-player.css +9 -0
- package/package.json +19 -20
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.EBPlayer = {}));
|
|
5
5
|
})(this, (function (exports) { 'use strict';
|
|
6
6
|
|
|
7
|
-
var __EB_PLAYER_VERSION__ = "2.0.
|
|
7
|
+
var __EB_PLAYER_VERSION__ = "2.0.20";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Finite State Machine for player playback state transitions.
|
|
@@ -118,6 +118,7 @@
|
|
|
118
118
|
socialsOpen: false,
|
|
119
119
|
infoOpen: false,
|
|
120
120
|
adPlaying: false,
|
|
121
|
+
introStreamPlaying: false,
|
|
121
122
|
castAvailable: false,
|
|
122
123
|
isRtl: false,
|
|
123
124
|
isRadio: false,
|
|
@@ -608,6 +609,9 @@
|
|
|
608
609
|
},
|
|
609
610
|
'settings.auto': {
|
|
610
611
|
en: 'Auto', fr: 'Auto', ar: 'تلقائي', es: 'Auto'
|
|
612
|
+
},
|
|
613
|
+
'preroll.skip': {
|
|
614
|
+
en: 'Skip', fr: 'Passer', ar: 'تخطي', es: 'Saltar'
|
|
611
615
|
}
|
|
612
616
|
};
|
|
613
617
|
/**
|
|
@@ -1371,6 +1375,7 @@
|
|
|
1371
1375
|
this.state.on('bufferedEnd', () => this.scheduleRender(), { signal: this.signal });
|
|
1372
1376
|
this.state.on('isRtl', () => this.scheduleRender(), { signal: this.signal });
|
|
1373
1377
|
this.state.on('adPlaying', () => this.scheduleRender(), { signal: this.signal });
|
|
1378
|
+
this.state.on('introStreamPlaying', () => this.scheduleRender(), { signal: this.signal });
|
|
1374
1379
|
this.state.on('chapters', () => this.scheduleRender(), { signal: this.signal });
|
|
1375
1380
|
this.state.on('epgPrograms', () => this.scheduleRender(), { signal: this.signal });
|
|
1376
1381
|
this.render();
|
|
@@ -1438,7 +1443,7 @@
|
|
|
1438
1443
|
}
|
|
1439
1444
|
// ---- Drag handlers ----
|
|
1440
1445
|
handlePointerDown(event) {
|
|
1441
|
-
if (this.state.adPlaying)
|
|
1446
|
+
if (this.state.adPlaying || this.state.introStreamPlaying)
|
|
1442
1447
|
return;
|
|
1443
1448
|
const trackEl = event.currentTarget;
|
|
1444
1449
|
this.trackEl = trackEl;
|
|
@@ -1545,9 +1550,9 @@
|
|
|
1545
1550
|
}
|
|
1546
1551
|
// ---- Template ----
|
|
1547
1552
|
template() {
|
|
1548
|
-
const { currentTime, duration, bufferedEnd, adPlaying, chapters, epgPrograms } = this.state;
|
|
1553
|
+
const { currentTime, duration, bufferedEnd, adPlaying, introStreamPlaying, chapters, epgPrograms } = this.state;
|
|
1549
1554
|
const isSeekbarHidden = !this.config.seekbar;
|
|
1550
|
-
const isDisabled = adPlaying;
|
|
1555
|
+
const isDisabled = adPlaying || introStreamPlaying;
|
|
1551
1556
|
// Progress percentage — use dragValue during drag to prevent live-update jitter
|
|
1552
1557
|
const progressPercent = duration > 0
|
|
1553
1558
|
? (this.isDragging ? this.dragValue : currentTime) / duration * 100
|
|
@@ -2442,15 +2447,16 @@
|
|
|
2442
2447
|
*
|
|
2443
2448
|
* Displayed in the middle bar beside the play/pause button.
|
|
2444
2449
|
* Renders a circular arrow icon with the seek offset number below.
|
|
2445
|
-
* Hidden during ad playback.
|
|
2450
|
+
* Hidden during ad playback or intro-stream playback.
|
|
2446
2451
|
*/
|
|
2447
2452
|
class RewindButton extends BaseComponent {
|
|
2448
2453
|
onConnect() {
|
|
2449
2454
|
this.state.on('adPlaying', () => this.render(), { signal: this.signal });
|
|
2455
|
+
this.state.on('introStreamPlaying', () => this.render(), { signal: this.signal });
|
|
2450
2456
|
this.render();
|
|
2451
2457
|
}
|
|
2452
2458
|
template() {
|
|
2453
|
-
if (this.state.adPlaying) {
|
|
2459
|
+
if (this.state.adPlaying || this.state.introStreamPlaying) {
|
|
2454
2460
|
return b ``;
|
|
2455
2461
|
}
|
|
2456
2462
|
const offset = this.config.seekOffset || 15;
|
|
@@ -2482,15 +2488,16 @@
|
|
|
2482
2488
|
*
|
|
2483
2489
|
* Displayed in the middle bar beside the play/pause button.
|
|
2484
2490
|
* Renders a circular arrow icon with the seek offset number below.
|
|
2485
|
-
* Hidden during ad playback.
|
|
2491
|
+
* Hidden during ad playback or intro-stream playback.
|
|
2486
2492
|
*/
|
|
2487
2493
|
class ForwardButton extends BaseComponent {
|
|
2488
2494
|
onConnect() {
|
|
2489
2495
|
this.state.on('adPlaying', () => this.render(), { signal: this.signal });
|
|
2496
|
+
this.state.on('introStreamPlaying', () => this.render(), { signal: this.signal });
|
|
2490
2497
|
this.render();
|
|
2491
2498
|
}
|
|
2492
2499
|
template() {
|
|
2493
|
-
if (this.state.adPlaying) {
|
|
2500
|
+
if (this.state.adPlaying || this.state.introStreamPlaying) {
|
|
2494
2501
|
return b ``;
|
|
2495
2502
|
}
|
|
2496
2503
|
const offset = this.config.seekOffset || 15;
|
|
@@ -2781,6 +2788,101 @@
|
|
|
2781
2788
|
}
|
|
2782
2789
|
}
|
|
2783
2790
|
|
|
2791
|
+
/**
|
|
2792
|
+
* PrerollOverlay — V1 parity for the intro-stream skip + click-through UX.
|
|
2793
|
+
*
|
|
2794
|
+
* Two responsibilities:
|
|
2795
|
+
* 1. Skip button + countdown (D-13 / D-14). `Number.isFinite(config.prerollSkip)`
|
|
2796
|
+
* gates existence. While the countdown is > 0 the button shows `Skip (N)` and
|
|
2797
|
+
* is disabled. At 0 it shows `Skip` and is clickable. Clicking emits
|
|
2798
|
+
* `preroll-skip` on the bus.
|
|
2799
|
+
* 2. Whole-overlay click-through (D-16, INTRO-04). Clicks landing on the
|
|
2800
|
+
* overlay (outside the skip button) open `config.prerollLink` in a new
|
|
2801
|
+
* tab with the security argument required by ASVS V14 / T-07-05 (blocks
|
|
2802
|
+
* reverse tabnabbing and Referer leaks). Skip-button clicks
|
|
2803
|
+
* `stopPropagation()` so they never reach this handler.
|
|
2804
|
+
*
|
|
2805
|
+
* Hidden (empty div) when `state.introStreamPlaying === false`.
|
|
2806
|
+
*
|
|
2807
|
+
* The skip countdown is derived from intro PLAYBACK progress
|
|
2808
|
+
* (`state.currentTime`), not a wall-clock timer — so it only advances while the
|
|
2809
|
+
* intro is actually playing. While the intro is paused (e.g. `autoplay:false`
|
|
2810
|
+
* waiting for the user's gesture, or buffering) the countdown holds. Without
|
|
2811
|
+
* this the skip control would unlock before the user ever saw the intro play.
|
|
2812
|
+
* State subscriptions auto-clean via the `{ signal }` option — no manual timer
|
|
2813
|
+
* teardown is required.
|
|
2814
|
+
*/
|
|
2815
|
+
class PrerollOverlay extends BaseComponent {
|
|
2816
|
+
onConnect() {
|
|
2817
|
+
this.state.on('introStreamPlaying', () => this.render(), { signal: this.signal });
|
|
2818
|
+
// currentTime drives the countdown; re-render as intro playback advances.
|
|
2819
|
+
this.state.on('currentTime', () => this.render(), { signal: this.signal });
|
|
2820
|
+
this.render();
|
|
2821
|
+
}
|
|
2822
|
+
/**
|
|
2823
|
+
* Seconds remaining before the intro can be skipped, derived from intro
|
|
2824
|
+
* playback position (`state.currentTime`). Infinity when prerollSkip is
|
|
2825
|
+
* Infinity (no skip button). Clamped at 0 (never negative).
|
|
2826
|
+
*/
|
|
2827
|
+
remainingSkip() {
|
|
2828
|
+
const prerollSkip = this.config.prerollSkip;
|
|
2829
|
+
if (!Number.isFinite(prerollSkip))
|
|
2830
|
+
return Infinity;
|
|
2831
|
+
return Math.max(0, Math.ceil(prerollSkip - this.state.currentTime));
|
|
2832
|
+
}
|
|
2833
|
+
template() {
|
|
2834
|
+
if (!this.state.introStreamPlaying) {
|
|
2835
|
+
return b `<div class="eb-preroll-overlay" hidden aria-hidden="true"></div>`;
|
|
2836
|
+
}
|
|
2837
|
+
const prerollSkip = this.config.prerollSkip;
|
|
2838
|
+
const prerollLink = this.config.prerollLink;
|
|
2839
|
+
const showSkip = Number.isFinite(prerollSkip);
|
|
2840
|
+
const remaining = this.remainingSkip();
|
|
2841
|
+
const skipClickable = remaining <= 0;
|
|
2842
|
+
const label = this.i18n?.t('preroll.skip') ?? 'Skip';
|
|
2843
|
+
return b `
|
|
2844
|
+
<div
|
|
2845
|
+
class="eb-preroll-overlay"
|
|
2846
|
+
@click="${(event) => this.handleOverlayClick(event, prerollLink)}"
|
|
2847
|
+
>
|
|
2848
|
+
${showSkip
|
|
2849
|
+
? b `<button
|
|
2850
|
+
class="eb-preroll-skip${skipClickable ? '' : ' eb-preroll-skip--locked'}"
|
|
2851
|
+
aria-label="${label}"
|
|
2852
|
+
?disabled="${!skipClickable}"
|
|
2853
|
+
@click="${(event) => this.handleSkip(event)}"
|
|
2854
|
+
>${label}${remaining > 0 ? b ` (${remaining})` : ''}</button>`
|
|
2855
|
+
: ''}
|
|
2856
|
+
</div>
|
|
2857
|
+
`;
|
|
2858
|
+
}
|
|
2859
|
+
handleSkip(event) {
|
|
2860
|
+
event.stopPropagation();
|
|
2861
|
+
if (this.remainingSkip() > 0)
|
|
2862
|
+
return;
|
|
2863
|
+
this.bus.emit('preroll-skip');
|
|
2864
|
+
}
|
|
2865
|
+
handleOverlayClick(_event, prerollLink) {
|
|
2866
|
+
if (!prerollLink)
|
|
2867
|
+
return;
|
|
2868
|
+
// Reject non-http(s) URIs (blocks javascript:, data:, vbscript:, file:, …).
|
|
2869
|
+
// prerollLink is integrator-provided and may originate from a compromised
|
|
2870
|
+
// CMS; blindly passing it to window.open would let a javascript: URI run
|
|
2871
|
+
// in the user's session. See REVIEW.md CR-04.
|
|
2872
|
+
try {
|
|
2873
|
+
const url = new URL(prerollLink, window.location.href);
|
|
2874
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
2875
|
+
console.warn('PrerollOverlay: refusing to open non-http(s) prerollLink', prerollLink);
|
|
2876
|
+
return;
|
|
2877
|
+
}
|
|
2878
|
+
window.open(url.href, '_blank', 'noopener,noreferrer');
|
|
2879
|
+
}
|
|
2880
|
+
catch {
|
|
2881
|
+
console.warn('PrerollOverlay: invalid prerollLink', prerollLink);
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2784
2886
|
/**
|
|
2785
2887
|
* ForjaPlaylistBar renders a horizontal episode list in the bottom-extra
|
|
2786
2888
|
* extension zone for the Forja brand skin.
|
|
@@ -3152,7 +3254,8 @@
|
|
|
3152
3254
|
[new ErrorMessage(), 'eb-error-slot'],
|
|
3153
3255
|
[new SocialsOverlay(), 'eb-socials-slot'],
|
|
3154
3256
|
[new InfoOverlay(), 'eb-info-slot'],
|
|
3155
|
-
[new ToastNotification(), 'eb-toast-slot']
|
|
3257
|
+
[new ToastNotification(), 'eb-toast-slot'],
|
|
3258
|
+
[new PrerollOverlay(), 'eb-preroll-slot']
|
|
3156
3259
|
];
|
|
3157
3260
|
for (const [component, slotClass] of interactiveOverlays) {
|
|
3158
3261
|
const slot = document.createElement('div');
|
|
@@ -3221,6 +3324,11 @@
|
|
|
3221
3324
|
state.on('controlsVisible', () => {
|
|
3222
3325
|
this.updateControlsVisibility();
|
|
3223
3326
|
}, { signal });
|
|
3327
|
+
// Toggle ad-playing class so the IMA ads container can receive clicks
|
|
3328
|
+
// (skip button, click-through) and sit above the player chrome during ads.
|
|
3329
|
+
state.on('adPlaying', () => {
|
|
3330
|
+
this.updateAdPlayingState();
|
|
3331
|
+
}, { signal });
|
|
3224
3332
|
}
|
|
3225
3333
|
/**
|
|
3226
3334
|
* Toggles poster image visibility based on playbackState.
|
|
@@ -3295,6 +3403,19 @@
|
|
|
3295
3403
|
inner.classList.toggle('eb-controls-visible', visible);
|
|
3296
3404
|
inner.classList.toggle('eb-controls-hidden', !visible);
|
|
3297
3405
|
}
|
|
3406
|
+
/**
|
|
3407
|
+
* Toggles the eb-ad-playing class on the player container. While an IMA ad
|
|
3408
|
+
* plays, CSS raises the ads container above the player chrome and enables
|
|
3409
|
+
* pointer-events on it so IMA's own ad UI (Skip button, click-through) is
|
|
3410
|
+
* clickable — the container is pointer-events:none at rest to avoid blocking
|
|
3411
|
+
* clicks on the content video.
|
|
3412
|
+
*/
|
|
3413
|
+
updateAdPlayingState() {
|
|
3414
|
+
const inner = this.el?.firstElementChild;
|
|
3415
|
+
if (inner == null)
|
|
3416
|
+
return;
|
|
3417
|
+
inner.classList.toggle('eb-ad-playing', this.state.adPlaying);
|
|
3418
|
+
}
|
|
3298
3419
|
/**
|
|
3299
3420
|
* Applies or removes the dir=rtl attribute without full re-render.
|
|
3300
3421
|
* More efficient than re-rendering the entire template for a simple attribute change.
|
|
@@ -3360,8 +3481,8 @@
|
|
|
3360
3481
|
* AutoHideController manages the 3-second auto-hide timer for player controls.
|
|
3361
3482
|
*
|
|
3362
3483
|
* On any user activity (pointermove, touchstart, keyup), the timer resets to 3s.
|
|
3363
|
-
* When the timer fires, controls are hidden — but ONLY if no panel is open
|
|
3364
|
-
* no ad is playing.
|
|
3484
|
+
* When the timer fires, controls are hidden — but ONLY if no panel is open,
|
|
3485
|
+
* no ad is playing, and no intro stream is playing.
|
|
3365
3486
|
*
|
|
3366
3487
|
* Note: Uses manual removeEventListener instead of the { signal } option in
|
|
3367
3488
|
* addEventListener. JSDOM 28 rejects non-JSDOM AbortSignal instances in the
|
|
@@ -3406,7 +3527,8 @@
|
|
|
3406
3527
|
const shouldKeepVisible = this.state.settingsOpen ||
|
|
3407
3528
|
this.state.socialsOpen ||
|
|
3408
3529
|
this.state.infoOpen ||
|
|
3409
|
-
this.state.adPlaying
|
|
3530
|
+
this.state.adPlaying ||
|
|
3531
|
+
this.state.introStreamPlaying;
|
|
3410
3532
|
if (!shouldKeepVisible) {
|
|
3411
3533
|
this.state.controlsVisible = false;
|
|
3412
3534
|
}
|
|
@@ -3421,7 +3543,7 @@
|
|
|
3421
3543
|
* ArrowRight: seek forward by config.seekOffset seconds (clamped to duration).
|
|
3422
3544
|
* m: mutes/unmutes — only when config.supportHotKeys is true.
|
|
3423
3545
|
*
|
|
3424
|
-
* All shortcuts are disabled during ads (state.adPlaying
|
|
3546
|
+
* All shortcuts are disabled during ads or intro streams (state.adPlaying || state.introStreamPlaying).
|
|
3425
3547
|
*
|
|
3426
3548
|
* The container must be focusable to receive keyboard events.
|
|
3427
3549
|
* If container.tabIndex < 0, it is set to 0 automatically.
|
|
@@ -3443,8 +3565,8 @@
|
|
|
3443
3565
|
}
|
|
3444
3566
|
const handleKeyup = (event) => {
|
|
3445
3567
|
const keyEvent = event;
|
|
3446
|
-
// Ignore all keys during ad playback
|
|
3447
|
-
if (state.adPlaying) {
|
|
3568
|
+
// Ignore all keys during ad playback or intro-stream playback
|
|
3569
|
+
if (state.adPlaying || state.introStreamPlaying) {
|
|
3448
3570
|
return;
|
|
3449
3571
|
}
|
|
3450
3572
|
if (keyEvent.key === ' ') {
|
|
@@ -3776,15 +3898,28 @@
|
|
|
3776
3898
|
}
|
|
3777
3899
|
/**
|
|
3778
3900
|
* Initialize the IMA SDK preroll ad system.
|
|
3779
|
-
* - Skips if config.
|
|
3901
|
+
* - Skips if config.ad is undefined
|
|
3780
3902
|
* - If !config.autoplay: waits for user click on video element before initializing
|
|
3781
3903
|
* (required by browser autoplay policies to avoid muted/blocked display contexts)
|
|
3782
3904
|
* - Loads IMA SDK, creates AdDisplayContainer, AdsLoader, and AdsRequest
|
|
3783
3905
|
* - Wires ad lifecycle events to PlayerState and TypedEventBus
|
|
3784
3906
|
*/
|
|
3785
3907
|
async init(config, state, bus, video, adsContainer, signal) {
|
|
3786
|
-
if (!config.
|
|
3908
|
+
if (!config.ad)
|
|
3787
3909
|
return;
|
|
3910
|
+
// CR-01: D-10 intro → ad → main precedence. If an intro stream is currently
|
|
3911
|
+
// playing, defer IMA initialization until the bus emits 'intro-stream-complete'.
|
|
3912
|
+
// Without this gate, IMA fires CONTENT_PAUSE_REQUESTED and tries to play the ad
|
|
3913
|
+
// in the same <video> element the intro is using — the two streams race for
|
|
3914
|
+
// the element with order determined by network/SDK timing.
|
|
3915
|
+
if (state.introStreamPlaying) {
|
|
3916
|
+
await new Promise((resolve) => {
|
|
3917
|
+
bus.on('intro-stream-complete', () => resolve(), { signal });
|
|
3918
|
+
signal.addEventListener('abort', () => resolve(), { once: true });
|
|
3919
|
+
});
|
|
3920
|
+
if (signal.aborted)
|
|
3921
|
+
return;
|
|
3922
|
+
}
|
|
3788
3923
|
// Gate initialization on user gesture when not in autoplay mode
|
|
3789
3924
|
if (!config.autoplay) {
|
|
3790
3925
|
await this.waitForUserGesture(video, signal);
|
|
@@ -3797,16 +3932,38 @@
|
|
|
3797
3932
|
this.imaDisplayContainer = displayContainer;
|
|
3798
3933
|
displayContainer.initialize();
|
|
3799
3934
|
const adsLoader = new ima.AdsLoader(displayContainer);
|
|
3935
|
+
// AD_ERROR fall-through (CR-02): if the VAST tag fails to load (network
|
|
3936
|
+
// error, ad-blocker, malformed XML, autoplay rejection), IMA fires
|
|
3937
|
+
// AD_ERROR and never ALL_ADS_COMPLETED. Without this handler the
|
|
3938
|
+
// orchestration in eb-player.ts would wait forever for 'ad-complete'
|
|
3939
|
+
// and the main stream would never open. Treat AD_ERROR as a normal
|
|
3940
|
+
// completion so the orchestration always advances.
|
|
3941
|
+
adsLoader.addEventListener(ima.AdErrorEvent.Type.AD_ERROR, (errorEvent) => {
|
|
3942
|
+
console.warn('AdsManager: AD_ERROR (loader) — falling through to main', errorEvent);
|
|
3943
|
+
state.adPlaying = false;
|
|
3944
|
+
bus.emit('ad-complete');
|
|
3945
|
+
});
|
|
3800
3946
|
adsLoader.addEventListener(ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED, (loadedEvent) => {
|
|
3801
3947
|
const event = loadedEvent;
|
|
3802
3948
|
const adsManager = event.getAdsManager(video);
|
|
3803
3949
|
this.imaAdsManager = adsManager;
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3950
|
+
// getBoundingClientRect is always defined on HTMLElement; the prior
|
|
3951
|
+
// truthy check on the method reference was dead code. The real edge
|
|
3952
|
+
// case is a zero-sized element (display:none, before layout) which
|
|
3953
|
+
// would make IMA's ads container collapse — fall back to a 16:9
|
|
3954
|
+
// baseline in that case.
|
|
3955
|
+
const rect = video.getBoundingClientRect();
|
|
3956
|
+
const width = rect.width > 0 ? rect.width : 640;
|
|
3957
|
+
const height = rect.height > 0 ? rect.height : 360;
|
|
3807
3958
|
// Wire event handlers for ad lifecycle
|
|
3808
3959
|
adsManager.addEventListener(ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED, () => {
|
|
3809
3960
|
state.adPlaying = true;
|
|
3961
|
+
// Pause the content stream while the ad plays. IMA's integration
|
|
3962
|
+
// contract requires the publisher to pause its own content video on
|
|
3963
|
+
// CONTENT_PAUSE_REQUESTED; the symmetric CONTENT_RESUME_REQUESTED
|
|
3964
|
+
// handler below resumes it with video.play(). Without this the main
|
|
3965
|
+
// HLS/DASH stream keeps playing (audio + video) underneath the ad.
|
|
3966
|
+
video.pause();
|
|
3810
3967
|
});
|
|
3811
3968
|
adsManager.addEventListener(ima.AdEvent.Type.CONTENT_RESUME_REQUESTED, () => {
|
|
3812
3969
|
state.adPlaying = false;
|
|
@@ -3816,12 +3973,20 @@
|
|
|
3816
3973
|
state.adPlaying = false;
|
|
3817
3974
|
bus.emit('ad-complete');
|
|
3818
3975
|
});
|
|
3976
|
+
// Same AD_ERROR fall-through on the manager — covers errors that
|
|
3977
|
+
// surface only after the ad starts playing (decode error, mid-roll
|
|
3978
|
+
// load failure, etc.).
|
|
3979
|
+
adsManager.addEventListener(ima.AdErrorEvent.Type.AD_ERROR, (errorEvent) => {
|
|
3980
|
+
console.warn('AdsManager: AD_ERROR (manager) — falling through to main', errorEvent);
|
|
3981
|
+
state.adPlaying = false;
|
|
3982
|
+
bus.emit('ad-complete');
|
|
3983
|
+
});
|
|
3819
3984
|
adsManager.init(width, height);
|
|
3820
3985
|
adsManager.start();
|
|
3821
3986
|
});
|
|
3822
3987
|
// Load the ad tag
|
|
3823
3988
|
const adsRequest = new ima.AdsRequest();
|
|
3824
|
-
adsRequest.adTagUrl = config.
|
|
3989
|
+
adsRequest.adTagUrl = config.ad;
|
|
3825
3990
|
adsLoader.requestAds(adsRequest);
|
|
3826
3991
|
// Register abort cleanup
|
|
3827
3992
|
signal.addEventListener('abort', () => {
|
|
@@ -3838,23 +4003,95 @@
|
|
|
3838
4003
|
/**
|
|
3839
4004
|
* Wait for a click event on the video element.
|
|
3840
4005
|
* Used to satisfy browser autoplay policies when autoplay is disabled.
|
|
4006
|
+
*
|
|
4007
|
+
* WR-05: single-shot settle flag guards against the click-vs-abort race —
|
|
4008
|
+
* Promise resolution is already idempotent but settled tightens the
|
|
4009
|
+
* contract by ensuring listener cleanup happens exactly once for either
|
|
4010
|
+
* outcome. Callers still distinguish outcomes via signal.aborted.
|
|
3841
4011
|
*/
|
|
3842
4012
|
waitForUserGesture(video, signal) {
|
|
3843
4013
|
return new Promise((resolve) => {
|
|
3844
|
-
|
|
4014
|
+
let settled = false;
|
|
4015
|
+
const settle = () => {
|
|
4016
|
+
if (settled)
|
|
4017
|
+
return;
|
|
4018
|
+
settled = true;
|
|
3845
4019
|
video.removeEventListener('click', onClick);
|
|
3846
4020
|
resolve();
|
|
3847
4021
|
};
|
|
4022
|
+
const onClick = () => settle();
|
|
3848
4023
|
video.addEventListener('click', onClick);
|
|
3849
|
-
// Clean up if aborted before click
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
resolve();
|
|
3853
|
-
}, { once: true });
|
|
4024
|
+
// Clean up if aborted before click. The abort listener auto-removes
|
|
4025
|
+
// via `{ once: true }`; the click listener is removed inside settle().
|
|
4026
|
+
signal.addEventListener('abort', settle, { once: true });
|
|
3854
4027
|
});
|
|
3855
4028
|
}
|
|
3856
4029
|
}
|
|
3857
4030
|
|
|
4031
|
+
/**
|
|
4032
|
+
* IntroStreamManager owns the intro-stream lifecycle.
|
|
4033
|
+
*
|
|
4034
|
+
* - On init(), if config.preroll is set: flips state.introStreamPlaying = true and
|
|
4035
|
+
* subscribes to:
|
|
4036
|
+
* - the video element's native `ended` event
|
|
4037
|
+
* - bus `preroll-skip` (from the PrerollOverlay skip button)
|
|
4038
|
+
* - bus `error-fatal` (D-04 silent fall-through — intro must never block main)
|
|
4039
|
+
* - Any one of those triggers calls a closure `complete()` exactly once
|
|
4040
|
+
* (idempotent via a `completed` boolean guard), which flips
|
|
4041
|
+
* state.introStreamPlaying = false and emits `intro-stream-complete`.
|
|
4042
|
+
* - On abort, the video `ended` listener is removed explicitly via
|
|
4043
|
+
* removeEventListener. Bus listeners auto-clean via the `{ signal }` option
|
|
4044
|
+
* (see src/core/event-bus.ts on()).
|
|
4045
|
+
*
|
|
4046
|
+
* Scope: this manager listens and emits — it does NOT itself open the intro
|
|
4047
|
+
* stream. The engine open() call is owned by eb-player.ts (single source of
|
|
4048
|
+
* engine selection + DRM wiring), driven by the orchestration around this
|
|
4049
|
+
* manager's `intro-stream-complete` event.
|
|
4050
|
+
*/
|
|
4051
|
+
class IntroStreamManager {
|
|
4052
|
+
/**
|
|
4053
|
+
* Initialize intro-stream lifecycle tracking.
|
|
4054
|
+
* - Skips entirely if config.preroll is falsy (no state flip, no listeners).
|
|
4055
|
+
* - Otherwise flips state.introStreamPlaying = true and registers the three
|
|
4056
|
+
* completion triggers (video `ended`, bus `preroll-skip`, bus `error-fatal`).
|
|
4057
|
+
*/
|
|
4058
|
+
async init(config, state, bus, video, signal) {
|
|
4059
|
+
if (!config.preroll)
|
|
4060
|
+
return;
|
|
4061
|
+
state.introStreamPlaying = true;
|
|
4062
|
+
// Child AbortController scoped to the intro pass. We tear it down inside
|
|
4063
|
+
// complete() so the bus listeners (in particular 'error-fatal') do not
|
|
4064
|
+
// outlive the intro stream. Without this, a fatal error on the MAIN
|
|
4065
|
+
// stream would re-fire this handler and log the misleading
|
|
4066
|
+
// "intro stream errored" message (WR-01).
|
|
4067
|
+
const local = new AbortController();
|
|
4068
|
+
// Mirror outer aborts onto the local controller so dispose() still
|
|
4069
|
+
// cleans everything up.
|
|
4070
|
+
signal.addEventListener('abort', () => local.abort(), { once: true });
|
|
4071
|
+
let completed = false;
|
|
4072
|
+
const complete = () => {
|
|
4073
|
+
if (completed)
|
|
4074
|
+
return;
|
|
4075
|
+
completed = true;
|
|
4076
|
+
state.introStreamPlaying = false;
|
|
4077
|
+
bus.emit('intro-stream-complete');
|
|
4078
|
+
local.abort(); // tear down bus listeners after the intro completes
|
|
4079
|
+
};
|
|
4080
|
+
const onEnded = () => complete();
|
|
4081
|
+
video.addEventListener('ended', onEnded);
|
|
4082
|
+
bus.on('preroll-skip', complete, { signal: local.signal });
|
|
4083
|
+
// D-04: intro stream errors fall through silently to main. Log a warning
|
|
4084
|
+
// and treat the failure as a normal completion.
|
|
4085
|
+
bus.on('error-fatal', () => {
|
|
4086
|
+
console.warn('IntroStreamManager: intro stream errored — falling through to main');
|
|
4087
|
+
complete();
|
|
4088
|
+
}, { signal: local.signal });
|
|
4089
|
+
signal.addEventListener('abort', () => {
|
|
4090
|
+
video.removeEventListener('ended', onEnded);
|
|
4091
|
+
}, { once: true });
|
|
4092
|
+
}
|
|
4093
|
+
}
|
|
4094
|
+
|
|
3858
4095
|
/**
|
|
3859
4096
|
* Poster HLS handler.
|
|
3860
4097
|
*
|
|
@@ -4100,8 +4337,8 @@
|
|
|
4100
4337
|
const playlistManager = new PlaylistManager();
|
|
4101
4338
|
playlistManager.init(config, state, bus, signal);
|
|
4102
4339
|
}
|
|
4103
|
-
// AdsManager: skip when config.
|
|
4104
|
-
if (config.
|
|
4340
|
+
// AdsManager: skip when config.ad is undefined (D-09)
|
|
4341
|
+
if (config.ad && this.skinRoot !== null) {
|
|
4105
4342
|
const adsManager = new AdsManager();
|
|
4106
4343
|
const video = this.skinRoot.getVideoElement();
|
|
4107
4344
|
const adsContainer = this.skinRoot.getAdsContainer();
|
|
@@ -4111,6 +4348,16 @@
|
|
|
4111
4348
|
});
|
|
4112
4349
|
}
|
|
4113
4350
|
}
|
|
4351
|
+
// IntroStreamManager: skip when config.preroll is false (D-02)
|
|
4352
|
+
if (config.preroll && this.skinRoot !== null) {
|
|
4353
|
+
const introManager = new IntroStreamManager();
|
|
4354
|
+
const video = this.skinRoot.getVideoElement();
|
|
4355
|
+
if (video !== null) {
|
|
4356
|
+
introManager.init(config, state, bus, video, signal).catch((error) => {
|
|
4357
|
+
console.error('EBPlayer: IntroStreamManager init failed:', error);
|
|
4358
|
+
});
|
|
4359
|
+
}
|
|
4360
|
+
}
|
|
4114
4361
|
// Poster handler: loop an HLS stream as video background when configured
|
|
4115
4362
|
if (config.posterStream) {
|
|
4116
4363
|
const posterHandler = createPosterHandler();
|
|
@@ -4255,6 +4502,14 @@
|
|
|
4255
4502
|
this.signal = null;
|
|
4256
4503
|
this.bus = null;
|
|
4257
4504
|
this.config = null;
|
|
4505
|
+
/**
|
|
4506
|
+
* The URL to load — set by reference.open(src) via setLoadSource().
|
|
4507
|
+
* Takes precedence over config.src in engine init so the intro stream URL
|
|
4508
|
+
* is loaded rather than the main stream URL (root cause of Phase 7 bug:
|
|
4509
|
+
* reference.open(preroll) wired config but engines read config.src, which
|
|
4510
|
+
* always points at the main stream).
|
|
4511
|
+
*/
|
|
4512
|
+
this.loadSourceUrl = '';
|
|
4258
4513
|
this.watchdog = null;
|
|
4259
4514
|
this.driverReady = new Promise((resolve) => {
|
|
4260
4515
|
this.resolveDriverReady = resolve;
|
|
@@ -4277,6 +4532,7 @@
|
|
|
4277
4532
|
this.signal = null;
|
|
4278
4533
|
this.bus = null;
|
|
4279
4534
|
this.config = null;
|
|
4535
|
+
this.loadSourceUrl = '';
|
|
4280
4536
|
}
|
|
4281
4537
|
// -------------------------------------------------------------------------
|
|
4282
4538
|
// Stall watchdog helpers
|
|
@@ -4318,6 +4574,16 @@
|
|
|
4318
4574
|
setVideo(video) {
|
|
4319
4575
|
this.video = video;
|
|
4320
4576
|
}
|
|
4577
|
+
/**
|
|
4578
|
+
* Set the URL that the engine driver should load.
|
|
4579
|
+
* Must be called by reference.open(src) BEFORE controller.setEngineSync()
|
|
4580
|
+
* so that onAttach() → init() picks up the correct URL.
|
|
4581
|
+
* This decouples the stream URL from config.src, which always points at the
|
|
4582
|
+
* main stream URL (set once at start() time and never mutated).
|
|
4583
|
+
*/
|
|
4584
|
+
setLoadSource(src) {
|
|
4585
|
+
this.loadSourceUrl = src;
|
|
4586
|
+
}
|
|
4321
4587
|
// -------------------------------------------------------------------------
|
|
4322
4588
|
// Video element event binding
|
|
4323
4589
|
// -------------------------------------------------------------------------
|
|
@@ -5093,7 +5359,8 @@
|
|
|
5093
5359
|
* automatic recovery:
|
|
5094
5360
|
* - Fatal mediaError: calls recoverMediaError() after 1s
|
|
5095
5361
|
* - Fatal keySystemError: unrecoverable (e.g. no HTTPS for DRM) — no retry
|
|
5096
|
-
* - Fatal other error: calls
|
|
5362
|
+
* - Fatal other error (intro pass): calls onFatalNetworkError() to fall through to main
|
|
5363
|
+
* - Fatal other error (main pass): calls startLoad() after 1s (keeps retrying)
|
|
5097
5364
|
* - Non-fatal error: re-checks after 3s; if currentTime has not advanced
|
|
5098
5365
|
* (and Chromecast is not casting), applies the same recovery
|
|
5099
5366
|
*
|
|
@@ -5112,6 +5379,8 @@
|
|
|
5112
5379
|
const exhaustedErrors = new Set();
|
|
5113
5380
|
/** Tracks unrecoverable fatal errors that have already been reported */
|
|
5114
5381
|
const reportedUnrecoverable = new Set();
|
|
5382
|
+
/** Ensures onFatalNetworkError is only called once per handler instance */
|
|
5383
|
+
let fatalNetworkErrorReported = false;
|
|
5115
5384
|
let lastSuccessfulTime;
|
|
5116
5385
|
driver.on('hlsError', (event, data) => {
|
|
5117
5386
|
const errorKey = data.details ?? data.type;
|
|
@@ -5132,6 +5401,17 @@
|
|
|
5132
5401
|
engine?.onUnrecoverableError?.(`DRM error: ${errorKey}`);
|
|
5133
5402
|
return;
|
|
5134
5403
|
}
|
|
5404
|
+
// Intro-pass fall-through: when onFatalNetworkError is provided, signal
|
|
5405
|
+
// the caller once and stop retrying. This lets the player fall through to
|
|
5406
|
+
// the main stream instead of looping startLoad() on an unreachable intro.
|
|
5407
|
+
if (engine?.onFatalNetworkError !== undefined) {
|
|
5408
|
+
if (fatalNetworkErrorReported)
|
|
5409
|
+
return;
|
|
5410
|
+
fatalNetworkErrorReported = true;
|
|
5411
|
+
console.warn(`HLS Retry: Fatal error on intro pass — signalling fall-through (${errorKey}).`, event, data);
|
|
5412
|
+
engine.onFatalNetworkError(errorKey);
|
|
5413
|
+
return;
|
|
5414
|
+
}
|
|
5135
5415
|
console.warn('HLS Retry: Fatal error, trying to fix now.', event, data);
|
|
5136
5416
|
setTimeout(() => {
|
|
5137
5417
|
if (data.type === 'mediaError')
|
|
@@ -5470,7 +5750,13 @@
|
|
|
5470
5750
|
applyDiscontinuityWorkaround(driver, Hls.Events);
|
|
5471
5751
|
// Wire retry handler — pass engine context so unrecoverable DRM errors
|
|
5472
5752
|
// stop the stall watchdog (prevents useless reload loops) and surface to UI.
|
|
5753
|
+
// For the intro pass (loadSourceUrl matches config.preroll), also wire
|
|
5754
|
+
// onFatalNetworkError so a permanently-unreachable intro manifest emits
|
|
5755
|
+
// error-fatal and falls through to main instead of looping startLoad() forever.
|
|
5756
|
+
// This callback is intentionally NOT set for the main stream so transient
|
|
5757
|
+
// network blips still trigger the normal startLoad() recovery.
|
|
5473
5758
|
if (config.retry) {
|
|
5759
|
+
const isIntroPass = Boolean(config.preroll) && this.loadSourceUrl === config.preroll;
|
|
5474
5760
|
handleHlsRetry(driver, {
|
|
5475
5761
|
get chromecast_casting() { return state.isCasting; },
|
|
5476
5762
|
onUnrecoverableError: (message) => {
|
|
@@ -5481,7 +5767,18 @@
|
|
|
5481
5767
|
// hls.js nulls its media on a fatal keySystemError; signal so the
|
|
5482
5768
|
// P2P SDK is torn down before it loops on a null media element.
|
|
5483
5769
|
this.bus?.emit('error-fatal', { code: 'DRM', message });
|
|
5484
|
-
}
|
|
5770
|
+
},
|
|
5771
|
+
...(isIntroPass
|
|
5772
|
+
? {
|
|
5773
|
+
onFatalNetworkError: (errorKey) => {
|
|
5774
|
+
// D-04: intro stream fatal network/manifest error — stop retrying
|
|
5775
|
+
// and signal fall-through to main. IntroStreamManager's error-fatal
|
|
5776
|
+
// listener completes the intro pass so openMainStream() can proceed.
|
|
5777
|
+
this.stopWatchdog();
|
|
5778
|
+
this.bus?.emit('error-fatal', { code: 'NETWORK', message: `Intro stream error: ${errorKey}` });
|
|
5779
|
+
}
|
|
5780
|
+
}
|
|
5781
|
+
: {}),
|
|
5485
5782
|
});
|
|
5486
5783
|
}
|
|
5487
5784
|
// Geo-block detection (opt-in, ported from v1.x).
|
|
@@ -5506,8 +5803,10 @@
|
|
|
5506
5803
|
this.bindVideoEvents(video, state, signal);
|
|
5507
5804
|
// Attach media and load source
|
|
5508
5805
|
driver.attachMedia(video);
|
|
5509
|
-
// Build source URL with token params if applicable
|
|
5510
|
-
|
|
5806
|
+
// Build source URL with token params if applicable.
|
|
5807
|
+
// Use loadSourceUrl (set by reference.open(src) via setLoadSource) so the intro
|
|
5808
|
+
// stream URL is loaded rather than config.src which always points at the main stream.
|
|
5809
|
+
let src = this.loadSourceUrl || config.src || '';
|
|
5511
5810
|
if (this.tokenManager && src) {
|
|
5512
5811
|
src = await this.tokenManager.updateUrlWithTokenParams({ url: src });
|
|
5513
5812
|
// Guard: abort if detached during token URL update
|
|
@@ -6015,7 +6314,9 @@
|
|
|
6015
6314
|
}
|
|
6016
6315
|
this.driver = player;
|
|
6017
6316
|
this.resolveDriverReady();
|
|
6018
|
-
|
|
6317
|
+
// Use loadSourceUrl (set by reference.open(src) via setLoadSource) so the intro
|
|
6318
|
+
// stream URL is loaded rather than config.src which always points at the main stream.
|
|
6319
|
+
player.initialize(video, this.loadSourceUrl || config.src || '', config.autoplay ?? false);
|
|
6019
6320
|
// Apply retry settings if requested
|
|
6020
6321
|
if (config.retry) {
|
|
6021
6322
|
applyDashRetrySettings(player);
|
|
@@ -6574,6 +6875,25 @@
|
|
|
6574
6875
|
return isDash ? new DashEngine() : new HlsEngine();
|
|
6575
6876
|
}
|
|
6576
6877
|
// ---------------------------------------------------------------------------
|
|
6878
|
+
// EME teardown timing
|
|
6879
|
+
// ---------------------------------------------------------------------------
|
|
6880
|
+
/**
|
|
6881
|
+
* Empirical delay to give the browser time to complete async EME teardown
|
|
6882
|
+
* (setMediaKeys(null)) before the next stream is opened on the same <video>.
|
|
6883
|
+
* hls.js destroy() does not await setMediaKeys(null), so reusing the element
|
|
6884
|
+
* too soon yields "The existing ContentDecryptor" errors on DRM streams.
|
|
6885
|
+
*
|
|
6886
|
+
* Tuned at 500ms: 100ms was too short on slower hardware. There is no
|
|
6887
|
+
* promise-able event for setMediaKeys(null) completion; switch this helper
|
|
6888
|
+
* to an event-driven wait once one exists.
|
|
6889
|
+
*/
|
|
6890
|
+
const EME_TEARDOWN_DELAY_MS = 500;
|
|
6891
|
+
function waitForEmeTeardown() {
|
|
6892
|
+
// TODO: replace with an event-driven wait once setMediaKeys(null) is
|
|
6893
|
+
// observably awaitable.
|
|
6894
|
+
return new Promise((resolve) => setTimeout(resolve, EME_TEARDOWN_DELAY_MS));
|
|
6895
|
+
}
|
|
6896
|
+
// ---------------------------------------------------------------------------
|
|
6577
6897
|
// Public API
|
|
6578
6898
|
// ---------------------------------------------------------------------------
|
|
6579
6899
|
/**
|
|
@@ -6596,6 +6916,17 @@
|
|
|
6596
6916
|
instances.delete(container);
|
|
6597
6917
|
}
|
|
6598
6918
|
const mergedConfig = mergeConfig(DEFAULT_CONFIG, runtimeConfig);
|
|
6919
|
+
// Phase 7 — capture original runtime overrides BEFORE controller.mount() so
|
|
6920
|
+
// any future mutation of mergedConfig.muted / .manager inside mount or
|
|
6921
|
+
// initIntegrations (e.g. an autoplay-policy normalization) cannot poison the
|
|
6922
|
+
// snapshot. They are restored on `<video>.muted` + PlayerState.muted (the
|
|
6923
|
+
// runtime ABI per command-handler.ts:115 and lifecycle.ts:158) and on
|
|
6924
|
+
// `mergedConfig.manager` (the P2P opt-in IS re-read on each `reference.open`,
|
|
6925
|
+
// so the config-level swap is legitimate for that field only). NEVER mutate
|
|
6926
|
+
// `mergedConfig.muted` — it is consumed only at video element creation in
|
|
6927
|
+
// skin-root.ts:141/318 and is a no-op for runtime mute.
|
|
6928
|
+
const originalMuted = mergedConfig.muted ?? false;
|
|
6929
|
+
const originalManager = mergedConfig.manager;
|
|
6599
6930
|
const controller = new PlayerController(runtimeConfig);
|
|
6600
6931
|
controller.mount(container);
|
|
6601
6932
|
// Video element is available after mount() creates the skin DOM
|
|
@@ -6624,6 +6955,11 @@
|
|
|
6624
6955
|
}
|
|
6625
6956
|
engine.setBus(controller.bus);
|
|
6626
6957
|
engine.setConfig(mergedConfig);
|
|
6958
|
+
// Tell the engine which URL to load. Must be set BEFORE setEngineSync() triggers
|
|
6959
|
+
// onAttach() → init(), where loadSourceUrl is consumed. config.src always holds
|
|
6960
|
+
// the main stream URL (never mutated); loadSourceUrl carries the per-open URL
|
|
6961
|
+
// (intro or main depending on the call site).
|
|
6962
|
+
engine.setLoadSource(src);
|
|
6627
6963
|
controller.setEngineSync(engine);
|
|
6628
6964
|
inst.engine = engine;
|
|
6629
6965
|
// P2P integration: wire EasyBroadcast SDK when config.lib and config.manager are set
|
|
@@ -6646,8 +6982,12 @@
|
|
|
6646
6982
|
console.error('EBPlayer: P2PManager integrate failed:', error);
|
|
6647
6983
|
});
|
|
6648
6984
|
}
|
|
6649
|
-
// Snapshot handler: create snapshot engine for seekbar preview thumbnails
|
|
6650
|
-
|
|
6985
|
+
// Snapshot handler: create snapshot engine for seekbar preview thumbnails.
|
|
6986
|
+
// Gated by `src !== mergedConfig.preroll` so the intro pass does NOT
|
|
6987
|
+
// initialize snapshot (Pitfall 5) — seekbar is hidden during intro anyway
|
|
6988
|
+
// and we want to avoid duplicate CDN tokens for the short intro stream.
|
|
6989
|
+
// The main pass still initializes snapshot normally.
|
|
6990
|
+
if (mergedConfig.showProgressThumb && src !== mergedConfig.preroll) {
|
|
6651
6991
|
// Clean up any previous snapshot handler
|
|
6652
6992
|
if (inst.snapshotDestroy !== null) {
|
|
6653
6993
|
inst.snapshotDestroy();
|
|
@@ -6743,10 +7083,8 @@
|
|
|
6743
7083
|
// Get saved selections before close (CommandHandler saved them before emitting request-reload)
|
|
6744
7084
|
const saved = controller.getSavedSelections();
|
|
6745
7085
|
reference.close();
|
|
6746
|
-
//
|
|
6747
|
-
|
|
6748
|
-
// 100ms was too short and caused "The existing ContentDecryptor" errors.
|
|
6749
|
-
setTimeout(() => {
|
|
7086
|
+
// EME teardown delay — see waitForEmeTeardown / EME_TEARDOWN_DELAY_MS.
|
|
7087
|
+
waitForEmeTeardown().then(() => {
|
|
6750
7088
|
reference.open(currentSrc);
|
|
6751
7089
|
// Restore selections after manifest loads (~2s pragmatic delay for async manifest parsing)
|
|
6752
7090
|
setTimeout(() => {
|
|
@@ -6756,7 +7094,7 @@
|
|
|
6756
7094
|
inst.engine.setSubtitle(saved.subtitleTrack);
|
|
6757
7095
|
}
|
|
6758
7096
|
}, 2000);
|
|
6759
|
-
}
|
|
7097
|
+
});
|
|
6760
7098
|
});
|
|
6761
7099
|
// On an unrecoverable engine error (e.g. fatal DRM keySystemError), tear down
|
|
6762
7100
|
// the P2P SDK. hls.js nulls its media element, so a still-running eblib would
|
|
@@ -6765,11 +7103,92 @@
|
|
|
6765
7103
|
controller.bus.on('error-fatal', () => {
|
|
6766
7104
|
inst.p2p?.stop();
|
|
6767
7105
|
}, { signal: controller.signal });
|
|
7106
|
+
// Phase 7 — intro→main orchestration.
|
|
7107
|
+
// When mergedConfig.preroll is set, open the intro stream first, then on
|
|
7108
|
+
// bus 'intro-stream-complete' (emitted by IntroStreamManager on video.ended,
|
|
7109
|
+
// preroll-skip, or error-fatal) close → 500ms EME teardown delay → open main.
|
|
7110
|
+
// If config.ad is also set, wait additionally for bus 'ad-complete' before
|
|
7111
|
+
// opening main (D-10: intro → ad → main precedence).
|
|
7112
|
+
//
|
|
7113
|
+
// Runtime mute override: the live <video>.muted and PlayerState.muted are
|
|
7114
|
+
// forced to false for the intro pass (D-13 — intros always audible) and
|
|
7115
|
+
// restored to `originalMuted` before opening main. We do NOT mutate
|
|
7116
|
+
// `mergedConfig.muted` because it is consumed only at video element creation
|
|
7117
|
+
// (skin-root.ts:141/318) and is a no-op for runtime mute.
|
|
7118
|
+
//
|
|
7119
|
+
// P2P opt-in: `mergedConfig.manager` IS re-read on each reference.open() in
|
|
7120
|
+
// the P2P branch (eb-player.ts:192), so we config-level swap it to false for
|
|
7121
|
+
// the intro pass and restore it for main (D-08 — P2P attaches only to main).
|
|
7122
|
+
const openMainStream = () => {
|
|
7123
|
+
if (video !== null) {
|
|
7124
|
+
video.muted = originalMuted;
|
|
7125
|
+
}
|
|
7126
|
+
controller.state.muted = originalMuted;
|
|
7127
|
+
// CR-05: keep mergedConfig.manager and controller.config.manager in sync.
|
|
7128
|
+
// start() builds mergedConfig and PlayerController builds its own merged
|
|
7129
|
+
// config independently from the same runtimeConfig (lifecycle.ts:73), so
|
|
7130
|
+
// they are SEPARATE object references with the same initial values. Today
|
|
7131
|
+
// only the engine path reads mergedConfig.manager, but any future
|
|
7132
|
+
// consumer of controller.config.manager would observe the wrong value
|
|
7133
|
+
// during the intro pass. Synchronize both objects on every swap.
|
|
7134
|
+
mergedConfig.manager = originalManager;
|
|
7135
|
+
controller.config.manager = originalManager;
|
|
7136
|
+
// CR-03: clear any stale error that leaked from the intro/ad pass.
|
|
7137
|
+
// IntroStreamManager's silent fall-through (D-04) treats error-fatal as a
|
|
7138
|
+
// normal completion but leaves state.error populated with the intro
|
|
7139
|
+
// stream's message — without this, ErrorMessage overlay would keep
|
|
7140
|
+
// showing the intro's error on top of the successfully-loading main
|
|
7141
|
+
// stream. Same applies on the ad-complete branch (an IMA AD_ERROR may
|
|
7142
|
+
// surface a non-recoverable error before falling through).
|
|
7143
|
+
controller.state.error = null;
|
|
7144
|
+
if (!mergedConfig.src)
|
|
7145
|
+
return;
|
|
7146
|
+
reference.open(mergedConfig.src);
|
|
7147
|
+
};
|
|
7148
|
+
if (mergedConfig.preroll) {
|
|
7149
|
+
const hasAd = Boolean(mergedConfig.ad);
|
|
7150
|
+
controller.bus.on('intro-stream-complete', () => {
|
|
7151
|
+
reference.close();
|
|
7152
|
+
// WR-08: restore PlayerState.muted synchronously — DO NOT wait for the
|
|
7153
|
+
// EME teardown delay. introStreamPlaying is now false, so skin
|
|
7154
|
+
// components (volume button, etc.) immediately re-render from
|
|
7155
|
+
// state.muted; leaving the intro-forced value of `false` until
|
|
7156
|
+
// openMainStream() would briefly show an unmuted speaker icon for a
|
|
7157
|
+
// user whose original config was `muted: true`. video.muted is
|
|
7158
|
+
// restored later in openMainStream() — the live element can wait, but
|
|
7159
|
+
// the visual state should reflect user intent immediately.
|
|
7160
|
+
controller.state.muted = originalMuted;
|
|
7161
|
+
// EME teardown delay — see waitForEmeTeardown / EME_TEARDOWN_DELAY_MS.
|
|
7162
|
+
waitForEmeTeardown().then(() => {
|
|
7163
|
+
if (!hasAd) {
|
|
7164
|
+
openMainStream();
|
|
7165
|
+
}
|
|
7166
|
+
// If hasAd, the ad-complete handler below will call openMainStream
|
|
7167
|
+
// after the AdsManager finishes (D-10 precedence).
|
|
7168
|
+
});
|
|
7169
|
+
}, { signal: controller.signal });
|
|
7170
|
+
if (hasAd) {
|
|
7171
|
+
controller.bus.on('ad-complete', () => {
|
|
7172
|
+
openMainStream();
|
|
7173
|
+
}, { signal: controller.signal });
|
|
7174
|
+
}
|
|
7175
|
+
// Force runtime mute off for the intro pass — applied to the LIVE <video>
|
|
7176
|
+
// element and PlayerState, NOT to mergedConfig.muted (no-op for runtime).
|
|
7177
|
+
if (video !== null) {
|
|
7178
|
+
video.muted = false;
|
|
7179
|
+
}
|
|
7180
|
+
controller.state.muted = false;
|
|
7181
|
+
// Skip P2P attach for the intro stream (D-08); restored before main open.
|
|
7182
|
+
// CR-05: keep the dual-merged configs in sync (see openMainStream).
|
|
7183
|
+
mergedConfig.manager = false;
|
|
7184
|
+
controller.config.manager = false;
|
|
7185
|
+
reference.open(mergedConfig.preroll);
|
|
7186
|
+
}
|
|
6768
7187
|
// Auto-open the stream if src is provided in config (matches legacy player behaviour
|
|
6769
7188
|
// where consumers call start({ src: '...' }) and expect playback to begin immediately).
|
|
6770
7189
|
// When autoplay is false, defer open() until the user requests play — this avoids
|
|
6771
7190
|
// fetching CDN tokens and loading manifests before playback is actually needed.
|
|
6772
|
-
if (mergedConfig.src) {
|
|
7191
|
+
else if (mergedConfig.src) {
|
|
6773
7192
|
if (mergedConfig.autoplay) {
|
|
6774
7193
|
reference.open(mergedConfig.src);
|
|
6775
7194
|
}
|