eb-player 2.0.17 → 2.0.19
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/ebplayer.bundle.js +482 -53
- 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/fsm.d.ts +4 -1
- package/dist/build/types/core/fsm.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/integrations/p2p-manager.d.ts +9 -0
- package/dist/build/types/integrations/p2p-manager.d.ts.map +1 -1
- 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.map +1 -1
- package/package.json +4 -2
|
@@ -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.19";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Finite State Machine for player playback state transitions.
|
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
* - idle -> loading: initial load or post-stall reload
|
|
20
20
|
* - loading -> playing | error: stream starts or fails
|
|
21
21
|
* - playing -> paused | buffering | ended | error: normal playback events
|
|
22
|
-
* - paused -> playing | loading | idle: resume or stop
|
|
22
|
+
* - paused -> playing | loading | idle | buffering: resume or stop
|
|
23
|
+
* - paused -> buffering is needed when resuming a paused (live) stream where
|
|
24
|
+
* the browser fires 'waiting' before 'playing' (Safari live segment edge);
|
|
25
|
+
* without it the FSM strands at 'paused' while the video actually plays
|
|
23
26
|
* - buffering -> playing | paused | idle | error: buffer recovered, user pause, or stall
|
|
24
27
|
* - buffering -> idle is needed for the stall-watchdog recovery path (kick off reload)
|
|
25
28
|
* - buffering -> paused is needed for live stream support (user pauses during buffer)
|
|
@@ -30,7 +33,7 @@
|
|
|
30
33
|
idle: ['loading'],
|
|
31
34
|
loading: ['playing', 'buffering', 'paused', 'error', 'idle'],
|
|
32
35
|
playing: ['paused', 'buffering', 'ended', 'error', 'idle'],
|
|
33
|
-
paused: ['playing', 'loading', 'idle'],
|
|
36
|
+
paused: ['playing', 'loading', 'idle', 'buffering'],
|
|
34
37
|
buffering: ['playing', 'paused', 'idle', 'error'],
|
|
35
38
|
error: ['loading', 'idle'],
|
|
36
39
|
ended: ['loading', 'idle']
|
|
@@ -115,6 +118,7 @@
|
|
|
115
118
|
socialsOpen: false,
|
|
116
119
|
infoOpen: false,
|
|
117
120
|
adPlaying: false,
|
|
121
|
+
introStreamPlaying: false,
|
|
118
122
|
castAvailable: false,
|
|
119
123
|
isRtl: false,
|
|
120
124
|
isRadio: false,
|
|
@@ -175,7 +179,11 @@
|
|
|
175
179
|
if (previousValue === value) {
|
|
176
180
|
return true;
|
|
177
181
|
}
|
|
178
|
-
|
|
182
|
+
// Assign and notify subscribers.
|
|
183
|
+
// Cast the typed StateMap target to a mutable record so the unknown value
|
|
184
|
+
// can be written through the Proxy set trap.
|
|
185
|
+
const writable = rawTarget;
|
|
186
|
+
writable[String(key)] = value;
|
|
179
187
|
impl.notify(key, value, previousValue);
|
|
180
188
|
return true;
|
|
181
189
|
}
|
|
@@ -601,6 +609,9 @@
|
|
|
601
609
|
},
|
|
602
610
|
'settings.auto': {
|
|
603
611
|
en: 'Auto', fr: 'Auto', ar: 'تلقائي', es: 'Auto'
|
|
612
|
+
},
|
|
613
|
+
'preroll.skip': {
|
|
614
|
+
en: 'Skip', fr: 'Passer', ar: 'تخطي', es: 'Saltar'
|
|
604
615
|
}
|
|
605
616
|
};
|
|
606
617
|
/**
|
|
@@ -1364,6 +1375,7 @@
|
|
|
1364
1375
|
this.state.on('bufferedEnd', () => this.scheduleRender(), { signal: this.signal });
|
|
1365
1376
|
this.state.on('isRtl', () => this.scheduleRender(), { signal: this.signal });
|
|
1366
1377
|
this.state.on('adPlaying', () => this.scheduleRender(), { signal: this.signal });
|
|
1378
|
+
this.state.on('introStreamPlaying', () => this.scheduleRender(), { signal: this.signal });
|
|
1367
1379
|
this.state.on('chapters', () => this.scheduleRender(), { signal: this.signal });
|
|
1368
1380
|
this.state.on('epgPrograms', () => this.scheduleRender(), { signal: this.signal });
|
|
1369
1381
|
this.render();
|
|
@@ -1431,7 +1443,7 @@
|
|
|
1431
1443
|
}
|
|
1432
1444
|
// ---- Drag handlers ----
|
|
1433
1445
|
handlePointerDown(event) {
|
|
1434
|
-
if (this.state.adPlaying)
|
|
1446
|
+
if (this.state.adPlaying || this.state.introStreamPlaying)
|
|
1435
1447
|
return;
|
|
1436
1448
|
const trackEl = event.currentTarget;
|
|
1437
1449
|
this.trackEl = trackEl;
|
|
@@ -1538,9 +1550,9 @@
|
|
|
1538
1550
|
}
|
|
1539
1551
|
// ---- Template ----
|
|
1540
1552
|
template() {
|
|
1541
|
-
const { currentTime, duration, bufferedEnd, adPlaying, chapters, epgPrograms } = this.state;
|
|
1553
|
+
const { currentTime, duration, bufferedEnd, adPlaying, introStreamPlaying, chapters, epgPrograms } = this.state;
|
|
1542
1554
|
const isSeekbarHidden = !this.config.seekbar;
|
|
1543
|
-
const isDisabled = adPlaying;
|
|
1555
|
+
const isDisabled = adPlaying || introStreamPlaying;
|
|
1544
1556
|
// Progress percentage — use dragValue during drag to prevent live-update jitter
|
|
1545
1557
|
const progressPercent = duration > 0
|
|
1546
1558
|
? (this.isDragging ? this.dragValue : currentTime) / duration * 100
|
|
@@ -2435,15 +2447,16 @@
|
|
|
2435
2447
|
*
|
|
2436
2448
|
* Displayed in the middle bar beside the play/pause button.
|
|
2437
2449
|
* Renders a circular arrow icon with the seek offset number below.
|
|
2438
|
-
* Hidden during ad playback.
|
|
2450
|
+
* Hidden during ad playback or intro-stream playback.
|
|
2439
2451
|
*/
|
|
2440
2452
|
class RewindButton extends BaseComponent {
|
|
2441
2453
|
onConnect() {
|
|
2442
2454
|
this.state.on('adPlaying', () => this.render(), { signal: this.signal });
|
|
2455
|
+
this.state.on('introStreamPlaying', () => this.render(), { signal: this.signal });
|
|
2443
2456
|
this.render();
|
|
2444
2457
|
}
|
|
2445
2458
|
template() {
|
|
2446
|
-
if (this.state.adPlaying) {
|
|
2459
|
+
if (this.state.adPlaying || this.state.introStreamPlaying) {
|
|
2447
2460
|
return b ``;
|
|
2448
2461
|
}
|
|
2449
2462
|
const offset = this.config.seekOffset || 15;
|
|
@@ -2475,15 +2488,16 @@
|
|
|
2475
2488
|
*
|
|
2476
2489
|
* Displayed in the middle bar beside the play/pause button.
|
|
2477
2490
|
* Renders a circular arrow icon with the seek offset number below.
|
|
2478
|
-
* Hidden during ad playback.
|
|
2491
|
+
* Hidden during ad playback or intro-stream playback.
|
|
2479
2492
|
*/
|
|
2480
2493
|
class ForwardButton extends BaseComponent {
|
|
2481
2494
|
onConnect() {
|
|
2482
2495
|
this.state.on('adPlaying', () => this.render(), { signal: this.signal });
|
|
2496
|
+
this.state.on('introStreamPlaying', () => this.render(), { signal: this.signal });
|
|
2483
2497
|
this.render();
|
|
2484
2498
|
}
|
|
2485
2499
|
template() {
|
|
2486
|
-
if (this.state.adPlaying) {
|
|
2500
|
+
if (this.state.adPlaying || this.state.introStreamPlaying) {
|
|
2487
2501
|
return b ``;
|
|
2488
2502
|
}
|
|
2489
2503
|
const offset = this.config.seekOffset || 15;
|
|
@@ -2774,6 +2788,101 @@
|
|
|
2774
2788
|
}
|
|
2775
2789
|
}
|
|
2776
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
|
+
|
|
2777
2886
|
/**
|
|
2778
2887
|
* ForjaPlaylistBar renders a horizontal episode list in the bottom-extra
|
|
2779
2888
|
* extension zone for the Forja brand skin.
|
|
@@ -2794,8 +2903,8 @@
|
|
|
2794
2903
|
}, { signal: this.signal });
|
|
2795
2904
|
}
|
|
2796
2905
|
template() {
|
|
2797
|
-
const playlist = this.state
|
|
2798
|
-
const currentEpisode = this.state
|
|
2906
|
+
const playlist = this.state.playlist;
|
|
2907
|
+
const currentEpisode = this.state.currentEpisode;
|
|
2799
2908
|
if (playlist.length === 0)
|
|
2800
2909
|
return b ``;
|
|
2801
2910
|
return b `
|
|
@@ -2861,7 +2970,7 @@
|
|
|
2861
2970
|
}, { signal: this.signal });
|
|
2862
2971
|
}
|
|
2863
2972
|
template() {
|
|
2864
|
-
const programs = this.state
|
|
2973
|
+
const programs = this.state.epgPrograms;
|
|
2865
2974
|
return b `
|
|
2866
2975
|
<div class="eb-snrt-carousel">
|
|
2867
2976
|
${programs.map((program) => b `
|
|
@@ -3145,7 +3254,8 @@
|
|
|
3145
3254
|
[new ErrorMessage(), 'eb-error-slot'],
|
|
3146
3255
|
[new SocialsOverlay(), 'eb-socials-slot'],
|
|
3147
3256
|
[new InfoOverlay(), 'eb-info-slot'],
|
|
3148
|
-
[new ToastNotification(), 'eb-toast-slot']
|
|
3257
|
+
[new ToastNotification(), 'eb-toast-slot'],
|
|
3258
|
+
[new PrerollOverlay(), 'eb-preroll-slot']
|
|
3149
3259
|
];
|
|
3150
3260
|
for (const [component, slotClass] of interactiveOverlays) {
|
|
3151
3261
|
const slot = document.createElement('div');
|
|
@@ -3353,8 +3463,8 @@
|
|
|
3353
3463
|
* AutoHideController manages the 3-second auto-hide timer for player controls.
|
|
3354
3464
|
*
|
|
3355
3465
|
* On any user activity (pointermove, touchstart, keyup), the timer resets to 3s.
|
|
3356
|
-
* When the timer fires, controls are hidden — but ONLY if no panel is open
|
|
3357
|
-
* no ad is playing.
|
|
3466
|
+
* When the timer fires, controls are hidden — but ONLY if no panel is open,
|
|
3467
|
+
* no ad is playing, and no intro stream is playing.
|
|
3358
3468
|
*
|
|
3359
3469
|
* Note: Uses manual removeEventListener instead of the { signal } option in
|
|
3360
3470
|
* addEventListener. JSDOM 28 rejects non-JSDOM AbortSignal instances in the
|
|
@@ -3399,7 +3509,8 @@
|
|
|
3399
3509
|
const shouldKeepVisible = this.state.settingsOpen ||
|
|
3400
3510
|
this.state.socialsOpen ||
|
|
3401
3511
|
this.state.infoOpen ||
|
|
3402
|
-
this.state.adPlaying
|
|
3512
|
+
this.state.adPlaying ||
|
|
3513
|
+
this.state.introStreamPlaying;
|
|
3403
3514
|
if (!shouldKeepVisible) {
|
|
3404
3515
|
this.state.controlsVisible = false;
|
|
3405
3516
|
}
|
|
@@ -3414,7 +3525,7 @@
|
|
|
3414
3525
|
* ArrowRight: seek forward by config.seekOffset seconds (clamped to duration).
|
|
3415
3526
|
* m: mutes/unmutes — only when config.supportHotKeys is true.
|
|
3416
3527
|
*
|
|
3417
|
-
* All shortcuts are disabled during ads (state.adPlaying
|
|
3528
|
+
* All shortcuts are disabled during ads or intro streams (state.adPlaying || state.introStreamPlaying).
|
|
3418
3529
|
*
|
|
3419
3530
|
* The container must be focusable to receive keyboard events.
|
|
3420
3531
|
* If container.tabIndex < 0, it is set to 0 automatically.
|
|
@@ -3436,8 +3547,8 @@
|
|
|
3436
3547
|
}
|
|
3437
3548
|
const handleKeyup = (event) => {
|
|
3438
3549
|
const keyEvent = event;
|
|
3439
|
-
// Ignore all keys during ad playback
|
|
3440
|
-
if (state.adPlaying) {
|
|
3550
|
+
// Ignore all keys during ad playback or intro-stream playback
|
|
3551
|
+
if (state.adPlaying || state.introStreamPlaying) {
|
|
3441
3552
|
return;
|
|
3442
3553
|
}
|
|
3443
3554
|
if (keyEvent.key === ' ') {
|
|
@@ -3769,15 +3880,28 @@
|
|
|
3769
3880
|
}
|
|
3770
3881
|
/**
|
|
3771
3882
|
* Initialize the IMA SDK preroll ad system.
|
|
3772
|
-
* - Skips if config.
|
|
3883
|
+
* - Skips if config.ad is undefined
|
|
3773
3884
|
* - If !config.autoplay: waits for user click on video element before initializing
|
|
3774
3885
|
* (required by browser autoplay policies to avoid muted/blocked display contexts)
|
|
3775
3886
|
* - Loads IMA SDK, creates AdDisplayContainer, AdsLoader, and AdsRequest
|
|
3776
3887
|
* - Wires ad lifecycle events to PlayerState and TypedEventBus
|
|
3777
3888
|
*/
|
|
3778
3889
|
async init(config, state, bus, video, adsContainer, signal) {
|
|
3779
|
-
if (!config.
|
|
3890
|
+
if (!config.ad)
|
|
3780
3891
|
return;
|
|
3892
|
+
// CR-01: D-10 intro → ad → main precedence. If an intro stream is currently
|
|
3893
|
+
// playing, defer IMA initialization until the bus emits 'intro-stream-complete'.
|
|
3894
|
+
// Without this gate, IMA fires CONTENT_PAUSE_REQUESTED and tries to play the ad
|
|
3895
|
+
// in the same <video> element the intro is using — the two streams race for
|
|
3896
|
+
// the element with order determined by network/SDK timing.
|
|
3897
|
+
if (state.introStreamPlaying) {
|
|
3898
|
+
await new Promise((resolve) => {
|
|
3899
|
+
bus.on('intro-stream-complete', () => resolve(), { signal });
|
|
3900
|
+
signal.addEventListener('abort', () => resolve(), { once: true });
|
|
3901
|
+
});
|
|
3902
|
+
if (signal.aborted)
|
|
3903
|
+
return;
|
|
3904
|
+
}
|
|
3781
3905
|
// Gate initialization on user gesture when not in autoplay mode
|
|
3782
3906
|
if (!config.autoplay) {
|
|
3783
3907
|
await this.waitForUserGesture(video, signal);
|
|
@@ -3790,13 +3914,29 @@
|
|
|
3790
3914
|
this.imaDisplayContainer = displayContainer;
|
|
3791
3915
|
displayContainer.initialize();
|
|
3792
3916
|
const adsLoader = new ima.AdsLoader(displayContainer);
|
|
3917
|
+
// AD_ERROR fall-through (CR-02): if the VAST tag fails to load (network
|
|
3918
|
+
// error, ad-blocker, malformed XML, autoplay rejection), IMA fires
|
|
3919
|
+
// AD_ERROR and never ALL_ADS_COMPLETED. Without this handler the
|
|
3920
|
+
// orchestration in eb-player.ts would wait forever for 'ad-complete'
|
|
3921
|
+
// and the main stream would never open. Treat AD_ERROR as a normal
|
|
3922
|
+
// completion so the orchestration always advances.
|
|
3923
|
+
adsLoader.addEventListener(ima.AdErrorEvent.Type.AD_ERROR, (errorEvent) => {
|
|
3924
|
+
console.warn('AdsManager: AD_ERROR (loader) — falling through to main', errorEvent);
|
|
3925
|
+
state.adPlaying = false;
|
|
3926
|
+
bus.emit('ad-complete');
|
|
3927
|
+
});
|
|
3793
3928
|
adsLoader.addEventListener(ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED, (loadedEvent) => {
|
|
3794
3929
|
const event = loadedEvent;
|
|
3795
3930
|
const adsManager = event.getAdsManager(video);
|
|
3796
3931
|
this.imaAdsManager = adsManager;
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3932
|
+
// getBoundingClientRect is always defined on HTMLElement; the prior
|
|
3933
|
+
// truthy check on the method reference was dead code. The real edge
|
|
3934
|
+
// case is a zero-sized element (display:none, before layout) which
|
|
3935
|
+
// would make IMA's ads container collapse — fall back to a 16:9
|
|
3936
|
+
// baseline in that case.
|
|
3937
|
+
const rect = video.getBoundingClientRect();
|
|
3938
|
+
const width = rect.width > 0 ? rect.width : 640;
|
|
3939
|
+
const height = rect.height > 0 ? rect.height : 360;
|
|
3800
3940
|
// Wire event handlers for ad lifecycle
|
|
3801
3941
|
adsManager.addEventListener(ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED, () => {
|
|
3802
3942
|
state.adPlaying = true;
|
|
@@ -3809,12 +3949,20 @@
|
|
|
3809
3949
|
state.adPlaying = false;
|
|
3810
3950
|
bus.emit('ad-complete');
|
|
3811
3951
|
});
|
|
3952
|
+
// Same AD_ERROR fall-through on the manager — covers errors that
|
|
3953
|
+
// surface only after the ad starts playing (decode error, mid-roll
|
|
3954
|
+
// load failure, etc.).
|
|
3955
|
+
adsManager.addEventListener(ima.AdErrorEvent.Type.AD_ERROR, (errorEvent) => {
|
|
3956
|
+
console.warn('AdsManager: AD_ERROR (manager) — falling through to main', errorEvent);
|
|
3957
|
+
state.adPlaying = false;
|
|
3958
|
+
bus.emit('ad-complete');
|
|
3959
|
+
});
|
|
3812
3960
|
adsManager.init(width, height);
|
|
3813
3961
|
adsManager.start();
|
|
3814
3962
|
});
|
|
3815
3963
|
// Load the ad tag
|
|
3816
3964
|
const adsRequest = new ima.AdsRequest();
|
|
3817
|
-
adsRequest.adTagUrl = config.
|
|
3965
|
+
adsRequest.adTagUrl = config.ad;
|
|
3818
3966
|
adsLoader.requestAds(adsRequest);
|
|
3819
3967
|
// Register abort cleanup
|
|
3820
3968
|
signal.addEventListener('abort', () => {
|
|
@@ -3831,23 +3979,95 @@
|
|
|
3831
3979
|
/**
|
|
3832
3980
|
* Wait for a click event on the video element.
|
|
3833
3981
|
* Used to satisfy browser autoplay policies when autoplay is disabled.
|
|
3982
|
+
*
|
|
3983
|
+
* WR-05: single-shot settle flag guards against the click-vs-abort race —
|
|
3984
|
+
* Promise resolution is already idempotent but settled tightens the
|
|
3985
|
+
* contract by ensuring listener cleanup happens exactly once for either
|
|
3986
|
+
* outcome. Callers still distinguish outcomes via signal.aborted.
|
|
3834
3987
|
*/
|
|
3835
3988
|
waitForUserGesture(video, signal) {
|
|
3836
3989
|
return new Promise((resolve) => {
|
|
3837
|
-
|
|
3990
|
+
let settled = false;
|
|
3991
|
+
const settle = () => {
|
|
3992
|
+
if (settled)
|
|
3993
|
+
return;
|
|
3994
|
+
settled = true;
|
|
3838
3995
|
video.removeEventListener('click', onClick);
|
|
3839
3996
|
resolve();
|
|
3840
3997
|
};
|
|
3998
|
+
const onClick = () => settle();
|
|
3841
3999
|
video.addEventListener('click', onClick);
|
|
3842
|
-
// Clean up if aborted before click
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
resolve();
|
|
3846
|
-
}, { once: true });
|
|
4000
|
+
// Clean up if aborted before click. The abort listener auto-removes
|
|
4001
|
+
// via `{ once: true }`; the click listener is removed inside settle().
|
|
4002
|
+
signal.addEventListener('abort', settle, { once: true });
|
|
3847
4003
|
});
|
|
3848
4004
|
}
|
|
3849
4005
|
}
|
|
3850
4006
|
|
|
4007
|
+
/**
|
|
4008
|
+
* IntroStreamManager owns the intro-stream lifecycle.
|
|
4009
|
+
*
|
|
4010
|
+
* - On init(), if config.preroll is set: flips state.introStreamPlaying = true and
|
|
4011
|
+
* subscribes to:
|
|
4012
|
+
* - the video element's native `ended` event
|
|
4013
|
+
* - bus `preroll-skip` (from the PrerollOverlay skip button)
|
|
4014
|
+
* - bus `error-fatal` (D-04 silent fall-through — intro must never block main)
|
|
4015
|
+
* - Any one of those triggers calls a closure `complete()` exactly once
|
|
4016
|
+
* (idempotent via a `completed` boolean guard), which flips
|
|
4017
|
+
* state.introStreamPlaying = false and emits `intro-stream-complete`.
|
|
4018
|
+
* - On abort, the video `ended` listener is removed explicitly via
|
|
4019
|
+
* removeEventListener. Bus listeners auto-clean via the `{ signal }` option
|
|
4020
|
+
* (see src/core/event-bus.ts on()).
|
|
4021
|
+
*
|
|
4022
|
+
* Scope: this manager listens and emits — it does NOT itself open the intro
|
|
4023
|
+
* stream. The engine open() call is owned by eb-player.ts (single source of
|
|
4024
|
+
* engine selection + DRM wiring), driven by the orchestration around this
|
|
4025
|
+
* manager's `intro-stream-complete` event.
|
|
4026
|
+
*/
|
|
4027
|
+
class IntroStreamManager {
|
|
4028
|
+
/**
|
|
4029
|
+
* Initialize intro-stream lifecycle tracking.
|
|
4030
|
+
* - Skips entirely if config.preroll is falsy (no state flip, no listeners).
|
|
4031
|
+
* - Otherwise flips state.introStreamPlaying = true and registers the three
|
|
4032
|
+
* completion triggers (video `ended`, bus `preroll-skip`, bus `error-fatal`).
|
|
4033
|
+
*/
|
|
4034
|
+
async init(config, state, bus, video, signal) {
|
|
4035
|
+
if (!config.preroll)
|
|
4036
|
+
return;
|
|
4037
|
+
state.introStreamPlaying = true;
|
|
4038
|
+
// Child AbortController scoped to the intro pass. We tear it down inside
|
|
4039
|
+
// complete() so the bus listeners (in particular 'error-fatal') do not
|
|
4040
|
+
// outlive the intro stream. Without this, a fatal error on the MAIN
|
|
4041
|
+
// stream would re-fire this handler and log the misleading
|
|
4042
|
+
// "intro stream errored" message (WR-01).
|
|
4043
|
+
const local = new AbortController();
|
|
4044
|
+
// Mirror outer aborts onto the local controller so dispose() still
|
|
4045
|
+
// cleans everything up.
|
|
4046
|
+
signal.addEventListener('abort', () => local.abort(), { once: true });
|
|
4047
|
+
let completed = false;
|
|
4048
|
+
const complete = () => {
|
|
4049
|
+
if (completed)
|
|
4050
|
+
return;
|
|
4051
|
+
completed = true;
|
|
4052
|
+
state.introStreamPlaying = false;
|
|
4053
|
+
bus.emit('intro-stream-complete');
|
|
4054
|
+
local.abort(); // tear down bus listeners after the intro completes
|
|
4055
|
+
};
|
|
4056
|
+
const onEnded = () => complete();
|
|
4057
|
+
video.addEventListener('ended', onEnded);
|
|
4058
|
+
bus.on('preroll-skip', complete, { signal: local.signal });
|
|
4059
|
+
// D-04: intro stream errors fall through silently to main. Log a warning
|
|
4060
|
+
// and treat the failure as a normal completion.
|
|
4061
|
+
bus.on('error-fatal', () => {
|
|
4062
|
+
console.warn('IntroStreamManager: intro stream errored — falling through to main');
|
|
4063
|
+
complete();
|
|
4064
|
+
}, { signal: local.signal });
|
|
4065
|
+
signal.addEventListener('abort', () => {
|
|
4066
|
+
video.removeEventListener('ended', onEnded);
|
|
4067
|
+
}, { once: true });
|
|
4068
|
+
}
|
|
4069
|
+
}
|
|
4070
|
+
|
|
3851
4071
|
/**
|
|
3852
4072
|
* Poster HLS handler.
|
|
3853
4073
|
*
|
|
@@ -4093,8 +4313,8 @@
|
|
|
4093
4313
|
const playlistManager = new PlaylistManager();
|
|
4094
4314
|
playlistManager.init(config, state, bus, signal);
|
|
4095
4315
|
}
|
|
4096
|
-
// AdsManager: skip when config.
|
|
4097
|
-
if (config.
|
|
4316
|
+
// AdsManager: skip when config.ad is undefined (D-09)
|
|
4317
|
+
if (config.ad && this.skinRoot !== null) {
|
|
4098
4318
|
const adsManager = new AdsManager();
|
|
4099
4319
|
const video = this.skinRoot.getVideoElement();
|
|
4100
4320
|
const adsContainer = this.skinRoot.getAdsContainer();
|
|
@@ -4104,6 +4324,16 @@
|
|
|
4104
4324
|
});
|
|
4105
4325
|
}
|
|
4106
4326
|
}
|
|
4327
|
+
// IntroStreamManager: skip when config.preroll is false (D-02)
|
|
4328
|
+
if (config.preroll && this.skinRoot !== null) {
|
|
4329
|
+
const introManager = new IntroStreamManager();
|
|
4330
|
+
const video = this.skinRoot.getVideoElement();
|
|
4331
|
+
if (video !== null) {
|
|
4332
|
+
introManager.init(config, state, bus, video, signal).catch((error) => {
|
|
4333
|
+
console.error('EBPlayer: IntroStreamManager init failed:', error);
|
|
4334
|
+
});
|
|
4335
|
+
}
|
|
4336
|
+
}
|
|
4107
4337
|
// Poster handler: loop an HLS stream as video background when configured
|
|
4108
4338
|
if (config.posterStream) {
|
|
4109
4339
|
const posterHandler = createPosterHandler();
|
|
@@ -4248,6 +4478,14 @@
|
|
|
4248
4478
|
this.signal = null;
|
|
4249
4479
|
this.bus = null;
|
|
4250
4480
|
this.config = null;
|
|
4481
|
+
/**
|
|
4482
|
+
* The URL to load — set by reference.open(src) via setLoadSource().
|
|
4483
|
+
* Takes precedence over config.src in engine init so the intro stream URL
|
|
4484
|
+
* is loaded rather than the main stream URL (root cause of Phase 7 bug:
|
|
4485
|
+
* reference.open(preroll) wired config but engines read config.src, which
|
|
4486
|
+
* always points at the main stream).
|
|
4487
|
+
*/
|
|
4488
|
+
this.loadSourceUrl = '';
|
|
4251
4489
|
this.watchdog = null;
|
|
4252
4490
|
this.driverReady = new Promise((resolve) => {
|
|
4253
4491
|
this.resolveDriverReady = resolve;
|
|
@@ -4270,6 +4508,7 @@
|
|
|
4270
4508
|
this.signal = null;
|
|
4271
4509
|
this.bus = null;
|
|
4272
4510
|
this.config = null;
|
|
4511
|
+
this.loadSourceUrl = '';
|
|
4273
4512
|
}
|
|
4274
4513
|
// -------------------------------------------------------------------------
|
|
4275
4514
|
// Stall watchdog helpers
|
|
@@ -4311,6 +4550,16 @@
|
|
|
4311
4550
|
setVideo(video) {
|
|
4312
4551
|
this.video = video;
|
|
4313
4552
|
}
|
|
4553
|
+
/**
|
|
4554
|
+
* Set the URL that the engine driver should load.
|
|
4555
|
+
* Must be called by reference.open(src) BEFORE controller.setEngineSync()
|
|
4556
|
+
* so that onAttach() → init() picks up the correct URL.
|
|
4557
|
+
* This decouples the stream URL from config.src, which always points at the
|
|
4558
|
+
* main stream URL (set once at start() time and never mutated).
|
|
4559
|
+
*/
|
|
4560
|
+
setLoadSource(src) {
|
|
4561
|
+
this.loadSourceUrl = src;
|
|
4562
|
+
}
|
|
4314
4563
|
// -------------------------------------------------------------------------
|
|
4315
4564
|
// Video element event binding
|
|
4316
4565
|
// -------------------------------------------------------------------------
|
|
@@ -5086,7 +5335,8 @@
|
|
|
5086
5335
|
* automatic recovery:
|
|
5087
5336
|
* - Fatal mediaError: calls recoverMediaError() after 1s
|
|
5088
5337
|
* - Fatal keySystemError: unrecoverable (e.g. no HTTPS for DRM) — no retry
|
|
5089
|
-
* - Fatal other error: calls
|
|
5338
|
+
* - Fatal other error (intro pass): calls onFatalNetworkError() to fall through to main
|
|
5339
|
+
* - Fatal other error (main pass): calls startLoad() after 1s (keeps retrying)
|
|
5090
5340
|
* - Non-fatal error: re-checks after 3s; if currentTime has not advanced
|
|
5091
5341
|
* (and Chromecast is not casting), applies the same recovery
|
|
5092
5342
|
*
|
|
@@ -5105,6 +5355,8 @@
|
|
|
5105
5355
|
const exhaustedErrors = new Set();
|
|
5106
5356
|
/** Tracks unrecoverable fatal errors that have already been reported */
|
|
5107
5357
|
const reportedUnrecoverable = new Set();
|
|
5358
|
+
/** Ensures onFatalNetworkError is only called once per handler instance */
|
|
5359
|
+
let fatalNetworkErrorReported = false;
|
|
5108
5360
|
let lastSuccessfulTime;
|
|
5109
5361
|
driver.on('hlsError', (event, data) => {
|
|
5110
5362
|
const errorKey = data.details ?? data.type;
|
|
@@ -5125,6 +5377,17 @@
|
|
|
5125
5377
|
engine?.onUnrecoverableError?.(`DRM error: ${errorKey}`);
|
|
5126
5378
|
return;
|
|
5127
5379
|
}
|
|
5380
|
+
// Intro-pass fall-through: when onFatalNetworkError is provided, signal
|
|
5381
|
+
// the caller once and stop retrying. This lets the player fall through to
|
|
5382
|
+
// the main stream instead of looping startLoad() on an unreachable intro.
|
|
5383
|
+
if (engine?.onFatalNetworkError !== undefined) {
|
|
5384
|
+
if (fatalNetworkErrorReported)
|
|
5385
|
+
return;
|
|
5386
|
+
fatalNetworkErrorReported = true;
|
|
5387
|
+
console.warn(`HLS Retry: Fatal error on intro pass — signalling fall-through (${errorKey}).`, event, data);
|
|
5388
|
+
engine.onFatalNetworkError(errorKey);
|
|
5389
|
+
return;
|
|
5390
|
+
}
|
|
5128
5391
|
console.warn('HLS Retry: Fatal error, trying to fix now.', event, data);
|
|
5129
5392
|
setTimeout(() => {
|
|
5130
5393
|
if (data.type === 'mediaError')
|
|
@@ -5463,7 +5726,13 @@
|
|
|
5463
5726
|
applyDiscontinuityWorkaround(driver, Hls.Events);
|
|
5464
5727
|
// Wire retry handler — pass engine context so unrecoverable DRM errors
|
|
5465
5728
|
// stop the stall watchdog (prevents useless reload loops) and surface to UI.
|
|
5729
|
+
// For the intro pass (loadSourceUrl matches config.preroll), also wire
|
|
5730
|
+
// onFatalNetworkError so a permanently-unreachable intro manifest emits
|
|
5731
|
+
// error-fatal and falls through to main instead of looping startLoad() forever.
|
|
5732
|
+
// This callback is intentionally NOT set for the main stream so transient
|
|
5733
|
+
// network blips still trigger the normal startLoad() recovery.
|
|
5466
5734
|
if (config.retry) {
|
|
5735
|
+
const isIntroPass = Boolean(config.preroll) && this.loadSourceUrl === config.preroll;
|
|
5467
5736
|
handleHlsRetry(driver, {
|
|
5468
5737
|
get chromecast_casting() { return state.isCasting; },
|
|
5469
5738
|
onUnrecoverableError: (message) => {
|
|
@@ -5471,7 +5740,21 @@
|
|
|
5471
5740
|
if (this.state) {
|
|
5472
5741
|
this.state.error = message;
|
|
5473
5742
|
}
|
|
5474
|
-
|
|
5743
|
+
// hls.js nulls its media on a fatal keySystemError; signal so the
|
|
5744
|
+
// P2P SDK is torn down before it loops on a null media element.
|
|
5745
|
+
this.bus?.emit('error-fatal', { code: 'DRM', message });
|
|
5746
|
+
},
|
|
5747
|
+
...(isIntroPass
|
|
5748
|
+
? {
|
|
5749
|
+
onFatalNetworkError: (errorKey) => {
|
|
5750
|
+
// D-04: intro stream fatal network/manifest error — stop retrying
|
|
5751
|
+
// and signal fall-through to main. IntroStreamManager's error-fatal
|
|
5752
|
+
// listener completes the intro pass so openMainStream() can proceed.
|
|
5753
|
+
this.stopWatchdog();
|
|
5754
|
+
this.bus?.emit('error-fatal', { code: 'NETWORK', message: `Intro stream error: ${errorKey}` });
|
|
5755
|
+
}
|
|
5756
|
+
}
|
|
5757
|
+
: {}),
|
|
5475
5758
|
});
|
|
5476
5759
|
}
|
|
5477
5760
|
// Geo-block detection (opt-in, ported from v1.x).
|
|
@@ -5496,8 +5779,10 @@
|
|
|
5496
5779
|
this.bindVideoEvents(video, state, signal);
|
|
5497
5780
|
// Attach media and load source
|
|
5498
5781
|
driver.attachMedia(video);
|
|
5499
|
-
// Build source URL with token params if applicable
|
|
5500
|
-
|
|
5782
|
+
// Build source URL with token params if applicable.
|
|
5783
|
+
// Use loadSourceUrl (set by reference.open(src) via setLoadSource) so the intro
|
|
5784
|
+
// stream URL is loaded rather than config.src which always points at the main stream.
|
|
5785
|
+
let src = this.loadSourceUrl || config.src || '';
|
|
5501
5786
|
if (this.tokenManager && src) {
|
|
5502
5787
|
src = await this.tokenManager.updateUrlWithTokenParams({ url: src });
|
|
5503
5788
|
// Guard: abort if detached during token URL update
|
|
@@ -6005,7 +6290,9 @@
|
|
|
6005
6290
|
}
|
|
6006
6291
|
this.driver = player;
|
|
6007
6292
|
this.resolveDriverReady();
|
|
6008
|
-
|
|
6293
|
+
// Use loadSourceUrl (set by reference.open(src) via setLoadSource) so the intro
|
|
6294
|
+
// stream URL is loaded rather than config.src which always points at the main stream.
|
|
6295
|
+
player.initialize(video, this.loadSourceUrl || config.src || '', config.autoplay ?? false);
|
|
6009
6296
|
// Apply retry settings if requested
|
|
6010
6297
|
if (config.retry) {
|
|
6011
6298
|
applyDashRetrySettings(player);
|
|
@@ -6185,12 +6472,23 @@
|
|
|
6185
6472
|
this.lib.start();
|
|
6186
6473
|
// Clean up on abort
|
|
6187
6474
|
signal.addEventListener('abort', () => {
|
|
6188
|
-
|
|
6189
|
-
this.lib.stop();
|
|
6190
|
-
}
|
|
6191
|
-
this.lib = null;
|
|
6475
|
+
this.stop();
|
|
6192
6476
|
}, { once: true });
|
|
6193
6477
|
}
|
|
6478
|
+
/**
|
|
6479
|
+
* Stop and detach the P2P SDK. Idempotent.
|
|
6480
|
+
*
|
|
6481
|
+
* Called on AbortSignal teardown, and eagerly on an unrecoverable engine
|
|
6482
|
+
* error: hls.js nulls its media element on a fatal keySystemError, so a
|
|
6483
|
+
* still-running eblib instance would keep polling `media.currentTime` and
|
|
6484
|
+
* throw in a loop until full dispose.
|
|
6485
|
+
*/
|
|
6486
|
+
stop() {
|
|
6487
|
+
if (this.lib !== null && typeof this.lib.stop === 'function') {
|
|
6488
|
+
this.lib.stop();
|
|
6489
|
+
}
|
|
6490
|
+
this.lib = null;
|
|
6491
|
+
}
|
|
6194
6492
|
}
|
|
6195
6493
|
|
|
6196
6494
|
/**
|
|
@@ -6512,6 +6810,7 @@
|
|
|
6512
6810
|
inst.engine.detach();
|
|
6513
6811
|
inst.engine = null;
|
|
6514
6812
|
}
|
|
6813
|
+
inst.p2p = null;
|
|
6515
6814
|
inst.controller.dispose();
|
|
6516
6815
|
}
|
|
6517
6816
|
// ---------------------------------------------------------------------------
|
|
@@ -6552,6 +6851,25 @@
|
|
|
6552
6851
|
return isDash ? new DashEngine() : new HlsEngine();
|
|
6553
6852
|
}
|
|
6554
6853
|
// ---------------------------------------------------------------------------
|
|
6854
|
+
// EME teardown timing
|
|
6855
|
+
// ---------------------------------------------------------------------------
|
|
6856
|
+
/**
|
|
6857
|
+
* Empirical delay to give the browser time to complete async EME teardown
|
|
6858
|
+
* (setMediaKeys(null)) before the next stream is opened on the same <video>.
|
|
6859
|
+
* hls.js destroy() does not await setMediaKeys(null), so reusing the element
|
|
6860
|
+
* too soon yields "The existing ContentDecryptor" errors on DRM streams.
|
|
6861
|
+
*
|
|
6862
|
+
* Tuned at 500ms: 100ms was too short on slower hardware. There is no
|
|
6863
|
+
* promise-able event for setMediaKeys(null) completion; switch this helper
|
|
6864
|
+
* to an event-driven wait once one exists.
|
|
6865
|
+
*/
|
|
6866
|
+
const EME_TEARDOWN_DELAY_MS = 500;
|
|
6867
|
+
function waitForEmeTeardown() {
|
|
6868
|
+
// TODO: replace with an event-driven wait once setMediaKeys(null) is
|
|
6869
|
+
// observably awaitable.
|
|
6870
|
+
return new Promise((resolve) => setTimeout(resolve, EME_TEARDOWN_DELAY_MS));
|
|
6871
|
+
}
|
|
6872
|
+
// ---------------------------------------------------------------------------
|
|
6555
6873
|
// Public API
|
|
6556
6874
|
// ---------------------------------------------------------------------------
|
|
6557
6875
|
/**
|
|
@@ -6574,6 +6892,17 @@
|
|
|
6574
6892
|
instances.delete(container);
|
|
6575
6893
|
}
|
|
6576
6894
|
const mergedConfig = mergeConfig(DEFAULT_CONFIG, runtimeConfig);
|
|
6895
|
+
// Phase 7 — capture original runtime overrides BEFORE controller.mount() so
|
|
6896
|
+
// any future mutation of mergedConfig.muted / .manager inside mount or
|
|
6897
|
+
// initIntegrations (e.g. an autoplay-policy normalization) cannot poison the
|
|
6898
|
+
// snapshot. They are restored on `<video>.muted` + PlayerState.muted (the
|
|
6899
|
+
// runtime ABI per command-handler.ts:115 and lifecycle.ts:158) and on
|
|
6900
|
+
// `mergedConfig.manager` (the P2P opt-in IS re-read on each `reference.open`,
|
|
6901
|
+
// so the config-level swap is legitimate for that field only). NEVER mutate
|
|
6902
|
+
// `mergedConfig.muted` — it is consumed only at video element creation in
|
|
6903
|
+
// skin-root.ts:141/318 and is a no-op for runtime mute.
|
|
6904
|
+
const originalMuted = mergedConfig.muted ?? false;
|
|
6905
|
+
const originalManager = mergedConfig.manager;
|
|
6577
6906
|
const controller = new PlayerController(runtimeConfig);
|
|
6578
6907
|
controller.mount(container);
|
|
6579
6908
|
// Video element is available after mount() creates the skin DOM
|
|
@@ -6583,6 +6912,7 @@
|
|
|
6583
6912
|
controller,
|
|
6584
6913
|
engine: null,
|
|
6585
6914
|
snapshotDestroy: null,
|
|
6915
|
+
p2p: null,
|
|
6586
6916
|
container
|
|
6587
6917
|
};
|
|
6588
6918
|
instances.set(container, inst);
|
|
@@ -6601,12 +6931,20 @@
|
|
|
6601
6931
|
}
|
|
6602
6932
|
engine.setBus(controller.bus);
|
|
6603
6933
|
engine.setConfig(mergedConfig);
|
|
6934
|
+
// Tell the engine which URL to load. Must be set BEFORE setEngineSync() triggers
|
|
6935
|
+
// onAttach() → init(), where loadSourceUrl is consumed. config.src always holds
|
|
6936
|
+
// the main stream URL (never mutated); loadSourceUrl carries the per-open URL
|
|
6937
|
+
// (intro or main depending on the call site).
|
|
6938
|
+
engine.setLoadSource(src);
|
|
6604
6939
|
controller.setEngineSync(engine);
|
|
6605
6940
|
inst.engine = engine;
|
|
6606
6941
|
// P2P integration: wire EasyBroadcast SDK when config.lib and config.manager are set
|
|
6607
6942
|
if (mergedConfig.lib && mergedConfig.manager && video !== null) {
|
|
6608
6943
|
const isDash = src.includes('.mpd') && mergedConfig.dashjs !== false;
|
|
6944
|
+
// Stop any P2P SDK from a previous open() before attaching a new one
|
|
6945
|
+
inst.p2p?.stop();
|
|
6609
6946
|
const p2pManager = new P2PManager();
|
|
6947
|
+
inst.p2p = p2pManager;
|
|
6610
6948
|
// Wait for the engine driver to be created before integrating P2P
|
|
6611
6949
|
engine.driverReady.then(() => {
|
|
6612
6950
|
return p2pManager.integrate({
|
|
@@ -6620,8 +6958,12 @@
|
|
|
6620
6958
|
console.error('EBPlayer: P2PManager integrate failed:', error);
|
|
6621
6959
|
});
|
|
6622
6960
|
}
|
|
6623
|
-
// Snapshot handler: create snapshot engine for seekbar preview thumbnails
|
|
6624
|
-
|
|
6961
|
+
// Snapshot handler: create snapshot engine for seekbar preview thumbnails.
|
|
6962
|
+
// Gated by `src !== mergedConfig.preroll` so the intro pass does NOT
|
|
6963
|
+
// initialize snapshot (Pitfall 5) — seekbar is hidden during intro anyway
|
|
6964
|
+
// and we want to avoid duplicate CDN tokens for the short intro stream.
|
|
6965
|
+
// The main pass still initializes snapshot normally.
|
|
6966
|
+
if (mergedConfig.showProgressThumb && src !== mergedConfig.preroll) {
|
|
6625
6967
|
// Clean up any previous snapshot handler
|
|
6626
6968
|
if (inst.snapshotDestroy !== null) {
|
|
6627
6969
|
inst.snapshotDestroy();
|
|
@@ -6717,10 +7059,8 @@
|
|
|
6717
7059
|
// Get saved selections before close (CommandHandler saved them before emitting request-reload)
|
|
6718
7060
|
const saved = controller.getSavedSelections();
|
|
6719
7061
|
reference.close();
|
|
6720
|
-
//
|
|
6721
|
-
|
|
6722
|
-
// 100ms was too short and caused "The existing ContentDecryptor" errors.
|
|
6723
|
-
setTimeout(() => {
|
|
7062
|
+
// EME teardown delay — see waitForEmeTeardown / EME_TEARDOWN_DELAY_MS.
|
|
7063
|
+
waitForEmeTeardown().then(() => {
|
|
6724
7064
|
reference.open(currentSrc);
|
|
6725
7065
|
// Restore selections after manifest loads (~2s pragmatic delay for async manifest parsing)
|
|
6726
7066
|
setTimeout(() => {
|
|
@@ -6730,13 +7070,101 @@
|
|
|
6730
7070
|
inst.engine.setSubtitle(saved.subtitleTrack);
|
|
6731
7071
|
}
|
|
6732
7072
|
}, 2000);
|
|
6733
|
-
}
|
|
7073
|
+
});
|
|
6734
7074
|
});
|
|
7075
|
+
// On an unrecoverable engine error (e.g. fatal DRM keySystemError), tear down
|
|
7076
|
+
// the P2P SDK. hls.js nulls its media element, so a still-running eblib would
|
|
7077
|
+
// otherwise loop on `media.currentTime`. The engine has already surfaced the
|
|
7078
|
+
// clean error via state.error and stopped its stall watchdog.
|
|
7079
|
+
controller.bus.on('error-fatal', () => {
|
|
7080
|
+
inst.p2p?.stop();
|
|
7081
|
+
}, { signal: controller.signal });
|
|
7082
|
+
// Phase 7 — intro→main orchestration.
|
|
7083
|
+
// When mergedConfig.preroll is set, open the intro stream first, then on
|
|
7084
|
+
// bus 'intro-stream-complete' (emitted by IntroStreamManager on video.ended,
|
|
7085
|
+
// preroll-skip, or error-fatal) close → 500ms EME teardown delay → open main.
|
|
7086
|
+
// If config.ad is also set, wait additionally for bus 'ad-complete' before
|
|
7087
|
+
// opening main (D-10: intro → ad → main precedence).
|
|
7088
|
+
//
|
|
7089
|
+
// Runtime mute override: the live <video>.muted and PlayerState.muted are
|
|
7090
|
+
// forced to false for the intro pass (D-13 — intros always audible) and
|
|
7091
|
+
// restored to `originalMuted` before opening main. We do NOT mutate
|
|
7092
|
+
// `mergedConfig.muted` because it is consumed only at video element creation
|
|
7093
|
+
// (skin-root.ts:141/318) and is a no-op for runtime mute.
|
|
7094
|
+
//
|
|
7095
|
+
// P2P opt-in: `mergedConfig.manager` IS re-read on each reference.open() in
|
|
7096
|
+
// the P2P branch (eb-player.ts:192), so we config-level swap it to false for
|
|
7097
|
+
// the intro pass and restore it for main (D-08 — P2P attaches only to main).
|
|
7098
|
+
const openMainStream = () => {
|
|
7099
|
+
if (video !== null) {
|
|
7100
|
+
video.muted = originalMuted;
|
|
7101
|
+
}
|
|
7102
|
+
controller.state.muted = originalMuted;
|
|
7103
|
+
// CR-05: keep mergedConfig.manager and controller.config.manager in sync.
|
|
7104
|
+
// start() builds mergedConfig and PlayerController builds its own merged
|
|
7105
|
+
// config independently from the same runtimeConfig (lifecycle.ts:73), so
|
|
7106
|
+
// they are SEPARATE object references with the same initial values. Today
|
|
7107
|
+
// only the engine path reads mergedConfig.manager, but any future
|
|
7108
|
+
// consumer of controller.config.manager would observe the wrong value
|
|
7109
|
+
// during the intro pass. Synchronize both objects on every swap.
|
|
7110
|
+
mergedConfig.manager = originalManager;
|
|
7111
|
+
controller.config.manager = originalManager;
|
|
7112
|
+
// CR-03: clear any stale error that leaked from the intro/ad pass.
|
|
7113
|
+
// IntroStreamManager's silent fall-through (D-04) treats error-fatal as a
|
|
7114
|
+
// normal completion but leaves state.error populated with the intro
|
|
7115
|
+
// stream's message — without this, ErrorMessage overlay would keep
|
|
7116
|
+
// showing the intro's error on top of the successfully-loading main
|
|
7117
|
+
// stream. Same applies on the ad-complete branch (an IMA AD_ERROR may
|
|
7118
|
+
// surface a non-recoverable error before falling through).
|
|
7119
|
+
controller.state.error = null;
|
|
7120
|
+
if (!mergedConfig.src)
|
|
7121
|
+
return;
|
|
7122
|
+
reference.open(mergedConfig.src);
|
|
7123
|
+
};
|
|
7124
|
+
if (mergedConfig.preroll) {
|
|
7125
|
+
const hasAd = Boolean(mergedConfig.ad);
|
|
7126
|
+
controller.bus.on('intro-stream-complete', () => {
|
|
7127
|
+
reference.close();
|
|
7128
|
+
// WR-08: restore PlayerState.muted synchronously — DO NOT wait for the
|
|
7129
|
+
// EME teardown delay. introStreamPlaying is now false, so skin
|
|
7130
|
+
// components (volume button, etc.) immediately re-render from
|
|
7131
|
+
// state.muted; leaving the intro-forced value of `false` until
|
|
7132
|
+
// openMainStream() would briefly show an unmuted speaker icon for a
|
|
7133
|
+
// user whose original config was `muted: true`. video.muted is
|
|
7134
|
+
// restored later in openMainStream() — the live element can wait, but
|
|
7135
|
+
// the visual state should reflect user intent immediately.
|
|
7136
|
+
controller.state.muted = originalMuted;
|
|
7137
|
+
// EME teardown delay — see waitForEmeTeardown / EME_TEARDOWN_DELAY_MS.
|
|
7138
|
+
waitForEmeTeardown().then(() => {
|
|
7139
|
+
if (!hasAd) {
|
|
7140
|
+
openMainStream();
|
|
7141
|
+
}
|
|
7142
|
+
// If hasAd, the ad-complete handler below will call openMainStream
|
|
7143
|
+
// after the AdsManager finishes (D-10 precedence).
|
|
7144
|
+
});
|
|
7145
|
+
}, { signal: controller.signal });
|
|
7146
|
+
if (hasAd) {
|
|
7147
|
+
controller.bus.on('ad-complete', () => {
|
|
7148
|
+
openMainStream();
|
|
7149
|
+
}, { signal: controller.signal });
|
|
7150
|
+
}
|
|
7151
|
+
// Force runtime mute off for the intro pass — applied to the LIVE <video>
|
|
7152
|
+
// element and PlayerState, NOT to mergedConfig.muted (no-op for runtime).
|
|
7153
|
+
if (video !== null) {
|
|
7154
|
+
video.muted = false;
|
|
7155
|
+
}
|
|
7156
|
+
controller.state.muted = false;
|
|
7157
|
+
// Skip P2P attach for the intro stream (D-08); restored before main open.
|
|
7158
|
+
// CR-05: keep the dual-merged configs in sync (see openMainStream).
|
|
7159
|
+
mergedConfig.manager = false;
|
|
7160
|
+
controller.config.manager = false;
|
|
7161
|
+
reference.open(mergedConfig.preroll);
|
|
7162
|
+
}
|
|
6735
7163
|
// Auto-open the stream if src is provided in config (matches legacy player behaviour
|
|
6736
7164
|
// where consumers call start({ src: '...' }) and expect playback to begin immediately).
|
|
6737
7165
|
// When autoplay is false, defer open() until the user requests play — this avoids
|
|
6738
7166
|
// fetching CDN tokens and loading manifests before playback is actually needed.
|
|
6739
|
-
if (mergedConfig.src) {
|
|
7167
|
+
else if (mergedConfig.src) {
|
|
6740
7168
|
if (mergedConfig.autoplay) {
|
|
6741
7169
|
reference.open(mergedConfig.src);
|
|
6742
7170
|
}
|
|
@@ -6802,7 +7230,8 @@
|
|
|
6802
7230
|
// ---------------------------------------------------------------------------
|
|
6803
7231
|
if (typeof window !== 'undefined') {
|
|
6804
7232
|
console.info(`%cEBPlayer v${VERSION}`, 'color: #1FA9DD; font-weight: bold');
|
|
6805
|
-
|
|
7233
|
+
const win = window;
|
|
7234
|
+
win.EBPlayer = { start, stop, destroy, AVAILABLE_THEMES, THEME_LAYOUTS, version: VERSION };
|
|
6806
7235
|
}
|
|
6807
7236
|
|
|
6808
7237
|
/**
|