eb-player 2.1.1 → 2.1.3

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.1";
7
+ var __EB_PLAYER_VERSION__ = "2.1.3";
8
8
 
9
9
  /**
10
10
  * Finite State Machine for player playback state transitions.
@@ -5119,6 +5119,7 @@
5119
5119
  this.retryInterval = retryInterval;
5120
5120
  this.expirationMarginInSeconds = expirationMarginInSeconds;
5121
5121
  this.lastTokenResponse = null;
5122
+ this.sourceUrl = null;
5122
5123
  this.resetAttemptCounterTimeout = null;
5123
5124
  this.inFlightFetch = null;
5124
5125
  }
@@ -5437,8 +5438,17 @@
5437
5438
  }
5438
5439
  break;
5439
5440
  }
5441
+ // Query-param tokens (easy_b/venom/bunny) carry a prefix-scoped `token_path`
5442
+ // and share one grant across manifests and segments, so the grant must be
5443
+ // minted against the asset-level source URL rather than whichever URL
5444
+ // triggered this refresh. Akamai's path-segment mechanic is scoped
5445
+ // differently and is deliberately left on the requested URL.
5446
+ const sharesPrefixScopedGrant = this.tokenType === TOKEN_TYPES.EASY_B
5447
+ || this.tokenType === TOKEN_TYPES.VENOM
5448
+ || this.tokenType === TOKEN_TYPES.BUNNY;
5449
+ const fetchSrc = (sharesPrefixScopedGrant && this.sourceUrl) ? this.sourceUrl : srcUrl.toString();
5440
5450
  try {
5441
- const tokenResponse = await this.fetchToken({ src: srcUrl.toString() });
5451
+ const tokenResponse = await this.fetchToken({ src: fetchSrc });
5442
5452
  if (tokenResponse === null) {
5443
5453
  return url;
5444
5454
  }
@@ -5759,33 +5769,39 @@
5759
5769
  * Segment (fragment) CDN token signing — shared primitive.
5760
5770
  *
5761
5771
  * 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.
5772
+ * (`EngineSettings.signSegmentsWithCdnToken`). The fast path signs each segment
5773
+ * with the grant carried by its OWN parent playlist — read from
5774
+ * `context.frag.baseurl`, the object hls.js itself parsed that fragment from.
5775
+ *
5776
+ * Token expiry model (CDN-04, D-07, revised):
5777
+ *
5778
+ * `frag.baseurl` is a string frozen at the moment hls.js parsed the level
5779
+ * playlist. Live playlists reload on their own cadence, so their fragments pick
5780
+ * up refreshed grants for free — reload IS the refresh path. VOD playlists are
5781
+ * parsed exactly once by hls.js 1.6.10 (its level controller only (re)requests a
5782
+ * playlist it has none of, or one that is live), so for VOD that string never
5783
+ * changes. Pure transfer alone therefore pins a VOD asset's segments to the
5784
+ * first grant for the asset's entire duration, and every segment 403s once that
5785
+ * grant lapses — the condition CONTEXT.md named as D-07's revisit trigger, since
5786
+ * observed in production (a ~294s grant against a ~26min asset).
5787
+ *
5788
+ * So when the inherited grant is inside the manager's expiry margin, this module
5789
+ * now defers to the shared grant via `updateUrlWithTokenParams` rather than
5790
+ * copying a dead string.
5791
+ *
5792
+ * Why sharing a grant across renditions is safe (supersedes this module's
5793
+ * previous prohibition): the token service derives `token_path` from the
5794
+ * directory of the URL it signs, and `token_path` is a PREFIX scope. An
5795
+ * asset-level grant returns 200 on every rendition beneath it — verified live
5796
+ * across all four renditions of a real asset. The hazard this module previously
5797
+ * guarded against — one rendition's grant handed to another — is real ONLY for a
5798
+ * rendition-scoped grant, and that is prevented at its source by
5799
+ * `CDNTokenManager.sourceUrl`, which pins acquisition of the shared grant to the
5800
+ * asset-level source URL. The invariant is therefore "the shared grant must be
5801
+ * asset-level", not "segments must never refresh".
5802
+ *
5803
+ * Still true: no timer, interval, or scheduled re-fetch belongs in this module.
5804
+ * Refresh here is demand-driven, on the load that needs it.
5789
5805
  */
5790
5806
  // ---------------------------------------------------------------------------
5791
5807
  // Supported token-type family (D-03) — query-param tokens only. Akamai's
@@ -5851,6 +5867,23 @@
5851
5867
  return segmentUrl;
5852
5868
  }
5853
5869
  }
