eb-player 2.0.18 → 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.
Files changed (34) hide show
  1. package/dist/build/ebplayer.bundle.js +437 -42
  2. package/dist/build/ebplayer.bundle.js.map +1 -1
  3. package/dist/build/types/core/event-bus.d.ts +2 -0
  4. package/dist/build/types/core/event-bus.d.ts.map +1 -1
  5. package/dist/build/types/core/i18n.d.ts.map +1 -1
  6. package/dist/build/types/core/lifecycle.d.ts.map +1 -1
  7. package/dist/build/types/core/player-state.d.ts.map +1 -1
  8. package/dist/build/types/core/types.d.ts +1 -0
  9. package/dist/build/types/core/types.d.ts.map +1 -1
  10. package/dist/build/types/eb-player.d.ts.map +1 -1
  11. package/dist/build/types/engines/base-engine.d.ts +16 -0
  12. package/dist/build/types/engines/base-engine.d.ts.map +1 -1
  13. package/dist/build/types/engines/dash.d.ts.map +1 -1
  14. package/dist/build/types/engines/hls.d.ts.map +1 -1
  15. package/dist/build/types/engines/retry/hls.d.ts +13 -1
  16. package/dist/build/types/engines/retry/hls.d.ts.map +1 -1
  17. package/dist/build/types/integrations/ads-manager.d.ts +6 -1
  18. package/dist/build/types/integrations/ads-manager.d.ts.map +1 -1
  19. package/dist/build/types/integrations/index.d.ts +1 -0
  20. package/dist/build/types/integrations/index.d.ts.map +1 -1
  21. package/dist/build/types/integrations/intro-stream-manager.d.ts +33 -0
  22. package/dist/build/types/integrations/intro-stream-manager.d.ts.map +1 -0
  23. package/dist/build/types/skin/controllers/auto-hide.d.ts +2 -2
  24. package/dist/build/types/skin/controllers/auto-hide.d.ts.map +1 -1
  25. package/dist/build/types/skin/controllers/keyboard.d.ts +1 -1
  26. package/dist/build/types/skin/controls/forward-button.d.ts +1 -1
  27. package/dist/build/types/skin/controls/forward-button.d.ts.map +1 -1
  28. package/dist/build/types/skin/controls/rewind-button.d.ts +1 -1
  29. package/dist/build/types/skin/controls/rewind-button.d.ts.map +1 -1
  30. package/dist/build/types/skin/controls/seekbar.d.ts.map +1 -1
  31. package/dist/build/types/skin/overlays/preroll-overlay.d.ts +39 -0
  32. package/dist/build/types/skin/overlays/preroll-overlay.d.ts.map +1 -0
  33. package/dist/build/types/skin/skin-root.d.ts.map +1 -1
  34. package/package.json +1 -1
@@ -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.18";
7
+ var __EB_PLAYER_VERSION__ = "2.0.19";
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');
@@ -3360,8 +3463,8 @@
3360
3463
  * AutoHideController manages the 3-second auto-hide timer for player controls.
3361
3464
  *
3362
3465
  * 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 and
3364
- * 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.
3365
3468
  *
3366
3469
  * Note: Uses manual removeEventListener instead of the { signal } option in
3367
3470
  * addEventListener. JSDOM 28 rejects non-JSDOM AbortSignal instances in the
@@ -3406,7 +3509,8 @@
3406
3509
  const shouldKeepVisible = this.state.settingsOpen ||
3407
3510
  this.state.socialsOpen ||
3408
3511
  this.state.infoOpen ||
3409
- this.state.adPlaying;
3512
+ this.state.adPlaying ||
3513
+ this.state.introStreamPlaying;
3410
3514
  if (!shouldKeepVisible) {
3411
3515
  this.state.controlsVisible = false;
3412
3516
  }
@@ -3421,7 +3525,7 @@
3421
3525
  * ArrowRight: seek forward by config.seekOffset seconds (clamped to duration).
3422
3526
  * m: mutes/unmutes — only when config.supportHotKeys is true.
3423
3527
  *
3424
- * All shortcuts are disabled during ads (state.adPlaying === true).
3528
+ * All shortcuts are disabled during ads or intro streams (state.adPlaying || state.introStreamPlaying).
3425
3529
  *
