eb-player 2.1.2 → 2.1.4

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.
@@ -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.1.2";
7
+ var __EB_PLAYER_VERSION__ = "2.1.4";
8
8
 
9
9
  /**
10
10
  * Finite State Machine for player playback state transitions.
@@ -4397,8 +4397,9 @@
4397
4397
  * Mount order:
4398
4398
  * 1. CSS vars / data-theme applied to container
4399
4399
  * 2. Config-derived state (isRtl, isRadio) set
4400
- * 3. SkinRoot connected (needs DOM) — creates video element and ads container
4401
- * 4. AutoHideController + KeyboardController wired
4400
+ * 3. SkinRoot connected (needs DOM) — creates the video element on every
4401
+ * mount, including noUi; the ads container is only rendered in UI mode
4402
+ * 4. AutoHideController + KeyboardController wired (UI mode only)
4402
4403
  * 5. Integrations initialized (need skin DOM for ads)
4403
4404
  *
4404
4405
  * Dispose order (reverse of mount):
@@ -4483,21 +4484,32 @@
4483
4484
  // Set config-derived state flags
4484
4485
  this.state.isRtl = this.config.lang === 'ar';
4485
4486
  this.state.isRadio = this.config.radio === true;
4486
- // Step 3: Create SkinRoot (unless noUi mode)
4487
+ // Step 3: Create SkinRoot. This runs on EVERY mount, including noUi —
4488
+ // SkinRoot is the only creator of the <video> element, and its own
4489
+ // template() already branches to a headless template that renders just
4490
+ // the bare element (see renderNoUiTemplate() in skin-root.ts). This block
4491
+ // used to be gated on `!this.config.noUi`, which left that headless
4492
+ // branch unreachable from the real player: a noUi mount produced an
4493
+ // empty container, no <video> was ever handed to the engine, and
4494
+ // therefore start() issued no CDN token, no manifest and no segment
4495
+ // request at all.
4496
+ const skinRoot = new SkinRoot();
4497
+ this.skinRoot = skinRoot;
4498
+ skinRoot.connect(container, this.state, this.bus, this.config, this.i18n);
4499
+ // Sync initial muted/volume from the actual video element so UI matches
4500
+ // reality. The volumechange event only fires on *changes*, not on initial
4501
+ // attribute values set during element creation. The browser is the source
4502
+ // of truth — it may override the muted attribute based on autoplay policy.
4503
+ const videoEl = skinRoot.getVideoElement();
4504
+ if (videoEl !== null) {
4505
+ this.state.muted = videoEl.muted;
4506
+ this.state.volume = videoEl.volume;
4507
+ }
4508
+ // Step 4: Wire activity and keyboard controllers — chrome-only, so they
4509
+ // stay gated on noUi (D-03). AutoHide drives visibility for controls that
4510
+ // don't exist headlessly; Keyboard would claim page-level key handling
4511
+ // that a headless integrator expects to own itself.
4487
4512
  if (!this.config.noUi) {
4488
- const skinRoot = new SkinRoot();
4489
- this.skinRoot = skinRoot;
4490
- skinRoot.connect(container, this.state, this.bus, this.config, this.i18n);
4491
- // Sync initial muted/volume from the actual video element so UI matches
4492
- // reality. The volumechange event only fires on *changes*, not on initial
4493
- // attribute values set during element creation. The browser is the source
4494
- // of truth — it may override the muted attribute based on autoplay policy.
4495
- const videoEl = skinRoot.getVideoElement();
4496
- if (videoEl !== null) {
4497
- this.state.muted = videoEl.muted;
4498
- this.state.volume = videoEl.volume;
4499
- }
4500
- // Step 4: Wire activity and keyboard controllers
4501
4513
  new AutoHideController(container, this.state, this.signal);
4502
4514
  new KeyboardController(container, this.state, this.bus, this.config, this.signal);
4503
4515
  }
@@ -4592,12 +4604,16 @@
4592
4604
  const playlistManager = new PlaylistManager();
4593
4605
  playlistManager.init(config, state, bus, signal);
4594
4606
  }
4595
- // AdsManager: skip when config.ad is undefined (D-09)
4607
+ // AdsManager: skip when config.ad is undefined (D-09), or when no ads
4608
+ // container exists — the headless template (D-04) renders none, so a
4609
+ // noUi mount must not construct AdsManager at all, not merely skip
4610
+ // init(). Constructing it unconditionally on skinRoot !== null would
4611
+ // regress now that SkinRoot is created in noUi mode too (D-01).
4596
4612
  if (config.ad && this.skinRoot !== null) {
4597
- const adsManager = new AdsManager();
4598
4613
  const video = this.skinRoot.getVideoElement();
4599
4614
  const adsContainer = this.skinRoot.getAdsContainer();
4600
4615
  if (video !== null && adsContainer !== null) {
4616
+ const adsManager = new AdsManager();
4601
4617
  adsManager.init(config, state, bus, video, adsContainer, signal).catch((error) => {
4602
4618
  console.error('EBPlayer: AdsManager init failed:', error);
4603
4619
  });
@@ -5119,6 +5135,7 @@
5119
5135
  this.retryInterval = retryInterval;
5120
5136
  this.expirationMarginInSeconds = expirationMarginInSeconds;
5121
5137
  this.lastTokenResponse = null;
5138
+ this.sourceUrl = null;
5122
5139
  this.resetAttemptCounterTimeout = null;
5123
5140
  this.inFlightFetch = null;
5124
5141
  }
@@ -5437,8 +5454,17 @@
5437
5454
  }
5438
5455
  break;
5439
5456
  }
5457
+ // Query-param tokens (easy_b/venom/bunny) carry a prefix-scoped `token_path`
5458
+ // and share one grant across manifests and segments, so the grant must be
5459
+ // minted against the asset-level source URL rather than whichever URL
5460
+ // triggered this refresh. Akamai's path-segment mechanic is scoped
5461
+ // differently and is deliberately left on the requested URL.
5462
+ const sharesPrefixScopedGrant = this.tokenType === TOKEN_TYPES.EASY_B
5463
+ || this.tokenType === TOKEN_TYPES.VENOM
5464
+ || this.tokenType === TOKEN_TYPES.BUNNY;
5465
+ const fetchSrc = (sharesPrefixScopedGrant && this.sourceUrl) ? this.sourceUrl : srcUrl.toString();
5440
5466
  try {
5441
- const tokenResponse = await this.fetchToken({ src: srcUrl.toString() });
5467
+ const tokenResponse = await this.fetchToken({ src: fetchSrc });
5442
5468
  if (tokenResponse === null) {
5443
5469
  return url;
5444
5470
  }
@@ -5759,33 +5785,39 @@
5759
5785
  * Segment (fragment) CDN token signing — shared primitive.
5760
5786
  *
5761
5787
  * Extends CDN token signing from manifests to segments, behind an opt-in flag
5762
- * (`EngineSettings.signSegmentsWithCdnToken`). Each segment is signed with the
5763
- * grant carried by its OWN parent playlist — read from `context.frag.baseurl`,
5764
- * the object hls.js itself parsed that fragment from — never from any store,
5765
- * cache, or shared manager state. This makes cross-rendition mis-signing
5766
- * structurally impossible rather than merely unlikely.
5767
- *
5768
- * CRITICAL: this module must never acquire or refresh a token. It only reads
5769
- * data already present on the fragment context handed to it by hls.js. Any
5770
- * acquisition call here would reintroduce the single-slot cache reuse in
5771
- * `CDNTokenManager.lastTokenResponse`, which hands one rendition's grant to
5772
- * another — the exact failure this design forbids.
5773
- *
5774
- * Token expiry model (CDN-04, D-07): a segment carries whatever grant its
5775
- * parent playlist carried when hls.js parsed it. Live playlists reload on
5776
- * their own cadence, so their fragments pick up refreshed grants with no
5777
- * refresh machinery here — reload IS the refresh path. VOD playlists are
5778
- * parsed exactly once by hls.js 1.6.10 (its level controller only
5779
- * (re)requests a playlist it has none of, or one that is live), so a VOD
5780
- * asset's segment grants are frozen at first parse for the asset's entire
5781
- * duration. If a token's lifetime is shorter than the asset's duration, later
5782
- * segments will be rejected by the edge and surface through the existing
5783
- * error/retry path. This is an accepted operational constraint — the token
5784
- * service's lifetime must exceed the longest VOD asset — not an oversight;
5785
- * D-07 chose reload-cadence refresh and CONTEXT.md defers a proactive
5786
- * near-expiry refresh path with a named revisit condition (a real asset shown
5787
- * to outlive its grant). No timer, interval, or scheduled re-fetch belongs in
5788
- * this module.
5788
+ * (`EngineSettings.signSegmentsWithCdnToken`). The fast path signs each segment
5789
+ * with the grant carried by its OWN parent playlist — read from
5790
+ * `context.frag.baseurl`, the object hls.js itself parsed that fragment from.
5791
+ *
5792
+ * Token expiry model (CDN-04, D-07, revised):
5793
+ *
5794
+ * `frag.baseurl` is a string frozen at the moment hls.js parsed the level
5795
+ * playlist. Live playlists reload on their own cadence, so their fragments pick
5796
+ * up refreshed grants for free — reload IS the refresh path. VOD playlists are
5797
+ * parsed exactly once by hls.js 1.6.10 (its level controller only (re)requests a
5798
+ * playlist it has none of, or one that is live), so for VOD that string never
5799
+ * changes. Pure transfer alone therefore pins a VOD asset's segments to the
5800
+ * first grant for the asset's entire duration, and every segment 403s once that
5801
+ * grant lapses — the condition CONTEXT.md named as D-07's revisit trigger, since
5802
+ * observed in production (a ~294s grant against a ~26min asset).
5803
+ *
5804
+ * So when the inherited grant is inside the manager's expiry margin, this module
5805
+ * now defers to the shared grant via `updateUrlWithTokenParams` rather than
5806
+ * copying a dead string.
5807
+ *
5808
+ * Why sharing a grant across renditions is safe (supersedes this module's
5809
+ * previous prohibition): the token service derives `token_path` from the
5810
+ * directory of the URL it signs, and `token_path` is a PREFIX scope. An
5811
+ * asset-level grant returns 200 on every rendition beneath it — verified live
5812
+ * across all four renditions of a real asset. The hazard this module previously
5813
+ * guarded against — one rendition's grant handed to another — is real ONLY for a
5814
+ * rendition-scoped grant, and that is prevented at its source by
5815
+ * `CDNTokenManager.sourceUrl`, which pins acquisition of the shared grant to the
5816
+ * asset-level source URL. The invariant is therefore "the shared grant must be
5817
+ * asset-level", not "segments must never refresh".
5818
+ *
5819
+ * Still true: no timer, interval, or scheduled re-fetch belongs in this module.
5820
+ * Refresh here is demand-driven, on the load that needs it.
5789
5821
  */
