eb-player 2.1.3 → 2.1.5

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.3";
7
+ var __EB_PLAYER_VERSION__ = "2.1.5";
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):
@@ -4420,6 +4421,11 @@
4420
4421
  return this._container;
4421
4422
  }
4422
4423
  constructor(config) {
4424
+ // Captured BEFORE merging with DEFAULT_CONFIG: DEFAULT_CONFIG.primaryColor is a
4425
+ // non-empty fallback ('#1FA9DD'), unlike skinColor/skinColors.* which default to
4426
+ // ''. Without this flag, mount() cannot tell "caller explicitly set primaryColor"
4427
+ // apart from "value is just the default surviving the merge" — see mount() below.
4428
+ this.hasExplicitPrimaryColor = config.primaryColor !== undefined;
4423
4429
  this.config = mergeConfig(DEFAULT_CONFIG, config);
4424
4430
  this.state = createPlayerState();
4425
4431
  this.bus = new TypedEventBus();
@@ -4440,7 +4446,9 @@
4440
4446
  * managers based on config flags.
4441
4447
  *
4442
4448
  * CSS application order (later wins):
4443
- * 1. primaryColor → --eb-color-primary
4449
+ * 1. primaryColor → --eb-color-primary (only when the caller explicitly set it —
4450
+ * an unset primaryColor falls back to DEFAULT_CONFIG's default and must not
4451
+ * override an active theme's own colors)
4444
4452
  * 2. skinColors.general → --eb-color-primary (overrides primaryColor)
4445
4453
  * 3. skinColors.progressBar → --eb-color-progress
4446
4454
  * 4. skinColors.volumeBar → --eb-color-volume
@@ -4463,7 +4471,7 @@
4463
4471
  }
4464
4472
  // Apply runtime CSS custom properties for backward compat with primaryColor / skinColors
4465
4473
  const skinColors = this.config.skinColors;
4466
- if (this.config.primaryColor) {
4474
+ if (this.hasExplicitPrimaryColor && this.config.primaryColor) {
4467
4475
  container.style.setProperty('--eb-color-primary', this.config.primaryColor);
4468
4476
  container.style.setProperty('--eb-color-progress', this.config.primaryColor);
4469
4477
  container.style.setProperty('--eb-accent', this.config.primaryColor);
@@ -4483,21 +4491,32 @@
4483
4491
  // Set config-derived state flags
4484
4492
  this.state.isRtl = this.config.lang === 'ar';
4485
4493
  this.state.isRadio = this.config.radio === true;
4486
- // Step 3: Create SkinRoot (unless noUi mode)
4494
+ // Step 3: Create SkinRoot. This runs on EVERY mount, including noUi —
4495
+ // SkinRoot is the only creator of the <video> element, and its own
4496
+ // template() already branches to a headless template that renders just
4497
+ // the bare element (see renderNoUiTemplate() in skin-root.ts). This block
4498
+ // used to be gated on `!this.config.noUi`, which left that headless
4499
+ // branch unreachable from the real player: a noUi mount produced an
4500
+ // empty container, no <video> was ever handed to the engine, and
4501
+ // therefore start() issued no CDN token, no manifest and no segment
4502
+ // request at all.
4503
+ const skinRoot = new SkinRoot();
4504
+ this.skinRoot = skinRoot;
4505
+ skinRoot.connect(container, this.state, this.bus, this.config, this.i18n);
4506
+ // Sync initial muted/volume from the actual video element so UI matches
4507
+ // reality. The volumechange event only fires on *changes*, not on initial
4508
+ // attribute values set during element creation. The browser is the source
4509
+ // of truth — it may override the muted attribute based on autoplay policy.
4510
+ const videoEl = skinRoot.getVideoElement();
4511
+ if (videoEl !== null) {
4512
+ this.state.muted = videoEl.muted;
4513
+ this.state.volume = videoEl.volume;
4514
+ }
4515
+ // Step 4: Wire activity and keyboard controllers — chrome-only, so they
4516
+ // stay gated on noUi (D-03). AutoHide drives visibility for controls that
4517
+ // don't exist headlessly; Keyboard would claim page-level key handling
4518
+ // that a headless integrator expects to own itself.
4487
4519
  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
4520
  new AutoHideController(container, this.state, this.signal);
4502
4521
  new KeyboardController(container, this.state, this.bus, this.config, this.signal);
4503
4522
  }
@@ -4592,12 +4611,16 @@
4592
4611
  const playlistManager = new PlaylistManager();
4593
4612
  playlistManager.init(config, state, bus, signal);
4594
4613
  }
4595
- // AdsManager: skip when config.ad is undefined (D-09)
4614
+ // AdsManager: skip when config.ad is undefined (D-09), or when no ads
4615
+ // container exists — the headless template (D-04) renders none, so a
4616
+ // noUi mount must not construct AdsManager at all, not merely skip
4617
+ // init(). Constructing it unconditionally on skinRoot !== null would
4618
+ // regress now that SkinRoot is created in noUi mode too (D-01).
4596
4619
  if (config.ad && this.skinRoot !== null) {
4597
- const adsManager = new AdsManager();
4598
4620
  const video = this.skinRoot.getVideoElement();
4599
4621
  const adsContainer = this.skinRoot.getAdsContainer();
4600
4622
  if (video !== null && adsContainer !== null) {
4623
+ const adsManager = new AdsManager();
4601
4624
  adsManager.init(config, state, bus, video, adsContainer, signal).catch((error) => {
4602
4625
  console.error('EBPlayer: AdsManager init failed:', error);
4603
4626
  });
@@ -7531,10 +7554,11 @@
7531
7554
  * (20s) so that more precise, better-logged inner mechanism wins first in a
7532
7555
  * live player — this is the outer safety net, not the primary signal. Covers
7533
7556
  * two cases the inner mechanism cannot: an AdsManager that was never
7534
- * constructed at all (noUi:true, or a null ads container — lifecycle.ts
7535
- * skips the branch that would create one), and an AdsManager that obtained
7536
- * its manager from the loader (disarming its own bound) whose creative then
7537
- * never reached the screen.
7557
+ * constructed at all (a headless player renders no ads container, so the
7558
+ * ads branch in lifecycle.ts is still skipped for that reason (D-04) — this
7559
+ * backstop applies unchanged to noUi + ad configurations), and an AdsManager
7560
+ * that obtained its manager from the loader (disarming its own bound) whose
7561
+ * creative then never reached the screen.
7538
7562
  */
7539
7563
  const MAIN_OPEN_FALLBACK_MS = 30000;
7540
7564
  // ---------------------------------------------------------------------------
@@ -7569,7 +7593,13 @@
7569
7593
  // so the config-level swap is legitimate for that field only). NEVER mutate
7570
7594
  // `mergedConfig.muted` — it is consumed only at video element creation in
7571
7595
  // skin-root.ts:141/318 and is a no-op for runtime mute.
7572
- const originalMuted = mergedConfig.muted ?? false;
7596
+ //
7597
+ // `mergedConfig.muted` is typed `boolean` (required, non-optional) on
7598
+ // PlayerConfig, and DEFAULT_CONFIG.muted is the boolean literal `true` —
7599
+ // mergeConfig()'s deepMerge always falls back to the base value for an
7600
+ // undefined override (see config.ts), so this field can never be nullish
7601
+ // through the real merge. No `?? false` fallback needed.
7602
+ const originalMuted = mergedConfig.muted;
7573
7603
  const originalManager = mergedConfig.manager;
7574
7604
  const originalStartAt = mergedConfig.startAt;
7575
7605
  const controller = new PlayerController(runtimeConfig);
@@ -7748,6 +7778,21 @@
7748
7778
  controller.bus.on('error-fatal', () => {
7749
7779
  inst.p2p?.stop();
7750
7780
  }, { signal: controller.signal });
7781
+ // Fail loud when mount() produced no video element (D-06). Without one,
7782
+ // engine.setVideo() is skipped inside open() (see the `video !== null`
7783
+ // guard above), so no engine ever attaches media and nothing is requested
7784
+ // at all — no CDN token, no manifest, no segments. No watchdog covers
7785
+ // this: the load-never-started watchdog lives inside the engine and is
7786
+ // only armed once a driver exists. state.error (not a bus emit) is the
7787
+ // channel here because start() has not returned the reference yet, so no
7788
+ // consumer can have subscribed to the bus at this point, whereas the
7789
+ // reactive state store is readable at any time afterwards.
7790
+ if (video === null) {
7791
+ const message = 'EBPlayer: no <video> element found in the container after mount() — playback cannot start.';
7792
+ console.error(message);
7793
+ controller.state.error = message;
7794
+ return reference;
7795
+ }
7751
7796
  // Phase 7/8 — pre-main leg orchestration.
7752
7797
  // When mergedConfig.preroll is set, open the intro stream first, then on
7753
7798
  // bus 'intro-stream-complete' (emitted by IntroStreamManager on video.ended,
@@ -7784,9 +7829,11 @@
7784
7829
  // same tick. complete() is idempotent, so on the normal ordering (intro
7785
7830
  // already finished) this is a no-op; the listener is already torn down.
7786
7831
  controller.bus.emit('force-intro-complete');
7787
- if (video !== null) {
7788
- video.muted = originalMuted;
7789
- }
7832
+ // D-06: `video` is guaranteed non-null here — the guard above (line ~415)
7833
+ // already returned early when mount() produced no <video>, and `video`
7834
+ // is a `const`, so TypeScript's control-flow narrowing carries the
7835
+ // non-null guarantee into this closure without a redundant re-check.
7836
+ video.muted = originalMuted;
7790
7837
  controller.state.muted = originalMuted;
7791
7838
  // CR-05: keep mergedConfig.manager and controller.config.manager in sync.
7792
7839
  // start() builds mergedConfig and PlayerController builds its own merged
@@ -7871,7 +7918,9 @@
7871
7918
  return;
7872
7919
  deferredPlaybackStarted = true;
7873
7920
  const engine = inst.engine;
7874
- if (engine === null || video === null)
7921
+ // D-06: `video === null` is unreachable here — see the comment on the
7922
+ // equivalent guard in openMainStream() above.
7923
+ if (engine === null)
7875
7924
  return;
7876
7925
  // Honor the user's click intent. CommandHandler's earlier bus.on('play')
7877
7926
  // listener fired before us with no source attached, so its video.play()
@@ -7941,9 +7990,9 @@
7941
7990
  }, { signal: controller.signal });
7942
7991
  // Force runtime mute off for the intro pass — applied to the LIVE <video>
7943
7992
  // element and PlayerState, NOT to mergedConfig.muted (no-op for runtime).
7944
- if (video !== null) {
7945
- video.muted = false;
7946
- }
7993
+ // D-06: `video` is guaranteed non-null here — see the comment on the
7994
+ // equivalent restore in openMainStream() above.
7995
+ video.muted = false;
7947
7996
  controller.state.muted = false;
7948
7997
  // Skip P2P attach for the intro stream (D-08); restored before main open.
7949
7998
  // CR-05: keep the dual-merged configs in sync (see openMainStream).