5870
+ /**
5871
+ * Reads the `expires` value off a parent playlist URL, or null when the URL
5872
+ * carries no token at all or cannot be parsed. Used to decide between the pure
5873
+ * transfer fast path and a shared-grant refresh.
5874
+ */
5875
+ function readPlaylistGrantExpiry(parentPlaylistUrl) {
5876
+ try {
5877
+ const playlistUrl = new URL(parentPlaylistUrl);
5878
+ const token = playlistUrl.searchParams.get('token');
5879
+ if (!token)
5880
+ return { hasToken: false, expires: null };
5881
+ return { hasToken: true, expires: playlistUrl.searchParams.get('expires') };
5882
+ }
5883
+ catch {
5884
+ return { hasToken: false, expires: null };
5885
+ }
5886
+ }
5854
5887
  /**
5855
5888
  * Returns a loader constructor extending `BaseLoader`: construct the base,
5856
5889
  * bind the inherited `load`, then assign an own `load` property that reads
@@ -5905,8 +5938,36 @@
5905
5938
  originalLoad(context, loadConfig, callbacks);
5906
5939
  return;
5907
5940
  }
5908
- context.url = signSegmentUrlFromPlaylist(context.url, baseUrl);
5909
- originalLoad(context, loadConfig, callbacks);
5941
+ const { hasToken, expires } = readPlaylistGrantExpiry(baseUrl);
5942
+ // Parent playlist carried no grant: nothing to transfer, and nothing to
5943
+ // refresh — an unsigned playlist means this path is not token-protected.
5944
+ if (!hasToken) {
5945
+ originalLoad(context, loadConfig, callbacks);
5946
+ return;
5947
+ }
5948
+ // Fast path: the inherited grant is still good. Pure synchronous transfer,
5949
+ // no manager involvement, no network.
5950
+ if (!tokenSource.isTokenExpired(expires)) {
5951
+ context.url = signSegmentUrlFromPlaylist(context.url, baseUrl);
5952
+ originalLoad(context, loadConfig, callbacks);
5953
+ return;
5954
+ }
5955
+ // Inherited grant is expired or inside the expiry margin. For VOD this is
5956
+ // terminal — hls.js will never re-parse the playlist, so the frozen
5957
+ // baseurl can never recover on its own. Defer to the shared asset-level
5958
+ // grant, which `CDNTokenManager.sourceUrl` keeps broad enough to cover
5959
+ // this segment.
5960
+ tokenSource.updateUrlWithTokenParams({ url: context.url })
5961
+ .then((updatedUrl) => {
5962
+ context.url = updatedUrl;
5963
+ originalLoad(context, loadConfig, callbacks);
5964
+ })
5965
+ .catch(() => {
5966
+ // Token error: fall back to the inherited grant rather than emitting an
5967
+ // unsigned request, and let the edge's 403 drive hls.js's own retry.
5968
+ context.url = signSegmentUrlFromPlaylist(context.url, baseUrl);
5969
+ originalLoad(context, loadConfig, callbacks);
5970
+ });
5910
5971
  };
5911
5972
  }
5912
5973
  function SegmentTokenLoader(loaderConfig) {
@@ -5937,6 +5998,17 @@
5937
5998
  * CRITICAL: Call applyDiscontinuityWorkaround BEFORE driver.attachMedia/loadSource (Pitfall 4).
5938
5999
  * CRITICAL: Never call driver.startLoad() from video 'play' event handler (Pitfall 3).
5939
6000
  */
6001
+ /**
6002
+ * Bound for the "load never started" watchdog: hls.js's own manifest-loading
6003
+ * budget (default manifestLoadingTimeOut 10 000 ms with one retry plus
6004
+ * backoff) tops out around 21s before hls.js surfaces its own fatal network
6005
+ * error, so 30s guarantees hls.js's own error path always wins first and
6006
+ * this watchdog only ever sees the case hls.js is silent about — a
6007
+ * StreamController parked in STOPPED with no error and no event. 30s also
6008
+ * matches the existing MAIN_OPEN_FALLBACK_MS precedent in src/eb-player.ts
6009
+ * and sits well below INTRO_MAX_DURATION_MS (90 000).
6010
+ */
6011
+ const LOAD_NEVER_STARTED_TIMEOUT_MS = 30000;
5940
6012
  // ---------------------------------------------------------------------------
5941
6013
  // HlsEngine
5942
6014
  // ---------------------------------------------------------------------------
@@ -5949,6 +6021,9 @@
5949
6021
  this.autoQuality = true;
5950
6022
  // Holds state reference for named driver event handlers
5951
6023
  this.eventState = null;
6024
+ // Bound "load never started" watchdog timer — 0-initialized (not null) so
6025
+ // clearTimeout(0) is a no-op, eliminating a null-guard phantom V8 branch.
6026
+ this.loadNeverStartedTimer = 0;
5952
6027
  this.liveSyncDisabled = false;