5790
5822
  // ---------------------------------------------------------------------------
5791
5823
  // Supported token-type family (D-03) — query-param tokens only. Akamai's
@@ -5851,6 +5883,23 @@
5851
5883
  return segmentUrl;
5852
5884
  }
5853
5885
  }
5886
+ /**
5887
+ * Reads the `expires` value off a parent playlist URL, or null when the URL
5888
+ * carries no token at all or cannot be parsed. Used to decide between the pure
5889
+ * transfer fast path and a shared-grant refresh.
5890
+ */
5891
+ function readPlaylistGrantExpiry(parentPlaylistUrl) {
5892
+ try {
5893
+ const playlistUrl = new URL(parentPlaylistUrl);
5894
+ const token = playlistUrl.searchParams.get('token');
5895
+ if (!token)
5896
+ return { hasToken: false, expires: null };
5897
+ return { hasToken: true, expires: playlistUrl.searchParams.get('expires') };
5898
+ }
5899
+ catch {
5900
+ return { hasToken: false, expires: null };
5901
+ }
5902
+ }
5854
5903
  /**
5855
5904
  * Returns a loader constructor extending `BaseLoader`: construct the base,
5856
5905
  * bind the inherited `load`, then assign an own `load` property that reads
@@ -5905,8 +5954,36 @@
5905
5954
  originalLoad(context, loadConfig, callbacks);
5906
5955
  return;
5907
5956
  }
5908
- context.url = signSegmentUrlFromPlaylist(context.url, baseUrl);
5909
- originalLoad(context, loadConfig, callbacks);
5957
+ const { hasToken, expires } = readPlaylistGrantExpiry(baseUrl);
5958
+ // Parent playlist carried no grant: nothing to transfer, and nothing to
5959
+ // refresh — an unsigned playlist means this path is not token-protected.
5960
+ if (!hasToken) {
5961
+ originalLoad(context, loadConfig, callbacks);
5962
+ return;
5963
+ }
5964
+ // Fast path: the inherited grant is still good. Pure synchronous transfer,
5965
+ // no manager involvement, no network.
5966
+ if (!tokenSource.isTokenExpired(expires)) {
5967
+ context.url = signSegmentUrlFromPlaylist(context.url, baseUrl);
5968
+ originalLoad(context, loadConfig, callbacks);
5969
+ return;
5970
+ }
5971
+ // Inherited grant is expired or inside the expiry margin. For VOD this is
5972
+ // terminal — hls.js will never re-parse the playlist, so the frozen
5973
+ // baseurl can never recover on its own. Defer to the shared asset-level
5974
+ // grant, which `CDNTokenManager.sourceUrl` keeps broad enough to cover
5975
+ // this segment.
5976
+ tokenSource.updateUrlWithTokenParams({ url: context.url })
5977
+ .then((updatedUrl) => {
5978
+ context.url = updatedUrl;
5979
+ originalLoad(context, loadConfig, callbacks);
5980
+ })
5981
+ .catch(() => {
5982
+ // Token error: fall back to the inherited grant rather than emitting an
5983
+ // unsigned request, and let the edge's 403 drive hls.js's own retry.
5984
+ context.url = signSegmentUrlFromPlaylist(context.url, baseUrl);
5985
+ originalLoad(context, loadConfig, callbacks);
5986
+ });
5910
5987
  };
5911
5988
  }
5912
5989
  function SegmentTokenLoader(loaderConfig) {
@@ -6279,6 +6356,11 @@
6279
6356
  // stream URL is loaded rather than config.src which always points at the main stream.
6280
6357
  let src = this.loadSourceUrl || config.src || '';
6281
6358
  if (this.tokenManager && src) {
6359
+ // Pin shared-grant acquisition to the asset-level source URL before the
6360
+ // first fetch. The token service scopes `token_path` to the directory of
6361
+ // the URL it signs, so a grant minted against a level playlist (as an ABR
6362
+ // switch would trigger) is rendition-scoped and 403s on every sibling.
6363
+ this.tokenManager.sourceUrl = src;
6282
6364
  src = await this.tokenManager.updateUrlWithTokenParams({ url: src });
6283
6365
  // Guard: abort if detached during token URL update
6284
6366
  if (this.detached) {
@@ -7465,10 +7547,11 @@
7465
7547
  * (20s) so that more precise, better-logged inner mechanism wins first in a
7466
7548
  * live player — this is the outer safety net, not the primary signal. Covers
7467
7549
  * two cases the inner mechanism cannot: an AdsManager that was never
7468
- * constructed at all (noUi:true, or a null ads container — lifecycle.ts
7469
- * skips the branch that would create one), and an AdsManager that obtained
7470
- * its manager from the loader (disarming its own bound) whose creative then
7471
- * never reached the screen.
7550
+ * constructed at all (a headless player renders no ads container, so the
7551
+ * ads branch in lifecycle.ts is still skipped for that reason (D-04) — this
7552
+ * backstop applies unchanged to noUi + ad configurations), and an AdsManager
7553
+ * that obtained its manager from the loader (disarming its own bound) whose
7554
+ * creative then never reached the screen.
7472
7555
  */
