eb-player 2.0.14 → 2.0.17

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 (39) hide show
  1. package/dist/build/eb-player.css +516 -58
  2. package/dist/build/ebplayer.bundle.js +265 -102
  3. package/dist/build/ebplayer.bundle.js.map +1 -1
  4. package/dist/build/theme-forja.css +409 -4
  5. package/dist/build/theme-lequipe.css +7 -14
  6. package/dist/build/theme-modern.css +6 -9
  7. package/dist/build/theme-radio.css +4 -1
  8. package/dist/build/theme-snrt.css +4 -1
  9. package/dist/build/theme-v2.css +50 -22
  10. package/dist/build/types/core/command-handler.d.ts +1 -0
  11. package/dist/build/types/core/command-handler.d.ts.map +1 -1
  12. package/dist/build/types/core/config.d.ts +2 -1
  13. package/dist/build/types/core/config.d.ts.map +1 -1
  14. package/dist/build/types/core/event-bus.d.ts +1 -0
  15. package/dist/build/types/core/event-bus.d.ts.map +1 -1
  16. package/dist/build/types/core/i18n.d.ts.map +1 -1
  17. package/dist/build/types/core/lifecycle.d.ts.map +1 -1
  18. package/dist/build/types/eb-player.d.ts +23 -10
  19. package/dist/build/types/eb-player.d.ts.map +1 -1
  20. package/dist/build/types/engines/base-engine.d.ts.map +1 -1
  21. package/dist/build/types/engines/hls.d.ts.map +1 -1
  22. package/dist/build/types/skin/brand/forja-playlist-bar.d.ts.map +1 -1
  23. package/dist/build/types/skin/component-registry.d.ts.map +1 -1
  24. package/dist/build/types/skin/controls/channel-name.d.ts +16 -0
  25. package/dist/build/types/skin/controls/channel-name.d.ts.map +1 -0
  26. package/dist/build/types/skin/controls/play-pause-button.d.ts.map +1 -1
  27. package/dist/build/types/skin/controls/settings-panel.d.ts.map +1 -1
  28. package/dist/build/types/skin/controls/time-display.d.ts.map +1 -1
  29. package/dist/build/types/skin/overlays/loading-spinner.d.ts +1 -1
  30. package/dist/build/types/skin/overlays/loading-spinner.d.ts.map +1 -1
  31. package/dist/build/types/skin/skin-root.d.ts.map +1 -1
  32. package/dist/eb-player.css +516 -58
  33. package/dist/theme-forja.css +409 -4
  34. package/dist/theme-lequipe.css +7 -14
  35. package/dist/theme-modern.css +6 -9
  36. package/dist/theme-radio.css +4 -1
  37. package/dist/theme-snrt.css +4 -1
  38. package/dist/theme-v2.css +50 -22
  39. 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.14";
7
+ var __EB_PLAYER_VERSION__ = "2.0.17";
8
8
 