5953
6028
  }
5954
6029
  getDriver() {
@@ -5982,6 +6057,7 @@
5982
6057
  }
5983
6058
  onDetach() {
5984
6059
  this.stopWatchdog();
6060
+ this.clearLoadNeverStartedWatchdog();
5985
6061
  if (this.driver !== null) {
5986
6062
  try {
5987
6063
  this.driver.detachMedia();
@@ -6108,8 +6184,17 @@
6108
6184
  delete hlsSafeSettings['extraParamsCallback'];
6109
6185
  delete hlsSafeSettings['onCDNTokenError'];
6110
6186
  delete hlsSafeSettings['signSegmentsWithCdnToken'];
6187
+ // Do NOT set an explicit auto-start disable here. hls.js starts loading
6188
+ // segments on its own by default, and the resume position is already
6189
+ // carried by startPosition below — hls.js honours it without any help.
6190
+ // Nothing on the VOD path would ever restart a stopped loader: the only
6191
+ // two startLoad() call sites in this file are recoverFromStall() (only
6192
+ // reachable after playback has started) and the video 'play' listener
6193
+ // (gated on state.isLive, so VOD never reaches it). Disabling auto-start
6194
+ // here parks the StreamController in STOPPED forever for any non-zero
6195
+ // resume position — no variant playlist, no segments, no error, no bus
6196
+ // event, spinner forever.
6111
6197
  const driverConfig = {
6112
- autoStartLoad: !config.startAt,
6113
6198
  enableWorker: true,
6114
6199
  backBufferLength: 30,
6115
6200
  ...hlsSafeSettings,
@@ -6191,6 +6276,11 @@
6191
6276
  this.driver = driver;
6192
6277
  // Pitfall 4: apply discontinuity workaround BEFORE attachMedia/loadSource
6193
6278
  applyDiscontinuityWorkaround(driver, Hls.Events);
6279
+ // Hoisted once, used both for the retry wiring below and for gating the
6280
+ // "load never started" watchdog — the intro pass already has its own
6281
+ // bounds (INTRO_MAX_DURATION_MS, D-04 silent fall-through) and a second
6282
+ // overlapping bound would only race them.
6283
+ const isIntroPass = Boolean(config.preroll) && this.loadSourceUrl === config.preroll;
6194
6284
  // Wire retry handler — pass engine context so unrecoverable DRM errors
6195
6285
  // stop the stall watchdog (prevents useless reload loops) and surface to UI.
6196
6286
  // For the intro pass (loadSourceUrl matches config.preroll), also wire
@@ -6199,7 +6289,6 @@
6199
6289
  // This callback is intentionally NOT set for the main stream so transient
6200
6290
  // network blips still trigger the normal startLoad() recovery.
6201
6291
  if (config.retry) {
6202
- const isIntroPass = Boolean(config.preroll) && this.loadSourceUrl === config.preroll;
6203
6292
  handleHlsRetry(driver, {
6204
6293
  get chromecast_casting() { return state.isCasting; },
6205
6294
  onUnrecoverableError: (message) => {
@@ -6251,6 +6340,11 @@
6251
6340
  // stream URL is loaded rather than config.src which always points at the main stream.
6252
6341
  let src = this.loadSourceUrl || config.src || '';
6253
6342
  if (this.tokenManager && src) {
6343
+ // Pin shared-grant acquisition to the asset-level source URL before the
6344
+ // first fetch. The token service scopes `token_path` to the directory of
6345
+ // the URL it signs, so a grant minted against a level playlist (as an ABR
6346
+ // switch would trigger) is rendition-scoped and 403s on every sibling.
6347
+ this.tokenManager.sourceUrl = src;
6254
6348
  src = await this.tokenManager.updateUrlWithTokenParams({ url: src });
6255
6349
  // Guard: abort if detached during token URL update
6256
6350
  if (this.detached) {
@@ -6266,6 +6360,8 @@
6266
6360
  this.resolveDriverReady();
6267
6361
  // Register driver event handlers
6268
6362
  this.registerDriverEvents(Hls, state);
6363
+ // Arm the "load never started" watchdog — see LOAD_NEVER_STARTED_TIMEOUT_MS.
6364
+ this.armLoadNeverStartedWatchdog(driver, Hls, src, isIntroPass);
6269
6365
  // Pause/resume loading on video pause/play to stop manifest refreshes
6270
6366
  // (and thus CDN token requests) while the player is paused.
6271
6367
  // Only applies to live streams where hls.js continuously refreshes the manifest.
@@ -6334,6 +6430,43 @@
6334
6430
  driver.on(Events.SUBTITLE_TRACKS_UPDATED, this._onSubtitleTracksUpdated.bind(this));
6335
6431
  driver.on(Events.SUBTITLE_TRACK_SWITCH, this._onSubtitleTrackSwitch.bind(this));
6336
6432
  }
6433
+ // -------------------------------------------------------------------------
6434
+ // "Load never started" watchdog
6435
+ // -------------------------------------------------------------------------
6436
+ /**
6437
+ * Arms a one-shot bound that turns an unobservable hang into `state.error`
6438
+ * + a typed bus event: hls.js emits neither when its StreamController
6439
+ * never leaves STOPPED. Disarmed by the first FRAG_LOADING (proof that a
6440
+ * fragment request was actually issued, whether or not playback was
6441
+ * allowed to start — so autoplay-blocked playback never false-positives).
6442
+ * Never armed for the intro pass — it already has its own bounds.
6443
+ */
6444
+ armLoadNeverStartedWatchdog(driver, Hls, src, isIntroPass) {
6445
+ if (isIntroPass)
6446
+ return;
6447
+ driver.on(Hls.Events.FRAG_LOADING, this._onFragLoading.bind(this));
6448
+ this.loadNeverStartedTimer = setTimeout(this._onLoadNeverStartedTimeout.bind(this, src), LOAD_NEVER_STARTED_TIMEOUT_MS);
6449
+ }
6450
+ clearLoadNeverStartedWatchdog() {
6451
+ clearTimeout(this.loadNeverStartedTimer);
6452
+ this.loadNeverStartedTimer = 0;
6453
+ }
6454
+ _onFragLoading() {
6455
+ this.clearLoadNeverStartedWatchdog();
6456
+ }
6457
+ _onLoadNeverStartedTimeout(src) {
6458
+ if (this.detached)
6459
+ return;
6460
+ // Two-signal live gate (mirrors _onLevelLoaded): a stream that never
6461
+ // loaded a level detail never got its isLive set from the driver, so
6462
+ // the config-level signal must also be checked.
6463
+ if (this.state?.isLive === true || this.config?.isLive === true)
6464
+ return;
6465
+ if (this.state) {
6466
+ this.state.error = 'HlsEngine: load never started — no fragment request was issued within the bound';
6467
+ }
6468
+ this.bus?.emit('load-never-started', { src });
6469
+ }
6337
6470
  _onManifestLoaded(_event, data) {
6338
6471
  const state = this.eventState;
6339
6472
  const driver = this.driver;
@@ -7438,6 +7571,7 @@
7438
7571
  // skin-root.ts:141/318 and is a no-op for runtime mute.
7439
7572
  const originalMuted = mergedConfig.muted ?? false;
7440
7573
  const originalManager = mergedConfig.manager;
7574
+ const originalStartAt = mergedConfig.startAt;
7441
7575
  const controller = new PlayerController(runtimeConfig);
7442
7576
  controller.mount(container);
7443
7577
  // Video element is available after mount() creates the skin DOM
@@ -7663,6 +7797,10 @@
7663
7797
  // during the intro pass. Synchronize both objects on every swap.
7664
7798
  mergedConfig.manager = originalManager;
7665
7799
  controller.config.manager = originalManager;
7800
+ // Restore the viewer's resume position for the main asset — see the
7801
+ // rationale comment at the intro-side swap below (CR-05 sync applies here too).
7802
+ mergedConfig.startAt = originalStartAt;
7803
+ controller.config.startAt = originalStartAt;
7666
7804
  // CR-03: clear any stale error that leaked from the intro/ad pass.
7667
7805
  // IntroStreamManager's silent fall-through (D-04) treats error-fatal as a
7668
7806
  // normal completion but leaves state.error populated with the intro
@@ -7811,6 +7949,13 @@
7811
7949
  // CR-05: keep the dual-merged configs in sync (see openMainStream).
7812
7950
  mergedConfig.manager = false;
7813
7951
  controller.config.manager = false;
7952
+ // A viewer resume position belongs to the main asset only — applying it
7953
+ // to a short intro clip seeks past the whole clip. This defect survives
7954
+ // the startAt/autoStartLoad fix: after that fix, startPosition: 300 is
7955
+ // still applied to the intro, which is still wrong, just differently
7956
+ // wrong. Cleared here and restored in openMainStream() (CR-05 sync).
7957
+ mergedConfig.startAt = undefined;
7958
+ controller.config.startAt = undefined;
7814
7959
  reference.open(mergedConfig.preroll);
7815
7960
  }
7816
7961
  // D-02/D-08-02 (Phase 8): ad-only configuration — no intro stream, so this