7473
7556
  const MAIN_OPEN_FALLBACK_MS = 30000;
7474
7557
  // ---------------------------------------------------------------------------
@@ -7503,7 +7586,13 @@
7503
7586
  // so the config-level swap is legitimate for that field only). NEVER mutate
7504
7587
  // `mergedConfig.muted` — it is consumed only at video element creation in
7505
7588
  // skin-root.ts:141/318 and is a no-op for runtime mute.
7506
- const originalMuted = mergedConfig.muted ?? false;
7589
+ //
7590
+ // `mergedConfig.muted` is typed `boolean` (required, non-optional) on
7591
+ // PlayerConfig, and DEFAULT_CONFIG.muted is the boolean literal `true` —
7592
+ // mergeConfig()'s deepMerge always falls back to the base value for an
7593
+ // undefined override (see config.ts), so this field can never be nullish
7594
+ // through the real merge. No `?? false` fallback needed.
7595
+ const originalMuted = mergedConfig.muted;
7507
7596
  const originalManager = mergedConfig.manager;
7508
7597
  const originalStartAt = mergedConfig.startAt;
7509
7598
  const controller = new PlayerController(runtimeConfig);
@@ -7682,6 +7771,21 @@
7682
7771
  controller.bus.on('error-fatal', () => {
7683
7772
  inst.p2p?.stop();
7684
7773
  }, { signal: controller.signal });