9
9
  /**
10
10
  * Finite State Machine for player playback state transitions.
@@ -287,7 +287,7 @@
287
287
  { value: 'radio', label: 'Radio', primaryColor: '#F4A261' },
288
288
  { value: 'snrt', label: 'SNRT', primaryColor: '#006633' },
289
289
  { value: 'modern', label: 'Modern', primaryColor: '#7c3aed' },
290
- { value: 'v2', label: 'V2', primaryColor: '#ff841f' },
290
+ { value: 'v2', label: 'V2', primaryColor: '#3d4097' },
291
291
  { value: 'lequipe', label: "L'Equipe", primaryColor: '#d61e00' },
292
292
  ];
293
293
  /**
@@ -297,12 +297,12 @@
297
297
  const V2_LAYOUT = {
298
298
  topBar: {
299
299
  left: [],
300
- right: ['settings', 'pip', 'cast']
300
+ right: ['pip', 'settings']
301
301
  },
302
302
  bottomBar: {
303
- left: ['play-pause', 'live-sync', 'time'],
304
- center: ['seekbar'],
305
- right: ['volume', 'fullscreen']
303
+ left: ['live-sync', 'time', 'channel-name'],
304
+ center: ['play-pause', 'seekbar', 'volume', 'fullscreen'],
305
+ right: []
306
306
  },
307
307
  middleBar: {
308
308
  left: ['rewind'],
@@ -326,10 +326,29 @@
326
326
  right: ['forward']
327
327
  }
328
328
  };
329
+ const FORJA_LAYOUT = {
330
+ topBar: {
331
+ left: [],
332
+ right: []
333
+ },
334
+ bottomBar: {
335
+ // Row 1: seekbar (full width)
336
+ // Row 2: play-pause + volume (left) | live-sync + settings + pip + fullscreen (right)
337
+ left: ['seekbar'],
338
+ center: ['play-pause', 'volume'],
339
+ right: ['live-sync', 'settings', 'pip', 'fullscreen']
340
+ },
341
+ middleBar: {
342
+ left: ['rewind'],
343
+ center: [],
344
+ right: ['forward']
345
+ }
346
+ };
329
347
  const THEME_LAYOUTS = {
330
348
  v2: V2_LAYOUT,
331
349
  lequipe: LEQUIPE_LAYOUT,
332
350
  modern: V2_LAYOUT,
351
+ forja: FORJA_LAYOUT,
333
352
  };
334
353
  /**
335
354
  * Returns the effective layout for a given config.
@@ -355,7 +374,7 @@
355
374
  // Drivers
356
375
  dashjs: 'https://reference.dashif.org/dash.js/v4.7.4/dist/dash.all.min.js',
357
376
  hlsjs: 'https://cdn.jsdelivr.net/npm/hls.js@1.6.10/dist/hls.min.js',
358
- engineSettings: { liveSyncDurationCount: 8 },
377
+ engineSettings: { liveSyncDurationCount: 3 },
359
378
  chromecast: 'https://cdnjs.cloudflare.com/ajax/libs/castjs/5.3.0/cast.min.js',
360
379
  chromecastApp: undefined,
361
380
  chromecastMetadata: undefined,
@@ -396,7 +415,7 @@
396
415
  forceAutoplay: false,
397
416
  forceQuality: false,
398
417
  liveButton: false,
399
- syncLiveMargin: 5 * 60,
418
+ syncLiveMargin: 30,
400
419
  isLive: undefined,
401
420
  noUi: false,
402
421
  preroll: false,
@@ -430,7 +449,7 @@
430
449
  }
431
450
  },
432
451
  hasPlaylistPicker: false,
433
- disableCustomAbr: false,
452
+ disableCustomAbr: true,
434
453
  lang: undefined,
435
454
  // EPG
436
455
  epgContentId: undefined,
@@ -438,6 +457,8 @@
438
457
  epgDefaultLang: 'en',
439
458
  showEpgTitlePreview: false,
440
459
  showProgressThumb: false,
460
+ // Display
461
+ channelName: undefined,
441
462
  // Layout
442
463
  layout: undefined
443
464
  };
@@ -545,6 +566,12 @@
545
566
  ar: 'The stream is unavailable. Please try again later.',
546
567
  es: 'La transmisión no está disponible. Por favor, inténtelo más tarde.'
547
568
  },
569
+ 'error.geoblocked': {
570
+ en: 'This content is not available in your region.',
571
+ fr: 'Ce contenu n\'est pas disponible dans votre région.',
572
+ ar: 'This content is not available in your region.',
573
+ es: 'Este contenido no está disponible en tu región.'
574
+ },
548
575
  'cast.failed': {
549
576
  en: 'Casting failed. Resuming local playback.',
550
577
  fr: 'La diffusion a échoué. Reprise de la lecture locale.',
@@ -686,6 +713,7 @@
686
713
  this.wireReloadOrchestration(bus, state, i18n, onReload, signal);
687
714
  this.wireCastHandoff(bus, video, state, chromecastManager, i18n, signal);
688
715
  this.wireVolumeRouting(state, chromecastManager, signal);
716
+ this.wireGeoblock(bus, state, i18n, signal);
689
717
  this.wireAbortCleanup(signal);
690
718
  }
691
719
  // ---------------------------------------------------------------------------
@@ -819,6 +847,11 @@
819
847
  }
820
848
  }, { signal });
821
849
  }
850
+ wireGeoblock(bus, state, i18n, signal) {
851
+ bus.on('geoblock-detected', () => {
852
+ state.error = i18n.t('error.geoblocked');
853
+ }, { signal });
854
+ }
822
855
  showToast(state, message) {
823
856
  state.toast = message;
824
857
  clearTimeout(this.toastTimer);
@@ -1117,7 +1150,7 @@
1117
1150
  }
1118
1151
  template() {
1119
1152
  const state = this.state;
1120
- const isPlaying = state.playbackState === 'playing';
1153
+ const isPlaying = state.playbackState === 'playing' || state.playbackState === 'buffering';
1121
1154
  const layout = resolveLayout(this.config).middleBar;
1122
1155
  const slot = (componentId) => b `<div class="eb-slot-${componentId}"></div>`;
1123
1156
  return b `
@@ -1137,7 +1170,7 @@
1137
1170
  handleClick() {
1138
1171
  const state = this.state;
1139
1172
  const bus = this.bus;
1140
- if (state.playbackState === 'playing') {
1173
+ if (state.playbackState === 'playing' || state.playbackState === 'buffering') {
1141
1174
  bus.emit('pause');
1142
1175
  }
1143
1176
  else {
@@ -1159,7 +1192,8 @@
1159
1192
  this.render();
1160
1193
  }
1161
1194
  template() {
1162
- const isPlaying = this.state.playbackState === 'playing';
1195
+ const playbackState = this.state.playbackState;
1196
+ const isPlaying = playbackState === 'playing' || playbackState === 'buffering';
1163
1197
  const ariaLabel = isPlaying ? 'Pause' : 'Play';
1164
1198
  const iconName = isPlaying ? 'pause' : 'play';
1165
1199
  return b `
@@ -1611,16 +1645,18 @@
1611
1645
  }
1612
1646
  template() {
1613
1647
  const { currentTime, duration, isLive, isSyncWithLive } = this.state;
1648
+ if (!this.config.seekbar) {
1649
+ return b `<div class="eb-time-display" hidden></div>`;
1650
+ }
1614
1651
  let timeText;
1615
1652
  if (isLive) {
1616
1653
  if (isSyncWithLive || !Number.isFinite(duration)) {
1617
- // At the live edge or non-DVR stream: show current wall-clock time
1618
1654
  timeText = formatWallClock(Date.now());
1619
1655
  }
1620
1656
  else {
1621
- // Behind the live edge with DVR: show negative offset
1657
+ // Behind live edge: show wall-clock time + negative offset
1622
1658
  const offset = duration - currentTime;
1623
- timeText = `- ${formatDuration(offset)}`;
1659
+ return b `<div class="eb-time-display">${formatWallClock(Date.now())} <span class="eb-time-display__offset">-${formatDuration(offset)}</span></div>`;
1624
1660
  }
1625
1661
  }
1626
1662
  else {
@@ -1652,7 +1688,7 @@
1652
1688
  template() {
1653
1689
  const { isLive, isSyncWithLive } = this.state;
1654
1690
  const configLive = this.config.liveButton === true || this.config.isLive === true;
1655
- if (!isLive && !configLive) {
1691
+ if (!this.config.seekbar || (!isLive && !configLive)) {
1656
1692
  return b `<button class="eb-live-sync" hidden style="display:none">${icon('live')}</button>`;
1657
1693
  }
1658
1694
  return b `
@@ -1818,15 +1854,19 @@
1818
1854
  }
1819
1855
  if (mode === 'audio') {
1820
1856
  const tracks = this.state.audioTracks;
1857
+ if (tracks.length === 0)
1858
+ return i18n.t('settings.default');
1821
1859
  const current = this.state.currentAudioTrack;
1822
1860
  const track = tracks[current];
1823
1861
  return track?.name || track?.lang || i18n.t('settings.default');
1824
1862
  }
1825
1863
  if (mode === 'subtitles') {
1864
+ const tracks = this.state.subtitleTracks;
1865
+ if (tracks.length === 0)
1866
+ return i18n.t('settings.off');
1826
1867
  const current = this.state.currentSubtitleTrack;
1827
1868
  if (current === -1)
1828
1869
  return i18n.t('settings.off');
1829
- const tracks = this.state.subtitleTracks;
1830
1870
  const track = tracks[current];
1831
1871
  return track?.name || track?.lang || `Track ${current}`;
1832
1872
  }
@@ -1834,13 +1874,9 @@
1834
1874
  }
1835
1875
  renderRootMenu() {
1836
1876
  const qualityLevels = this.state.qualityLevels;
1837
- const audioTracks = this.state.audioTracks;
1838
- const subtitleTracks = this.state.subtitleTracks;
1839
1877
  const i18n = this.i18n;
1840
1878
  const showQuality = qualityLevels.length > 0;
1841
1879
  const showSpeed = this.config.speed === true;
1842
- const showAudio = audioTracks.length > 1;
1843
- const showSubtitles = subtitleTracks.length > 0;
1844
1880
  const row = (iconName, label, value, mode) => b `
1845
1881
  <li>
1846
1882
  <button class="eb-settings-category" @click="${() => this.navigateTo(mode)}">
@@ -1853,8 +1889,8 @@
1853
1889
  `;
1854
1890
  return b `
1855
1891
  <ul class="eb-settings-menu eb-settings-root">
1856
- ${showAudio ? row('audio', i18n.t('settings.audio'), this.currentValueLabel('audio'), 'audio') : ''}
1857
- ${showSubtitles ? row('subtitle', i18n.t('settings.subtitles'), this.currentValueLabel('subtitles'), 'subtitles') : ''}
1892
+ ${row('audio', i18n.t('settings.audio'), this.currentValueLabel('audio'), 'audio')}
1893
+ ${row('subtitle', i18n.t('settings.subtitles'), this.currentValueLabel('subtitles'), 'subtitles')}
1858
1894
  ${showSpeed ? row('speed', i18n.t('settings.speed'), this.currentValueLabel('speed'), 'speed') : ''}
1859
1895
  ${showQuality ? row('quality', i18n.t('settings.quality'), this.currentValueLabel('quality'), 'quality') : ''}
1860
1896
  </ul>
@@ -1902,17 +1938,25 @@
1902
1938
  renderAudioMenu() {
1903
1939
  const tracks = this.state.audioTracks;
1904
1940
  const currentTrack = this.state.currentAudioTrack;
1905
- const items = getAudioItems(tracks, currentTrack);
1941
+ const items = tracks.length > 0
1942
+ ? getAudioItems(tracks, currentTrack)
1943
+ : [{ label: this.i18n.t('settings.default'), value: 0, selected: true }];
1906
1944
  return this.renderSubMenu(this.i18n.t('settings.audio'), items, (item) => {
1907
- this.bus.emit('settings-select-audio', { index: item.value });
1945
+ if (tracks.length > 0) {
1946
+ this.bus.emit('settings-select-audio', { index: item.value });
1947
+ }
1908
1948
  });
1909
1949
  }
1910
1950
  renderSubtitlesMenu() {
1911
1951
  const tracks = this.state.subtitleTracks;
1912
1952
  const currentTrack = this.state.currentSubtitleTrack;
1913
- const items = getSubtitleItems(tracks, currentTrack);
1953
+ const items = tracks.length > 0
1954
+ ? getSubtitleItems(tracks, currentTrack)
1955
+ : [{ label: this.i18n.t('settings.off'), value: -1, selected: true }];
1914
1956
  return this.renderSubMenu(this.i18n.t('settings.subtitles'), items, (item) => {
1915
- this.bus.emit('settings-select-subtitle', { index: item.value });
1957
+ if (tracks.length > 0) {
1958
+ this.bus.emit('settings-select-subtitle', { index: item.value });
1959
+ }
1916
1960
  });
1917
1961
  }
1918
1962
  addOutsideClickListener() {
@@ -2506,6 +2550,27 @@
2506
2550
  }
2507
2551
  }
2508
2552
 
2553
+ /**
2554
+ * ChannelName displays a static label (e.g. channel or program name)
2555
+ * in the bottom bar, next to the live badge and time display.
2556
+ *
2557
+ * Reads config.channelName and renders nothing if unset.
2558
+ *
2559
+ * Usage:
2560
+ * EBPlayer.start({ src: '...', skin: 'v2', channelName: 'ALOULA' })
2561
+ */
2562
+ class ChannelName extends BaseComponent {
2563
+ onConnect() {
2564
+ this.render();
2565
+ }
2566
+ template() {
2567
+ const name = this.config.channelName;
2568
+ if (!name)
2569
+ return b ``;
2570
+ return b `<span class="eb-channel-name">${name}</span>`;
2571
+ }
2572
+ }
2573
+
2509
2574
  /**
2510
2575
  * Maps ComponentId strings to factory functions that create component instances.
2511
2576
  * Used by SkinRoot.connectChildComponents() to dynamically mount components
@@ -2524,7 +2589,8 @@
2524
2589
  'rewind': () => new RewindButton(),
2525
2590
  'forward': () => new ForwardButton(),
2526
2591
  'share': () => new ShareButton(),
2527
- 'info': () => new InfoButton()
2592
+ 'info': () => new InfoButton(),
2593
+ 'channel-name': () => new ChannelName()
2528
2594
  };
2529
2595
  /**
2530
2596
  * Returns the CSS selector for a component's slot element.
@@ -2544,7 +2610,7 @@
2544
2610
  * - Reconnecting: visible when state.reconnecting === true (shows "Reconnecting..." text)
2545
2611
  *
2546
2612
  * Hidden when not buffering and not reconnecting.
2547
- * CSS in skin.css handles the spin animation on .eb-loading.
2613
+ * Renders a CSS arc ring that spins around the center (wrapping the play/pause button).
2548
2614
  */