3426
3530
  * The container must be focusable to receive keyboard events.
3427
3531
  * If container.tabIndex < 0, it is set to 0 automatically.
@@ -3443,8 +3547,8 @@
3443
3547
  }
3444
3548
  const handleKeyup = (event) => {
3445
3549
  const keyEvent = event;
3446
- // Ignore all keys during ad playback
3447
- if (state.adPlaying) {
3550
+ // Ignore all keys during ad playback or intro-stream playback
3551
+ if (state.adPlaying || state.introStreamPlaying) {
3448
3552
  return;
3449
3553
  }
3450
3554
  if (keyEvent.key === ' ') {
@@ -3776,15 +3880,28 @@
3776
3880
  }
3777
3881
  /**
3778
3882
  * Initialize the IMA SDK preroll ad system.
3779
- * - Skips if config.preroll is false/undefined
3883
+ * - Skips if config.ad is undefined
3780
3884
  * - If !config.autoplay: waits for user click on video element before initializing
3781
3885
  * (required by browser autoplay policies to avoid muted/blocked display contexts)
3782
3886
  * - Loads IMA SDK, creates AdDisplayContainer, AdsLoader, and AdsRequest
3783
3887
  * - Wires ad lifecycle events to PlayerState and TypedEventBus
3784
3888
  */
3785
3889
  async init(config, state, bus, video, adsContainer, signal) {
3786
- if (!config.preroll)
3890
+ if (!config.ad)
3787
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
+ }
3788
3905
  // Gate initialization on user gesture when not in autoplay mode
3789
3906
  if (!config.autoplay) {
3790
3907
  await this.waitForUserGesture(video, signal);
@@ -3797,13 +3914,29 @@
3797
3914
  this.imaDisplayContainer = displayContainer;
3798
3915
  displayContainer.initialize();
3799
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
+ });
3800
3928
  adsLoader.addEventListener(ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED, (loadedEvent) => {
3801
3929
  const event = loadedEvent;
3802
3930
  const adsManager = event.getAdsManager(video);
3803
3931
  this.imaAdsManager = adsManager;
3804
- const { width, height } = video.getBoundingClientRect
3805
- ? video.getBoundingClientRect()
3806
- : { width: 640, height: 360 };
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;
3807
3940
  // Wire event handlers for ad lifecycle
3808
3941
  adsManager.addEventListener(ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED, () => {
3809
3942
  state.adPlaying = true;
@@ -3816,12 +3949,20 @@
3816
3949
  state.adPlaying = false;
3817
3950
  bus.emit('ad-complete');
3818
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
+ });
3819
3960
  adsManager.init(width, height);
3820
3961
  adsManager.start();
3821
3962
  });
3822
3963
  // Load the ad tag
3823
3964
  const adsRequest = new ima.AdsRequest();
3824
- adsRequest.adTagUrl = config.preroll;
3965
+ adsRequest.adTagUrl = config.ad;
3825
3966
  adsLoader.requestAds(adsRequest);
3826
3967
  // Register abort cleanup
3827
3968
  signal.addEventListener('abort', () => {
@@ -3838,23 +3979,95 @@
3838
3979
  /**
3839
3980
  * Wait for a click event on the video element.
3840
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.
3841
3987
  */
3842
3988
  waitForUserGesture(video, signal) {
3843
3989
  return new Promise((resolve) => {
3844
- const onClick = () => {
3990
+ let settled = false;
3991
+ const settle = () => {
3992
+ if (settled)
3993
+ return;
3994
+ settled = true;
3845
3995
  video.removeEventListener('click', onClick);
3846
3996
  resolve();
3847
3997
  };
3998
+ const onClick = () => settle();
3848
3999
  video.addEventListener('click', onClick);
3849
- // Clean up if aborted before click
3850
- signal.addEventListener('abort', () => {
3851
- video.removeEventListener('click', onClick);
3852
- resolve();
3853
- }, { 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 });
3854
4003
  });
3855
4004
  }
3856
4005
  }
3857
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
+
3858
4071
  /**
3859
4072
  * Poster HLS handler.
3860
4073
  *
@@ -4100,8 +4313,8 @@
4100
4313
  const playlistManager = new PlaylistManager();
4101
4314
  playlistManager.init(config, state, bus, signal);
4102
4315
  }
4103
- // AdsManager: skip when config.preroll is false
4104
- if (config.preroll && this.skinRoot !== null) {
4316
+ // AdsManager: skip when config.ad is undefined (D-09)
4317
+ if (config.ad && this.skinRoot !== null) {
4105
4318
  const adsManager = new AdsManager();
4106
4319
  const video = this.skinRoot.getVideoElement();
4107
4320
  const adsContainer = this.skinRoot.getAdsContainer();
@@ -4111,6 +4324,16 @@
4111
4324
  });
4112
4325
  }
4113
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
+ }
4114
4337
  // Poster handler: loop an HLS stream as video background when configured
4115
4338
  if (config.posterStream) {
4116
4339
  const posterHandler = createPosterHandler();
@@ -4255,6 +4478,14 @@
4255
4478
  this.signal = null;
4256
4479
  this.bus = null;
4257
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 = '';
4258
4489
  this.watchdog = null;
4259
4490
  this.driverReady = new Promise((resolve) => {
4260
4491
  this.resolveDriverReady = resolve;
@@ -4277,6 +4508,7 @@
4277
4508
  this.signal = null;
4278
4509
  this.bus = null;
4279
4510
  this.config = null;
4511
+ this.loadSourceUrl = '';
4280
4512
  }
4281
4513
  // -------------------------------------------------------------------------
4282
4514
  // Stall watchdog helpers
@@ -4318,6 +4550,16 @@
4318
4550
  setVideo(video) {
4319
4551
  this.video = video;
4320
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
+ }
4321
4563
  // -------------------------------------------------------------------------
4322
4564
  // Video element event binding
4323
4565
  // -------------------------------------------------------------------------
@@ -5093,7 +5335,8 @@
5093
5335
  * automatic recovery:
5094
5336
  * - Fatal mediaError: calls recoverMediaError() after 1s
5095
5337
  * - Fatal keySystemError: unrecoverable (e.g. no HTTPS for DRM) — no retry
5096
- * - Fatal other error: calls startLoad() after 1s
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)
5097
5340
  * - Non-fatal error: re-checks after 3s; if currentTime has not advanced
5098
5341
  * (and Chromecast is not casting), applies the same recovery
5099
5342
  *
@@ -5112,6 +5355,8 @@
5112
5355
  const exhaustedErrors = new Set();
5113
5356
  /** Tracks unrecoverable fatal errors that have already been reported */
5114
5357
  const reportedUnrecoverable = new Set();
5358
+ /** Ensures onFatalNetworkError is only called once per handler instance */
5359
+ let fatalNetworkErrorReported = false;
5115
5360
  let lastSuccessfulTime;
5116
5361
  driver.on('hlsError', (event, data) => {
5117
5362
  const errorKey = data.details ?? data.type;
@@ -5132,6 +5377,17 @@
5132
5377
  engine?.onUnrecoverableError?.(`DRM error: ${errorKey}`);
5133
5378
  return;
5134
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
+ }
5135
5391
  console.warn('HLS Retry: Fatal error, trying to fix now.', event, data);
5136
5392
  setTimeout(() => {
5137
5393
  if (data.type === 'mediaError')
@@ -5470,7 +5726,13 @@
5470
5726
  applyDiscontinuityWorkaround(driver, Hls.Events);
5471
5727
  // Wire retry handler — pass engine context so unrecoverable DRM errors
5472
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.
5473
5734
  if (config.retry) {
5735
+ const isIntroPass = Boolean(config.preroll) && this.loadSourceUrl === config.preroll;
5474
5736
  handleHlsRetry(driver, {
5475
5737
  get chromecast_casting() { return state.isCasting; },
5476
5738
  onUnrecoverableError: (message) => {
@@ -5481,7 +5743,18 @@
5481
5743
  // hls.js nulls its media on a fatal keySystemError; signal so the
5482
5744
  // P2P SDK is torn down before it loops on a null media element.
5483
5745
  this.bus?.emit('error-fatal', { code: 'DRM', message });
5484
- }
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
+ : {}),
5485
5758
  });
5486
5759
  }
5487
5760
  // Geo-block detection (opt-in, ported from v1.x).
@@ -5506,8 +5779,10 @@
5506
5779
  this.bindVideoEvents(video, state, signal);
5507
5780
  // Attach media and load source
5508
5781
  driver.attachMedia(video);
5509
- // Build source URL with token params if applicable
5510
- let src = config.src ?? '';
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 || '';
5511
5786
  if (this.tokenManager && src) {
5512
5787
  src = await this.tokenManager.updateUrlWithTokenParams({ url: src });
5513
5788
  // Guard: abort if detached during token URL update
@@ -6015,7 +6290,9 @@
6015
6290
  }
6016
6291
  this.driver = player;
6017
6292
  this.resolveDriverReady();
6018
- player.initialize(video, config.src ?? '', config.autoplay ?? false);
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);
6019
6296
  // Apply retry settings if requested
6020
6297
  if (config.retry) {
6021
6298
  applyDashRetrySettings(player);
@@ -6574,6 +6851,25 @@
6574
6851
  return isDash ? new DashEngine() : new HlsEngine();
6575
6852
  }
6576
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
+ // ---------------------------------------------------------------------------
6577
6873
  // Public API
6578
6874
  // ---------------------------------------------------------------------------
6579
6875
  /**
@@ -6596,6 +6892,17 @@
6596
6892
  instances.delete(container);
6597
6893
  }
6598
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;
6599
6906
  const controller = new PlayerController(runtimeConfig);
6600
6907
  controller.mount(container);
6601
6908
  // Video element is available after mount() creates the skin DOM
@@ -6624,6 +6931,11 @@
6624
6931
  }
6625
6932
  engine.setBus(controller.bus);
6626
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);
6627
6939
  controller.setEngineSync(engine);
6628
6940
  inst.engine = engine;
6629
6941
  // P2P integration: wire EasyBroadcast SDK when config.lib and config.manager are set
@@ -6646,8 +6958,12 @@
6646
6958
  console.error('EBPlayer: P2PManager integrate failed:', error);
6647
6959
  });
6648
6960
  }
6649
- // Snapshot handler: create snapshot engine for seekbar preview thumbnails
6650
- if (mergedConfig.showProgressThumb) {
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) {
6651
6967
  // Clean up any previous snapshot handler
6652
6968
  if (inst.snapshotDestroy !== null) {
6653
6969
  inst.snapshotDestroy();
@@ -6743,10 +7059,8 @@
6743
7059
  // Get saved selections before close (CommandHandler saved them before emitting request-reload)
6744
7060
  const saved = controller.getSavedSelections();
6745
7061
  reference.close();
6746
- // 500ms delay gives the browser time to complete async EME teardown
6747
- // (setMediaKeys(null) is async and hls.js destroy() doesn't await it).
6748
- // 100ms was too short and caused "The existing ContentDecryptor" errors.
6749
- setTimeout(() => {
7062
+ // EME teardown delay see waitForEmeTeardown / EME_TEARDOWN_DELAY_MS.
7063
+ waitForEmeTeardown().then(() => {
6750
7064
  reference.open(currentSrc);
6751
7065
  // Restore selections after manifest loads (~2s pragmatic delay for async manifest parsing)
6752
7066
  setTimeout(() => {
@@ -6756,7 +7070,7 @@
6756
7070
  inst.engine.setSubtitle(saved.subtitleTrack);
6757
7071
  }
6758
7072
  }, 2000);
6759
- }, 500);
7073
+ });
6760
7074
  });
6761
7075
  // On an unrecoverable engine error (e.g. fatal DRM keySystemError), tear down
6762
7076
  // the P2P SDK. hls.js nulls its media element, so a still-running eblib would
@@ -6765,11 +7079,92 @@
6765
7079
  controller.bus.on('error-fatal', () => {
6766
7080
  inst.p2p?.stop();
6767
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
+ }
6768
7163
  // Auto-open the stream if src is provided in config (matches legacy player behaviour
6769
7164
  // where consumers call start({ src: '...' }) and expect playback to begin immediately).
6770
7165
  // When autoplay is false, defer open() until the user requests play — this avoids
6771
7166
  // fetching CDN tokens and loading manifests before playback is actually needed.
6772
- if (mergedConfig.src) {
7167
+ else if (mergedConfig.src) {
6773
7168
  if (mergedConfig.autoplay) {
6774
7169
  reference.open(mergedConfig.src);
6775
7170
  }