7774
+ // Fail loud when mount() produced no video element (D-06). Without one,
7775
+ // engine.setVideo() is skipped inside open() (see the `video !== null`
7776
+ // guard above), so no engine ever attaches media and nothing is requested
7777
+ // at all — no CDN token, no manifest, no segments. No watchdog covers
7778
+ // this: the load-never-started watchdog lives inside the engine and is
7779
+ // only armed once a driver exists. state.error (not a bus emit) is the
7780
+ // channel here because start() has not returned the reference yet, so no
7781
+ // consumer can have subscribed to the bus at this point, whereas the
7782
+ // reactive state store is readable at any time afterwards.
7783
+ if (video === null) {
7784
+ const message = 'EBPlayer: no <video> element found in the container after mount() — playback cannot start.';
7785
+ console.error(message);
7786
+ controller.state.error = message;
7787
+ return reference;
7788
+ }
7685
7789
  // Phase 7/8 — pre-main leg orchestration.
7686
7790
  // When mergedConfig.preroll is set, open the intro stream first, then on
7687
7791
  // bus 'intro-stream-complete' (emitted by IntroStreamManager on video.ended,
@@ -7718,9 +7822,11 @@
7718
7822
  // same tick. complete() is idempotent, so on the normal ordering (intro
7719
7823
  // already finished) this is a no-op; the listener is already torn down.