2549
2615
  class LoadingSpinner extends BaseComponent {
2550
2616
  onConnect() {
@@ -2556,13 +2622,13 @@
2556
2622
  const isBuffering = this.state.playbackState === 'buffering';
2557
2623
  const isReconnecting = this.state.reconnecting;
2558
2624
  if (!isBuffering && !isReconnecting) {
2559
- return b `<div class="eb-loading" hidden aria-hidden="true">${icon('spinner')}</div>`;
2625
+ return b `<div class="eb-loading" hidden aria-hidden="true"></div>`;
2560
2626
  }
2561
2627
  const label = isReconnecting
2562
2628
  ? (this.i18n?.t('loading.reconnecting') ?? 'Reconnecting...')
2563
2629
  : 'Loading';
2564
2630
  return b `<div class="eb-loading" role="status" aria-label="${label}">
2565
- ${icon('spinner')}
2631
+ <div class="eb-loading-arc"></div>
2566
2632
  ${isReconnecting ? b `<span class="eb-loading-text">${label}</span>` : ''}
2567
2633
  </div>`;
2568
2634
  }
@@ -2730,6 +2796,8 @@
2730
2796
  template() {
2731
2797
  const playlist = this.state?.playlist ?? [];
2732
2798
  const currentEpisode = this.state?.currentEpisode ?? 0;
2799
+ if (playlist.length === 0)
2800
+ return b ``;
2733
2801
  return b `
2734
2802
  <div class="eb-forja-playlist-bar">
2735
2803
  <button
@@ -2933,7 +3001,7 @@
2933
3001
  <video
2934
3002
  class="eb-video"
2935
3003
  playsinline
2936
- ?muted="${config.muted}"
3004
+ .muted="${config.muted}"
2937
3005
  ?autoplay="${config.autoplay}"
2938
3006
  ?controls="${isIOS}"
2939
3007
  ></video>
@@ -3063,16 +3131,14 @@
3063
3131
  }
3064
3132
  }
3065
3133
  }
3066
- // Loading spinner — stays inside overlay-zone (centered, no interaction needed)
3067
- const overlayZone = inner.querySelector('.eb-overlay-zone');
3068
- if (overlayZone !== null) {
3069
- const loadingSlot = document.createElement('div');
3070
- loadingSlot.className = 'eb-loading-slot';
3071
- overlayZone.appendChild(loadingSlot);
3072
- const loadingSpinner = new LoadingSpinner();
3073
- loadingSpinner.connect(loadingSlot, state, bus, config, i18n);
3074
- this.childComponents.push(loadingSpinner);
3075
- }
3134
+ // Loading spinner — mounted directly into .eb-player (not overlay-zone)
3135
+ // so it escapes the overlay-zone stacking context and displays above the middle bar
3136
+ const loadingSlot = document.createElement('div');
3137
+ loadingSlot.className = 'eb-loading-slot';
3138
+ inner.appendChild(loadingSlot);
3139
+ const loadingSpinner = new LoadingSpinner();
3140
+ loadingSpinner.connect(loadingSlot, state, bus, config, i18n);
3141
+ this.childComponents.push(loadingSpinner);
3076
3142
  // Interactive overlays — mounted directly into .eb-player (not overlay-zone)
3077
3143
  // so they escape the overlay-zone stacking context and display above all controls
3078
3144
  const interactiveOverlays = [
@@ -3103,7 +3169,7 @@
3103
3169
  <video
3104
3170
  class="eb-video"
3105
3171
  playsinline
3106
- ?muted="${config.muted}"
3172
+ .muted="${config.muted}"
3107
3173
  ?autoplay="${config.autoplay}"
3108
3174
  ></video>
3109
3175
  </div>
@@ -3900,10 +3966,14 @@
3900
3966
  const skinColors = this.config.skinColors;
3901
3967
  if (this.config.primaryColor) {
3902
3968
  container.style.setProperty('--eb-color-primary', this.config.primaryColor);
3969
+ container.style.setProperty('--eb-color-progress', this.config.primaryColor);
3970
+ container.style.setProperty('--eb-accent', this.config.primaryColor);
3903
3971
  }
3904
3972
  // skinColors.general overrides primaryColor
3905
3973
  if (skinColors?.general) {
3906
3974
  container.style.setProperty('--eb-color-primary', skinColors.general);
3975
+ container.style.setProperty('--eb-color-progress', skinColors.general);
3976
+ container.style.setProperty('--eb-accent', skinColors.general);
3907
3977
  }
3908
3978
  if (skinColors?.progressBar) {
3909
3979
  container.style.setProperty('--eb-color-progress', skinColors.progressBar);
@@ -4246,7 +4316,7 @@
4246
4316
  // -------------------------------------------------------------------------
4247
4317
  bindVideoEvents(video, state, signal) {
4248
4318
  // Playback timing + live sync detection
4249
- const syncMargin = this.config?.syncLiveMargin ?? 5 * 60;
4319
+ const syncMargin = this.config?.syncLiveMargin ?? 30;
4250
4320
  video.addEventListener('timeupdate', () => {
4251
4321
  state.currentTime = Math.round(video.currentTime);
4252
4322
  if (state.isLive) {
@@ -4272,14 +4342,41 @@
4272
4342
  state.playbackRate = video.playbackRate;
4273
4343
  }, { signal });
4274
4344
  // FSM transitions: playing, pause, waiting, ended
4345
+ //
4346
+ // Pause guard: browsers fire spurious 'pause' events when currentTime is
4347
+ // set programmatically (rewind/forward/seekbar). We only honour the 'pause'
4348
+ // event when the previous playback state was 'playing' — meaning the user
4349
+ // (or our code) explicitly called video.pause(). During a seek the state
4350
+ // is already 'buffering' by the time the late 'pause' arrives, so it's
4351
+ // harmlessly ignored.
4352
+ let userRequestedPause = false;
4275
4353
  video.addEventListener('playing', () => {
4354
+ userRequestedPause = false;
4276
4355
  state.playbackState = 'playing';
4277
4356
  }, { signal });
4357
+ video.addEventListener('waiting', () => {
4358
+ state.playbackState = 'buffering';
4359
+ }, { signal });
4278
4360
  video.addEventListener('pause', () => {
4361
+ if (video.seeking)
4362
+ return;
4363
+ if (state.playbackState === 'buffering') {
4364
+ userRequestedPause = true;
4365
+ return;
4366
+ }
4279
4367
  state.playbackState = 'paused';
4280
4368
  }, { signal });
4281
- video.addEventListener('waiting', () => {
4282
- state.playbackState = 'buffering';
4369
+ video.addEventListener('seeked', () => {
4370
+ // Update live sync state immediately after seek so the UI reflects
4371
+ // the new position without waiting for the next timeupdate
4372
+ if (state.isLive) {
4373
+ state.currentTime = Math.round(video.currentTime);
4374
+ state.isSyncWithLive = video.currentTime + syncMargin > video.duration;
4375
+ }
4376
+ if (userRequestedPause && video.paused) {
4377
+ state.playbackState = 'paused';
4378
+ userRequestedPause = false;
4379
+ }
4283
4380
  }, { signal });
4284
4381
  video.addEventListener('ended', () => {
4285
4382
  state.playbackState = 'ended';
@@ -5233,8 +5330,8 @@
5233
5330
  seek(time) {
5234
5331
  if (this.video === null)
5235
5332
  return;
5236
- // Disable hls.js live sync once on first seek, so it never
5237
- // auto-seeks back to the live edge on DVR/timeshift streams.
5333
+ // Disable hls.js live sync on first seek so it never auto-seeks
5334
+ // back to the live edge on DVR/timeshift streams.
5238
5335
  if (!this.liveSyncDisabled && this.driver !== null && this.state?.isLive) {
5239
5336
  const cfg = this.driver.config;
5240
5337
  cfg.liveSyncDurationCount = 0;
@@ -5377,6 +5474,24 @@
5377
5474
  }
5378
5475
  });
5379
5476
  }
5477
+ // Geo-block detection (opt-in, ported from v1.x).
5478
+ // Detects 403 on manifest/level load and surfaces it via the bus + a
5479
+ // window CustomEvent for back-compat with v1 host integrations.
5480
+ if (config.useGeoblockingErrorHandle) {
5481
+ const busRef = this.bus;
5482
+ driver.on(Hls.Events.ERROR, (_event, data) => {
5483
+ const error = data;
5484
+ if (error?.type === Hls.ErrorTypes.NETWORK_ERROR
5485
+ && (error?.details === 'manifestLoadError' || error?.details === 'levelLoadError')
5486
+ && error?.response?.code === 403) {
5487
+ busRef?.emit('geoblock-detected');
5488
+ window.dispatchEvent(new CustomEvent('geoblock', {
5489
+ detail: { title: 'Geoblock', message: 'Geoblock detected' }
5490
+ }));
5491
+ this.stopWatchdog();
5492
+ }
5493
+ });
5494
+ }
5380
5495
  // Bind native video element events to state
5381
5496
  this.bindVideoEvents(video, state, signal);
5382
5497
  // Attach media and load source
@@ -6368,21 +6483,37 @@
6368
6483
  /**
6369
6484
  * EBPlayer facade — consumer-facing API layer.
6370
6485
  *
6371
- * Provides the window.EBPlayer interface that existing consumers use:
6486
+ * Provides the window.EBPlayer interface:
6372
6487
  * - window.EBPlayer.start(config) — synchronously returns a PlayerReference
6373
- * - window.EBPlayer.stop() — detaches the active engine (skin stays mounted)
6374
- * - window.EBPlayer.destroy() — disposes the controller (tears down everything)
6488
+ * - window.EBPlayer.stop() — detaches all active engines (skins stay mounted)
6489
+ * - window.EBPlayer.destroy() — disposes all controllers (tears down everything)
6375
6490
  *
6376
- * The facade tracks one active controller and one active engine at module level.
6377
- * Multiple start() calls dispose the previous instance before creating a new one.
6491
+ * Supports multiple simultaneous player instances on the same page.
6492
+ * Each instance is keyed by its container element:
6493
+ * - Calling start() with the SAME container disposes the old instance first (backward compat).
6494
+ * - Calling start() with a DIFFERENT container creates an independent player.
6495
+ *
6496
+ * Per-instance cleanup is available via PlayerReference.destroy().
6497
+ * Global stop()/destroy() operate on all instances at once.
6378
6498
  */
6379
6499
  // Import from barrel — triggers CSS imports (base.css + skin.css) for postcss extraction
6380
- // ---------------------------------------------------------------------------
6381
- // Module-level active instance tracking
6382
- // ---------------------------------------------------------------------------
6383
- let activeController = null;
6384
- let activeEngine = null;
6385
- let activeSnapshotDestroy = null;
6500
+ /** Active player instances keyed by their container element. */
6501
+ const instances = new Map();
6502
+ /**
6503
+ * Tear down a single instance: snapshot handler, engine, then controller.
6504
+ * Does NOT remove the entry from the instances Map (caller must do that).
6505
+ */
6506
+ function destroyInstance(inst) {
6507
+ if (inst.snapshotDestroy !== null) {
6508
+ inst.snapshotDestroy();
6509
+ inst.snapshotDestroy = null;
6510
+ }
6511
+ if (inst.engine !== null) {
6512
+ inst.engine.detach();
6513
+ inst.engine = null;
6514
+ }
6515
+ inst.controller.dispose();
6516
+ }
6386
6517
  // ---------------------------------------------------------------------------
6387
6518
  // Container resolution
6388
6519
  // ---------------------------------------------------------------------------
@@ -6425,31 +6556,44 @@
6425
6556
  // ---------------------------------------------------------------------------
6426
6557
  /**
6427
6558
  * Start the player with the given config.
6428
- * If a player is already running, it is disposed before the new one is created.
6559
+ *
6560
+ * If a player is already mounted on the same container element, it is disposed
6561
+ * before the new one is created (backward-compatible single-instance behavior).
6562
+ * If the container is different, the new player coexists alongside existing ones
6563
+ * (multi-instance support).
6429
6564
  *
6430
6565
  * Returns a PlayerReference synchronously — never returns a Promise.
6431
6566
  */
6432
6567
  function start(runtimeConfig) {
6433
- // Dispose any existing instance before creating a new one
6434
- if (activeController !== null) {
6435
- destroy();
6568
+ const container = resolveContainer(runtimeConfig.el);
6569
+ // Dispose any existing instance on the SAME container (backward compat).
6570
+ // Instances on OTHER containers are left untouched (multi-instance).
6571
+ const existing = instances.get(container);
6572
+ if (existing !== undefined) {
6573
+ destroyInstance(existing);
6574
+ instances.delete(container);
6436
6575
  }
6437
6576
  const mergedConfig = mergeConfig(DEFAULT_CONFIG, runtimeConfig);
6438
6577
  const controller = new PlayerController(runtimeConfig);
6439
- const container = resolveContainer(runtimeConfig.el);
6440
6578
  controller.mount(container);
6441
6579
  // Video element is available after mount() creates the skin DOM
6442
6580
  const video = container.querySelector('video');
6443
- activeController = controller;
6444
- activeEngine = null;
6581
+ // Per-instance state — captured by the reference closure, never shared across instances
6582
+ const inst = {
6583
+ controller,
6584
+ engine: null,
6585
+ snapshotDestroy: null,
6586
+ container
6587
+ };
6588
+ instances.set(container, inst);
6445
6589
  let lastSrc = '';
6446
6590
  const reference = {
6447
6591
  open(src) {
6448
6592
  lastSrc = src;
6449
6593
  // Detach any existing engine before opening a new stream
6450
- if (activeEngine !== null) {
6451
- activeEngine.detach();
6452
- activeEngine = null;
6594
+ if (inst.engine !== null) {
6595
+ inst.engine.detach();
6596
+ inst.engine = null;
6453
6597
  }
6454
6598
  const engine = selectEngine(src, mergedConfig);
6455
6599
  if (video !== null) {
@@ -6458,7 +6602,7 @@
6458
6602
  engine.setBus(controller.bus);
6459
6603
  engine.setConfig(mergedConfig);
6460
6604
  controller.setEngineSync(engine);
6461
- activeEngine = engine;
6605
+ inst.engine = engine;
6462
6606
  // P2P integration: wire EasyBroadcast SDK when config.lib and config.manager are set
6463
6607
  if (mergedConfig.lib && mergedConfig.manager && video !== null) {
6464
6608
  const isDash = src.includes('.mpd') && mergedConfig.dashjs !== false;
@@ -6479,9 +6623,9 @@
6479
6623
  // Snapshot handler: create snapshot engine for seekbar preview thumbnails
6480
6624
  if (mergedConfig.showProgressThumb) {
6481
6625
  // Clean up any previous snapshot handler
6482
- if (activeSnapshotDestroy !== null) {
6483
- activeSnapshotDestroy();
6484
- activeSnapshotDestroy = null;
6626
+ if (inst.snapshotDestroy !== null) {
6627
+ inst.snapshotDestroy();
6628
+ inst.snapshotDestroy = null;
6485
6629
  }
6486
6630
  const isDash = src.includes('.mpd') && mergedConfig.dashjs !== false;
6487
6631
  // Wait for engine driver to be ready (CDN script loaded) before initializing snapshot
@@ -6492,7 +6636,7 @@
6492
6636
  const handler = new DashSnapshotHandler(src);
6493
6637
  handler.init(win.dashjs)
6494
6638
  .then((handle) => {
6495
- activeSnapshotDestroy = () => handle.destroy();
6639
+ inst.snapshotDestroy = () => handle.destroy();
6496
6640
  controller.bus.emit('snapshot-handler-ready', { take: handle.take, video: handle.video });
6497
6641
  })
6498
6642
  .catch((error) => {
@@ -6512,7 +6656,7 @@
6512
6656
  const handler = new HlsSnapshotHandler({ src, engineSettings: { ...mergedConfig.engineSettings, ...snapshotDrmConfig } }, sharedTokenManager);
6513
6657
  handler.init(win.Hls)
6514
6658
  .then(() => {
6515
- activeSnapshotDestroy = () => handler.destroy();
6659
+ inst.snapshotDestroy = () => handler.destroy();
6516
6660
  const snapshotVideo = handler.getVideo();
6517
6661
  if (snapshotVideo !== null) {
6518
6662
  controller.bus.emit('snapshot-handler-ready', { take: (time) => handler.take(time), video: snapshotVideo });
@@ -6547,15 +6691,19 @@
6547
6691
  }
6548
6692
  },
6549
6693
  close() {
6550
- if (activeSnapshotDestroy !== null) {
6551
- activeSnapshotDestroy();
6552
- activeSnapshotDestroy = null;
6694
+ if (inst.snapshotDestroy !== null) {
6695
+ inst.snapshotDestroy();
6696
+ inst.snapshotDestroy = null;
6553
6697
  }
6554
- if (activeEngine !== null) {
6555
- activeEngine.detach();
6556
- activeEngine = null;
6698
+ if (inst.engine !== null) {
6699
+ inst.engine.detach();
6700
+ inst.engine = null;
6557
6701
  }
6558
6702
  },
6703
+ destroy() {
6704
+ destroyInstance(inst);
6705
+ instances.delete(container);
6706
+ },
6559
6707
  get state() {
6560
6708
  return controller.state;
6561
6709
  }
@@ -6576,10 +6724,10 @@
6576
6724
  reference.open(currentSrc);
6577
6725
  // Restore selections after manifest loads (~2s pragmatic delay for async manifest parsing)
6578
6726
  setTimeout(() => {
6579
- if (activeEngine !== null) {
6580
- activeEngine.setQuality(saved.quality);
6581
- activeEngine.setAudioTrack(saved.audioTrack);
6582
- activeEngine.setSubtitle(saved.subtitleTrack);
6727
+ if (inst.engine !== null) {
6728
+ inst.engine.setQuality(saved.quality);
6729
+ inst.engine.setAudioTrack(saved.audioTrack);
6730
+ inst.engine.setSubtitle(saved.subtitleTrack);
6583
6731
  }
6584
6732
  }, 2000);
6585
6733
  }, 500);
@@ -6595,9 +6743,24 @@
6595
6743
  else {
6596
6744
  let deferredOpen = true;
6597
6745
  controller.bus.on('play', () => {
6598
- if (deferredOpen) {
6599
- deferredOpen = false;
6600
- reference.open(mergedConfig.src);
6746
+ if (!deferredOpen)
6747
+ return;
6748
+ deferredOpen = false;
6749
+ reference.open(mergedConfig.src);
6750
+ // Honor the user's click intent. CommandHandler's earlier bus.on('play')
6751
+ // listener fired before us with no source attached, so its video.play()
6752
+ // was a silent no-op. Wait for driverReady (attachMedia + loadSource
6753
+ // done) then start playback ourselves.
6754
+ const engine = inst.engine;
6755
+ if (engine !== null && video !== null) {
6756
+ engine.driverReady.then(() => {
6757
+ const playResult = video.play();
6758
+ if (playResult && typeof playResult.catch === 'function') {
6759
+ playResult.catch((error) => {
6760
+ console.warn('EBPlayer: deferred play() rejected', error);
6761
+ });
6762
+ }
6763
+ });
6601
6764
  }
6602
6765
  }, { signal: controller.signal });
6603
6766
  }
@@ -6605,28 +6768,28 @@
6605
6768
  return reference;
6606
6769
  }
6607
6770
  /**
6608
- * Stop the active stream.
6609
- * Detaches the engine without disposing the controller (skin stays mounted).
6771
+ * Stop all active streams.
6772
+ * Detaches engines without disposing controllers (skins stay mounted).
6773
+ * For single-instance usage this behaves identically to the old global stop().
6610
6774
  */
6611
6775
  function stop() {
6612
- if (activeEngine !== null) {
6613
- activeEngine.detach();
6614
- activeEngine = null;
6776
+ for (const inst of instances.values()) {
6777
+ if (inst.engine !== null) {
6778
+ inst.engine.detach();
6779
+ inst.engine = null;
6780
+ }
6615
6781
  }
6616
6782
  }
6617
6783
  /**
6618
- * Destroy the player completely.
6619
- * Disposes the controller (tears down skin, events, and all resources).
6784
+ * Destroy all player instances completely.
6785
+ * Disposes every controller (tears down skin, events, and all resources).
6786
+ * For single-instance usage this behaves identically to the old global destroy().
6620
6787
  */
6621
6788
  function destroy() {
6622
- if (activeEngine !== null) {
6623
- activeEngine.detach();
6624
- activeEngine = null;
6625
- }
6626
- if (activeController !== null) {
6627
- activeController.dispose();
6628
- activeController = null;
6789
+ for (const inst of instances.values()) {
6790
+ destroyInstance(inst);
6629
6791
  }
6792
+ instances.clear();
6630
6793
  }
6631
6794
  // ---------------------------------------------------------------------------
6632
6795
  // Version