7720
7824
  controller.bus.emit('force-intro-complete');
7721
- if (video !== null) {
7722
- video.muted = originalMuted;
7723
- }
7825
+ // D-06: `video` is guaranteed non-null here — the guard above (line ~415)
7826
+ // already returned early when mount() produced no <video>, and `video`
7827
+ // is a `const`, so TypeScript's control-flow narrowing carries the
7828
+ // non-null guarantee into this closure without a redundant re-check.
7829
+ video.muted = originalMuted;
7724
7830
  controller.state.muted = originalMuted;
7725
7831
  // CR-05: keep mergedConfig.manager and controller.config.manager in sync.
7726
7832
  // start() builds mergedConfig and PlayerController builds its own merged
@@ -7805,7 +7911,9 @@
7805
7911
  return;
7806
7912
  deferredPlaybackStarted = true;
7807
7913
  const engine = inst.engine;
7808
- if (engine === null || video === null)
7914
+ // D-06: `video === null` is unreachable here — see the comment on the
7915
+ // equivalent guard in openMainStream() above.
7916
+ if (engine === null)
7809
7917
  return;
7810
7918
  // Honor the user's click intent. CommandHandler's earlier bus.on('play')
7811
7919
  // listener fired before us with no source attached, so its video.play()
@@ -7875,9 +7983,9 @@
7875
7983
  }, { signal: controller.signal });
7876
7984
  // Force runtime mute off for the intro pass — applied to the LIVE <video>
7877
7985
  // element and PlayerState, NOT to mergedConfig.muted (no-op for runtime).
7878
- if (video !== null) {
7879
- video.muted = false;
7880
- }
7986
+ // D-06: `video` is guaranteed non-null here — see the comment on the
7987
+ // equivalent restore in openMainStream() above.
7988
+ video.muted = false;
7881
7989
  controller.state.muted = false;
7882
7990
  // Skip P2P attach for the intro stream (D-08); restored before main open.
7883
7991
  // CR-05: keep the dual-merged configs in sync (see openMainStream).