eb-player 2.0.15 → 2.0.18

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 (44) hide show
  1. package/dist/build/eb-player.css +516 -58
  2. package/dist/build/ebplayer.bundle.js +309 -112
  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/fsm.d.ts +4 -1
  17. package/dist/build/types/core/fsm.d.ts.map +1 -1
  18. package/dist/build/types/core/i18n.d.ts.map +1 -1
  19. package/dist/build/types/core/lifecycle.d.ts.map +1 -1
  20. package/dist/build/types/core/player-state.d.ts.map +1 -1
  21. package/dist/build/types/eb-player.d.ts +23 -10
  22. package/dist/build/types/eb-player.d.ts.map +1 -1
  23. package/dist/build/types/engines/base-engine.d.ts.map +1 -1
  24. package/dist/build/types/engines/hls.d.ts.map +1 -1
  25. package/dist/build/types/integrations/p2p-manager.d.ts +9 -0
  26. package/dist/build/types/integrations/p2p-manager.d.ts.map +1 -1
  27. package/dist/build/types/skin/brand/forja-playlist-bar.d.ts.map +1 -1
  28. package/dist/build/types/skin/component-registry.d.ts.map +1 -1
  29. package/dist/build/types/skin/controls/channel-name.d.ts +16 -0
  30. package/dist/build/types/skin/controls/channel-name.d.ts.map +1 -0
  31. package/dist/build/types/skin/controls/play-pause-button.d.ts.map +1 -1
  32. package/dist/build/types/skin/controls/settings-panel.d.ts.map +1 -1
  33. package/dist/build/types/skin/controls/time-display.d.ts.map +1 -1
  34. package/dist/build/types/skin/overlays/loading-spinner.d.ts +1 -1
  35. package/dist/build/types/skin/overlays/loading-spinner.d.ts.map +1 -1
  36. package/dist/build/types/skin/skin-root.d.ts.map +1 -1
  37. package/dist/eb-player.css +516 -58
  38. package/dist/theme-forja.css +409 -4
  39. package/dist/theme-lequipe.css +7 -14
  40. package/dist/theme-modern.css +6 -9
  41. package/dist/theme-radio.css +4 -1
  42. package/dist/theme-snrt.css +4 -1
  43. package/dist/theme-v2.css +50 -22
  44. package/package.json +4 -2
@@ -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.15";
7
+ var __EB_PLAYER_VERSION__ = "2.0.18";
8
8
 
9
9
  /**
10
10
  * Finite State Machine for player playback state transitions.
@@ -19,7 +19,10 @@
19
19
  * - idle -> loading: initial load or post-stall reload
20
20
  * - loading -> playing | error: stream starts or fails
21
21
  * - playing -> paused | buffering | ended | error: normal playback events
22
- * - paused -> playing | loading | idle: resume or stop
22
+ * - paused -> playing | loading | idle | buffering: resume or stop
23
+ * - paused -> buffering is needed when resuming a paused (live) stream where
24
+ * the browser fires 'waiting' before 'playing' (Safari live segment edge);
25
+ * without it the FSM strands at 'paused' while the video actually plays
23
26
  * - buffering -> playing | paused | idle | error: buffer recovered, user pause, or stall
24
27
  * - buffering -> idle is needed for the stall-watchdog recovery path (kick off reload)
25
28
  * - buffering -> paused is needed for live stream support (user pauses during buffer)
@@ -30,7 +33,7 @@
30
33
  idle: ['loading'],
31
34
  loading: ['playing', 'buffering', 'paused', 'error', 'idle'],
32
35
  playing: ['paused', 'buffering', 'ended', 'error', 'idle'],
33
- paused: ['playing', 'loading', 'idle'],
36
+ paused: ['playing', 'loading', 'idle', 'buffering'],
34
37
  buffering: ['playing', 'paused', 'idle', 'error'],
35
38
  error: ['loading', 'idle'],
36
39
  ended: ['loading', 'idle']
@@ -175,7 +178,11 @@
175
178
  if (previousValue === value) {
176
179
  return true;
177
180
  }
178
- rawTarget[String(key)] = value;
181
+ // Assign and notify subscribers.
182
+ // Cast the typed StateMap target to a mutable record so the unknown value
183
+ // can be written through the Proxy set trap.
184
+ const writable = rawTarget;
185
+ writable[String(key)] = value;
179
186
  impl.notify(key, value, previousValue);
180
187
  return true;
181
188
  }
@@ -287,7 +294,7 @@
287
294
  { value: 'radio', label: 'Radio', primaryColor: '#F4A261' },
288
295
  { value: 'snrt', label: 'SNRT', primaryColor: '#006633' },
289
296
  { value: 'modern', label: 'Modern', primaryColor: '#7c3aed' },
290
- { value: 'v2', label: 'V2', primaryColor: '#ff841f' },
297
+ { value: 'v2', label: 'V2', primaryColor: '#3d4097' },
291
298
  { value: 'lequipe', label: "L'Equipe", primaryColor: '#d61e00' },
292
299
  ];
293
300
  /**
@@ -297,12 +304,12 @@
297
304
  const V2_LAYOUT = {
298
305
  topBar: {
299
306
  left: [],
300
- right: ['settings', 'pip', 'cast']
307
+ right: ['pip', 'settings']
301
308
  },
302
309
  bottomBar: {
303
- left: ['play-pause', 'live-sync', 'time'],
304
- center: ['seekbar'],
305
- right: ['volume', 'fullscreen']
310
+ left: ['live-sync', 'time', 'channel-name'],
311
+ center: ['play-pause', 'seekbar', 'volume', 'fullscreen'],
312
+ right: []
306
313
  },
307
314
  middleBar: {
308
315
  left: ['rewind'],
@@ -326,10 +333,29 @@
326
333
  right: ['forward']
327
334
  }
328
335
  };
336
+ const FORJA_LAYOUT = {
337
+ topBar: {
338
+ left: [],
339
+ right: []
340
+ },
341
+ bottomBar: {
342
+ // Row 1: seekbar (full width)
343
+ // Row 2: play-pause + volume (left) | live-sync + settings + pip + fullscreen (right)
344
+ left: ['seekbar'],
345
+ center: ['play-pause', 'volume'],
346
+ right: ['live-sync', 'settings', 'pip', 'fullscreen']
347
+ },
348
+ middleBar: {
349
+ left: ['rewind'],
350
+ center: [],
351
+ right: ['forward']
352
+ }
353
+ };
329
354
  const THEME_LAYOUTS = {
330
355
  v2: V2_LAYOUT,
331
356
  lequipe: LEQUIPE_LAYOUT,
332
357
  modern: V2_LAYOUT,
358
+ forja: FORJA_LAYOUT,
333
359
  };
334
360
  /**
335
361
  * Returns the effective layout for a given config.
@@ -355,7 +381,7 @@
355
381
  // Drivers
356
382
  dashjs: 'https://reference.dashif.org/dash.js/v4.7.4/dist/dash.all.min.js',
357
383
  hlsjs: 'https://cdn.jsdelivr.net/npm/hls.js@1.6.10/dist/hls.min.js',
358
- engineSettings: { liveSyncDurationCount: 8 },
384
+ engineSettings: { liveSyncDurationCount: 3 },
359
385
  chromecast: 'https://cdnjs.cloudflare.com/ajax/libs/castjs/5.3.0/cast.min.js',
360
386
  chromecastApp: undefined,
361
387
  chromecastMetadata: undefined,
@@ -396,7 +422,7 @@
396
422
  forceAutoplay: false,
397
423
  forceQuality: false,
398
424
  liveButton: false,
399
- syncLiveMargin: 5 * 60,
425
+ syncLiveMargin: 30,
400
426
  isLive: undefined,
401
427
  noUi: false,
402
428
  preroll: false,
@@ -438,6 +464,8 @@
438
464
  epgDefaultLang: 'en',
439
465
  showEpgTitlePreview: false,
440
466
  showProgressThumb: false,
467
+ // Display
468
+ channelName: undefined,
441
469
  // Layout
442
470
  layout: undefined
443
471
  };
@@ -545,6 +573,12 @@
545
573
  ar: 'The stream is unavailable. Please try again later.',
546
574
  es: 'La transmisión no está disponible. Por favor, inténtelo más tarde.'
547
575
  },
576
+ 'error.geoblocked': {
577
+ en: 'This content is not available in your region.',
578
+ fr: 'Ce contenu n\'est pas disponible dans votre région.',
579
+ ar: 'This content is not available in your region.',
580
+ es: 'Este contenido no está disponible en tu región.'
581
+ },
548
582
  'cast.failed': {
549
583
  en: 'Casting failed. Resuming local playback.',
550
584
  fr: 'La diffusion a échoué. Reprise de la lecture locale.',
@@ -686,6 +720,7 @@
686
720
  this.wireReloadOrchestration(bus, state, i18n, onReload, signal);
687
721
  this.wireCastHandoff(bus, video, state, chromecastManager, i18n, signal);
688
722
  this.wireVolumeRouting(state, chromecastManager, signal);
723
+ this.wireGeoblock(bus, state, i18n, signal);
689
724
  this.wireAbortCleanup(signal);
690
725
  }
691
726
  // ---------------------------------------------------------------------------
@@ -819,6 +854,11 @@
819
854
  }
820
855
  }, { signal });
821
856
  }
857
+ wireGeoblock(bus, state, i18n, signal) {
858
+ bus.on('geoblock-detected', () => {
859
+ state.error = i18n.t('error.geoblocked');
860
+ }, { signal });
861
+ }
822
862
  showToast(state, message) {
823
863
  state.toast = message;
824
864
  clearTimeout(this.toastTimer);
@@ -1117,7 +1157,7 @@
1117
1157
  }
1118
1158
  template() {
1119
1159
  const state = this.state;
1120
- const isPlaying = state.playbackState === 'playing';
1160
+ const isPlaying = state.playbackState === 'playing' || state.playbackState === 'buffering';
1121
1161
  const layout = resolveLayout(this.config).middleBar;
1122
1162
  const slot = (componentId) => b `<div class="eb-slot-${componentId}"></div>`;
1123
1163
  return b `
@@ -1137,7 +1177,7 @@
1137
1177
  handleClick() {
1138
1178
  const state = this.state;
1139
1179
  const bus = this.bus;
1140
- if (state.playbackState === 'playing') {
1180
+ if (state.playbackState === 'playing' || state.playbackState === 'buffering') {
1141
1181
  bus.emit('pause');
1142
1182
  }
1143
1183
  else {
@@ -1159,7 +1199,8 @@
1159
1199
  this.render();
1160
1200
  }
1161
1201
  template() {
1162
- const isPlaying = this.state.playbackState === 'playing';
1202
+ const playbackState = this.state.playbackState;
1203
+ const isPlaying = playbackState === 'playing' || playbackState === 'buffering';
1163
1204
  const ariaLabel = isPlaying ? 'Pause' : 'Play';
1164
1205
  const iconName = isPlaying ? 'pause' : 'play';
1165
1206
  return b `
@@ -1611,16 +1652,18 @@
1611
1652
  }
1612
1653
  template() {
1613
1654
  const { currentTime, duration, isLive, isSyncWithLive } = this.state;
1655
+ if (!this.config.seekbar) {
1656
+ return b `<div class="eb-time-display" hidden></div>`;
1657
+ }
1614
1658
  let timeText;
1615
1659
  if (isLive) {
1616
1660
  if (isSyncWithLive || !Number.isFinite(duration)) {
1617
- // At the live edge or non-DVR stream: show current wall-clock time
1618
1661
  timeText = formatWallClock(Date.now());
1619
1662
  }
1620
1663
  else {
1621
- // Behind the live edge with DVR: show negative offset
1664
+ // Behind live edge: show wall-clock time + negative offset
1622
1665
  const offset = duration - currentTime;
1623
- timeText = `- ${formatDuration(offset)}`;
1666
+ return b `<div class="eb-time-display">${formatWallClock(Date.now())} <span class="eb-time-display__offset">-${formatDuration(offset)}</span></div>`;
1624
1667
  }
1625
1668
  }
1626
1669
  else {
@@ -1652,7 +1695,7 @@
1652
1695
  template() {
1653
1696
  const { isLive, isSyncWithLive } = this.state;
1654
1697
  const configLive = this.config.liveButton === true || this.config.isLive === true;
1655
- if (!isLive && !configLive) {
1698
+ if (!this.config.seekbar || (!isLive && !configLive)) {
1656
1699
  return b `<button class="eb-live-sync" hidden style="display:none">${icon('live')}</button>`;
1657
1700
  }
1658
1701
  return b `
@@ -1818,15 +1861,19 @@
1818
1861
  }
1819
1862
  if (mode === 'audio') {
1820
1863
  const tracks = this.state.audioTracks;
1864
+ if (tracks.length === 0)
1865
+ return i18n.t('settings.default');
1821
1866
  const current = this.state.currentAudioTrack;
1822
1867
  const track = tracks[current];
1823
1868
  return track?.name || track?.lang || i18n.t('settings.default');
1824
1869
  }
1825
1870
  if (mode === 'subtitles') {
1871
+ const tracks = this.state.subtitleTracks;
1872
+ if (tracks.length === 0)
1873
+ return i18n.t('settings.off');
1826
1874
  const current = this.state.currentSubtitleTrack;
1827
1875
  if (current === -1)
1828
1876
  return i18n.t('settings.off');
1829
- const tracks = this.state.subtitleTracks;
1830
1877
  const track = tracks[current];
1831
1878
  return track?.name || track?.lang || `Track ${current}`;
1832
1879
  }
@@ -1834,13 +1881,9 @@
1834
1881
  }
1835
1882
  renderRootMenu() {
1836
1883
  const qualityLevels = this.state.qualityLevels;
1837
- const audioTracks = this.state.audioTracks;
1838
- const subtitleTracks = this.state.subtitleTracks;
1839
1884
  const i18n = this.i18n;
1840
1885
  const showQuality = qualityLevels.length > 0;
1841
1886
  const showSpeed = this.config.speed === true;
1842
- const showAudio = audioTracks.length > 1;
1843
- const showSubtitles = subtitleTracks.length > 0;
1844
1887
  const row = (iconName, label, value, mode) => b `
1845
1888
  <li>
1846
1889
  <button class="eb-settings-category" @click="${() => this.navigateTo(mode)}">
@@ -1853,8 +1896,8 @@
1853
1896
  `;
1854
1897
  return b `
1855
1898
  <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') : ''}
1899
+ ${row('audio', i18n.t('settings.audio'), this.currentValueLabel('audio'), 'audio')}
1900
+ ${row('subtitle', i18n.t('settings.subtitles'), this.currentValueLabel('subtitles'), 'subtitles')}
1858
1901
  ${showSpeed ? row('speed', i18n.t('settings.speed'), this.currentValueLabel('speed'), 'speed') : ''}
1859
1902
  ${showQuality ? row('quality', i18n.t('settings.quality'), this.currentValueLabel('quality'), 'quality') : ''}
1860
1903
  </ul>
@@ -1902,17 +1945,25 @@
1902
1945
  renderAudioMenu() {
1903
1946
  const tracks = this.state.audioTracks;
1904
1947
  const currentTrack = this.state.currentAudioTrack;
1905
- const items = getAudioItems(tracks, currentTrack);
1948
+ const items = tracks.length > 0
1949
+ ? getAudioItems(tracks, currentTrack)
1950
+ : [{ label: this.i18n.t('settings.default'), value: 0, selected: true }];
1906
1951
  return this.renderSubMenu(this.i18n.t('settings.audio'), items, (item) => {
1907
- this.bus.emit('settings-select-audio', { index: item.value });
1952
+ if (tracks.length > 0) {
1953
+ this.bus.emit('settings-select-audio', { index: item.value });
1954
+ }
1908
1955
  });
1909
1956
  }
1910
1957
  renderSubtitlesMenu() {
1911
1958
  const tracks = this.state.subtitleTracks;
1912
1959
  const currentTrack = this.state.currentSubtitleTrack;
1913
- const items = getSubtitleItems(tracks, currentTrack);
1960
+ const items = tracks.length > 0
1961
+ ? getSubtitleItems(tracks, currentTrack)
1962
+ : [{ label: this.i18n.t('settings.off'), value: -1, selected: true }];
1914
1963
  return this.renderSubMenu(this.i18n.t('settings.subtitles'), items, (item) => {
1915
- this.bus.emit('settings-select-subtitle', { index: item.value });
1964
+ if (tracks.length > 0) {
1965
+ this.bus.emit('settings-select-subtitle', { index: item.value });
1966
+ }
1916
1967
  });
1917
1968
  }
1918
1969
  addOutsideClickListener() {
@@ -2506,6 +2557,27 @@
2506
2557
  }
2507
2558
  }
2508
2559
 
2560
+ /**
2561
+ * ChannelName displays a static label (e.g. channel or program name)
2562
+ * in the bottom bar, next to the live badge and time display.
2563
+ *
2564
+ * Reads config.channelName and renders nothing if unset.
2565
+ *
2566
+ * Usage:
2567
+ * EBPlayer.start({ src: '...', skin: 'v2', channelName: 'ALOULA' })
2568
+ */
2569
+ class ChannelName extends BaseComponent {
2570
+ onConnect() {
2571
+ this.render();
2572
+ }
2573
+ template() {
2574
+ const name = this.config.channelName;
2575
+ if (!name)
2576
+ return b ``;
2577
+ return b `<span class="eb-channel-name">${name}</span>`;
2578
+ }
2579
+ }
2580
+
2509
2581
  /**
2510
2582
  * Maps ComponentId strings to factory functions that create component instances.
2511
2583
  * Used by SkinRoot.connectChildComponents() to dynamically mount components
@@ -2524,7 +2596,8 @@
2524
2596
  'rewind': () => new RewindButton(),
2525
2597
  'forward': () => new ForwardButton(),
2526
2598
  'share': () => new ShareButton(),
2527
- 'info': () => new InfoButton()
2599
+ 'info': () => new InfoButton(),
2600
+ 'channel-name': () => new ChannelName()
2528
2601
  };
2529
2602
  /**
2530
2603
  * Returns the CSS selector for a component's slot element.
@@ -2544,7 +2617,7 @@
2544
2617
  * - Reconnecting: visible when state.reconnecting === true (shows "Reconnecting..." text)
2545
2618
  *
2546
2619
  * Hidden when not buffering and not reconnecting.
2547
- * CSS in skin.css handles the spin animation on .eb-loading.
2620
+ * Renders a CSS arc ring that spins around the center (wrapping the play/pause button).
2548
2621
  */
2549
2622
  class LoadingSpinner extends BaseComponent {
2550
2623
  onConnect() {
@@ -2556,13 +2629,13 @@
2556
2629
  const isBuffering = this.state.playbackState === 'buffering';
2557
2630
  const isReconnecting = this.state.reconnecting;
2558
2631
  if (!isBuffering && !isReconnecting) {
2559
- return b `<div class="eb-loading" hidden aria-hidden="true">${icon('spinner')}</div>`;
2632
+ return b `<div class="eb-loading" hidden aria-hidden="true"></div>`;
2560
2633
  }
2561
2634
  const label = isReconnecting
2562
2635
  ? (this.i18n?.t('loading.reconnecting') ?? 'Reconnecting...')
2563
2636
  : 'Loading';
2564
2637
  return b `<div class="eb-loading" role="status" aria-label="${label}">
2565
- ${icon('spinner')}
2638
+ <div class="eb-loading-arc"></div>
2566
2639
  ${isReconnecting ? b `<span class="eb-loading-text">${label}</span>` : ''}
2567
2640
  </div>`;
2568
2641
  }
@@ -2728,8 +2801,10 @@
2728
2801
  }, { signal: this.signal });
2729
2802
  }
2730
2803
  template() {
2731
- const playlist = this.state?.playlist ?? [];
2732
- const currentEpisode = this.state?.currentEpisode ?? 0;
2804
+ const playlist = this.state.playlist;
2805
+ const currentEpisode = this.state.currentEpisode;
2806
+ if (playlist.length === 0)
2807
+ return b ``;
2733
2808
  return b `
2734
2809
  <div class="eb-forja-playlist-bar">
2735
2810
  <button
@@ -2793,7 +2868,7 @@
2793
2868
  }, { signal: this.signal });
2794
2869
  }
2795
2870
  template() {
2796
- const programs = this.state?.epgPrograms ?? [];
2871
+ const programs = this.state.epgPrograms;
2797
2872
  return b `
2798
2873
  <div class="eb-snrt-carousel">
2799
2874
  ${programs.map((program) => b `
@@ -2933,7 +3008,7 @@
2933
3008
  <video
2934
3009
  class="eb-video"
2935
3010
  playsinline
2936
- ?muted="${config.muted}"
3011
+ .muted="${config.muted}"
2937
3012
  ?autoplay="${config.autoplay}"
2938
3013
  ?controls="${isIOS}"
2939
3014
  ></video>
@@ -3063,16 +3138,14 @@
3063
3138
  }
3064
3139
  }
3065
3140
  }
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
- }
3141
+ // Loading spinner — mounted directly into .eb-player (not overlay-zone)
3142
+ // so it escapes the overlay-zone stacking context and displays above the middle bar
3143
+ const loadingSlot = document.createElement('div');
3144
+ loadingSlot.className = 'eb-loading-slot';
3145
+ inner.appendChild(loadingSlot);
3146
+ const loadingSpinner = new LoadingSpinner();
3147
+ loadingSpinner.connect(loadingSlot, state, bus, config, i18n);
3148
+ this.childComponents.push(loadingSpinner);
3076
3149
  // Interactive overlays — mounted directly into .eb-player (not overlay-zone)
3077
3150
  // so they escape the overlay-zone stacking context and display above all controls
3078
3151
  const interactiveOverlays = [
@@ -3103,7 +3176,7 @@
3103
3176
  <video
3104
3177
  class="eb-video"
3105
3178
  playsinline
3106
- ?muted="${config.muted}"
3179
+ .muted="${config.muted}"
3107
3180
  ?autoplay="${config.autoplay}"
3108
3181
  ></video>
3109
3182
  </div>
@@ -3900,10 +3973,14 @@
3900
3973
  const skinColors = this.config.skinColors;
3901
3974
  if (this.config.primaryColor) {
3902
3975
  container.style.setProperty('--eb-color-primary', this.config.primaryColor);
3976
+ container.style.setProperty('--eb-color-progress', this.config.primaryColor);
3977
+ container.style.setProperty('--eb-accent', this.config.primaryColor);
3903
3978
  }
3904
3979
  // skinColors.general overrides primaryColor
3905
3980
  if (skinColors?.general) {
3906
3981
  container.style.setProperty('--eb-color-primary', skinColors.general);
3982
+ container.style.setProperty('--eb-color-progress', skinColors.general);
3983
+ container.style.setProperty('--eb-accent', skinColors.general);
3907
3984
  }
3908
3985
  if (skinColors?.progressBar) {
3909
3986
  container.style.setProperty('--eb-color-progress', skinColors.progressBar);
@@ -4246,7 +4323,7 @@
4246
4323
  // -------------------------------------------------------------------------
4247
4324
  bindVideoEvents(video, state, signal) {
4248
4325
  // Playback timing + live sync detection
4249
- const syncMargin = this.config?.syncLiveMargin ?? 5 * 60;
4326
+ const syncMargin = this.config?.syncLiveMargin ?? 30;
4250
4327
  video.addEventListener('timeupdate', () => {
4251
4328
  state.currentTime = Math.round(video.currentTime);
4252
4329
  if (state.isLive) {
@@ -4272,14 +4349,41 @@
4272
4349
  state.playbackRate = video.playbackRate;
4273
4350
  }, { signal });
4274
4351
  // FSM transitions: playing, pause, waiting, ended
4352
+ //
4353
+ // Pause guard: browsers fire spurious 'pause' events when currentTime is
4354
+ // set programmatically (rewind/forward/seekbar). We only honour the 'pause'
4355
+ // event when the previous playback state was 'playing' — meaning the user
4356
+ // (or our code) explicitly called video.pause(). During a seek the state
4357
+ // is already 'buffering' by the time the late 'pause' arrives, so it's
4358
+ // harmlessly ignored.
4359
+ let userRequestedPause = false;
4275
4360
  video.addEventListener('playing', () => {
4361
+ userRequestedPause = false;
4276
4362
  state.playbackState = 'playing';
4277
4363
  }, { signal });
4364
+ video.addEventListener('waiting', () => {
4365
+ state.playbackState = 'buffering';
4366
+ }, { signal });
4278
4367
  video.addEventListener('pause', () => {
4368
+ if (video.seeking)
4369
+ return;
4370
+ if (state.playbackState === 'buffering') {
4371
+ userRequestedPause = true;
4372
+ return;
4373
+ }
4279
4374
  state.playbackState = 'paused';
4280
4375
  }, { signal });
4281
- video.addEventListener('waiting', () => {
4282
- state.playbackState = 'buffering';
4376
+ video.addEventListener('seeked', () => {
4377
+ // Update live sync state immediately after seek so the UI reflects
4378
+ // the new position without waiting for the next timeupdate
4379
+ if (state.isLive) {
4380
+ state.currentTime = Math.round(video.currentTime);
4381
+ state.isSyncWithLive = video.currentTime + syncMargin > video.duration;
4382
+ }
4383
+ if (userRequestedPause && video.paused) {
4384
+ state.playbackState = 'paused';
4385
+ userRequestedPause = false;
4386
+ }
4283
4387
  }, { signal });
4284
4388
  video.addEventListener('ended', () => {
4285
4389
  state.playbackState = 'ended';
@@ -5233,8 +5337,8 @@
5233
5337
  seek(time) {
5234
5338
  if (this.video === null)
5235
5339
  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.
5340
+ // Disable hls.js live sync on first seek so it never auto-seeks
5341
+ // back to the live edge on DVR/timeshift streams.
5238
5342
  if (!this.liveSyncDisabled && this.driver !== null && this.state?.isLive) {
5239
5343
  const cfg = this.driver.config;
5240
5344
  cfg.liveSyncDurationCount = 0;
@@ -5374,6 +5478,27 @@
5374
5478
  if (this.state) {
5375
5479
  this.state.error = message;
5376
5480
  }
5481
+ // hls.js nulls its media on a fatal keySystemError; signal so the
5482
+ // P2P SDK is torn down before it loops on a null media element.
5483
+ this.bus?.emit('error-fatal', { code: 'DRM', message });
5484
+ }
5485
+ });
5486
+ }
5487
+ // Geo-block detection (opt-in, ported from v1.x).
5488
+ // Detects 403 on manifest/level load and surfaces it via the bus + a
5489
+ // window CustomEvent for back-compat with v1 host integrations.
5490
+ if (config.useGeoblockingErrorHandle) {
5491
+ const busRef = this.bus;
5492
+ driver.on(Hls.Events.ERROR, (_event, data) => {
5493
+ const error = data;
5494
+ if (error?.type === Hls.ErrorTypes.NETWORK_ERROR
5495
+ && (error?.details === 'manifestLoadError' || error?.details === 'levelLoadError')
5496
+ && error?.response?.code === 403) {
5497
+ busRef?.emit('geoblock-detected');
5498
+ window.dispatchEvent(new CustomEvent('geoblock', {
5499
+ detail: { title: 'Geoblock', message: 'Geoblock detected' }
5500
+ }));
5501
+ this.stopWatchdog();
5377
5502
  }
5378
5503
  });
5379
5504
  }
@@ -6070,12 +6195,23 @@
6070
6195
  this.lib.start();
6071
6196
  // Clean up on abort
6072
6197
  signal.addEventListener('abort', () => {
6073
- if (this.lib !== null && typeof this.lib.stop === 'function') {
6074
- this.lib.stop();
6075
- }
6076
- this.lib = null;
6198
+ this.stop();
6077
6199
  }, { once: true });
6078
6200
  }
6201
+ /**
6202
+ * Stop and detach the P2P SDK. Idempotent.
6203
+ *
6204
+ * Called on AbortSignal teardown, and eagerly on an unrecoverable engine
6205
+ * error: hls.js nulls its media element on a fatal keySystemError, so a
6206
+ * still-running eblib instance would keep polling `media.currentTime` and
6207
+ * throw in a loop until full dispose.
6208
+ */
6209
+ stop() {
6210
+ if (this.lib !== null && typeof this.lib.stop === 'function') {
6211
+ this.lib.stop();
6212
+ }
6213
+ this.lib = null;
6214
+ }
6079
6215
  }
6080
6216
 
6081
6217
  /**
@@ -6368,21 +6504,38 @@
6368
6504
  /**
6369
6505
  * EBPlayer facade — consumer-facing API layer.
6370
6506
  *
6371
- * Provides the window.EBPlayer interface that existing consumers use:
6507
+ * Provides the window.EBPlayer interface:
6372
6508
  * - 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)
6509
+ * - window.EBPlayer.stop() — detaches all active engines (skins stay mounted)
6510
+ * - window.EBPlayer.destroy() — disposes all controllers (tears down everything)
6375
6511
  *
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.
6512
+ * Supports multiple simultaneous player instances on the same page.
6513
+ * Each instance is keyed by its container element:
6514
+ * - Calling start() with the SAME container disposes the old instance first (backward compat).
6515
+ * - Calling start() with a DIFFERENT container creates an independent player.
6516
+ *
6517
+ * Per-instance cleanup is available via PlayerReference.destroy().
6518
+ * Global stop()/destroy() operate on all instances at once.
6378
6519
  */
6379
6520
  // 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;
6521
+ /** Active player instances keyed by their container element. */
6522
+ const instances = new Map();
6523
+ /**
6524
+ * Tear down a single instance: snapshot handler, engine, then controller.
6525
+ * Does NOT remove the entry from the instances Map (caller must do that).
6526
+ */
6527
+ function destroyInstance(inst) {
6528
+ if (inst.snapshotDestroy !== null) {
6529
+ inst.snapshotDestroy();
6530
+ inst.snapshotDestroy = null;
6531
+ }
6532
+ if (inst.engine !== null) {
6533
+ inst.engine.detach();
6534
+ inst.engine = null;
6535
+ }
6536
+ inst.p2p = null;
6537
+ inst.controller.dispose();
6538
+ }
6386
6539
  // ---------------------------------------------------------------------------
6387
6540
  // Container resolution
6388
6541
  // ---------------------------------------------------------------------------
@@ -6425,31 +6578,45 @@
6425
6578
  // ---------------------------------------------------------------------------
6426
6579
  /**
6427
6580
  * Start the player with the given config.
6428
- * If a player is already running, it is disposed before the new one is created.
6581
+ *
6582
+ * If a player is already mounted on the same container element, it is disposed
6583
+ * before the new one is created (backward-compatible single-instance behavior).
6584
+ * If the container is different, the new player coexists alongside existing ones
6585
+ * (multi-instance support).
6429
6586
  *
6430
6587
  * Returns a PlayerReference synchronously — never returns a Promise.
6431
6588
  */
6432
6589
  function start(runtimeConfig) {
6433
- // Dispose any existing instance before creating a new one
6434
- if (activeController !== null) {
6435
- destroy();
6590
+ const container = resolveContainer(runtimeConfig.el);
6591
+ // Dispose any existing instance on the SAME container (backward compat).
6592
+ // Instances on OTHER containers are left untouched (multi-instance).
6593
+ const existing = instances.get(container);
6594
+ if (existing !== undefined) {
6595
+ destroyInstance(existing);
6596
+ instances.delete(container);
6436
6597
  }
6437
6598
  const mergedConfig = mergeConfig(DEFAULT_CONFIG, runtimeConfig);
6438
6599
  const controller = new PlayerController(runtimeConfig);
6439
- const container = resolveContainer(runtimeConfig.el);
6440
6600
  controller.mount(container);
6441
6601
  // Video element is available after mount() creates the skin DOM
6442
6602
  const video = container.querySelector('video');
6443
- activeController = controller;
6444
- activeEngine = null;
6603
+ // Per-instance state — captured by the reference closure, never shared across instances
6604
+ const inst = {
6605
+ controller,
6606
+ engine: null,
6607
+ snapshotDestroy: null,
6608
+ p2p: null,
6609
+ container
6610
+ };
6611
+ instances.set(container, inst);
6445
6612
  let lastSrc = '';
6446
6613
  const reference = {
6447
6614
  open(src) {
6448
6615
  lastSrc = src;
6449
6616
  // Detach any existing engine before opening a new stream
6450
- if (activeEngine !== null) {
6451
- activeEngine.detach();
6452
- activeEngine = null;
6617
+ if (inst.engine !== null) {
6618
+ inst.engine.detach();
6619
+ inst.engine = null;
6453
6620
  }
6454
6621
  const engine = selectEngine(src, mergedConfig);
6455
6622
  if (video !== null) {
@@ -6458,11 +6625,14 @@
6458
6625
  engine.setBus(controller.bus);
6459
6626
  engine.setConfig(mergedConfig);
6460
6627
  controller.setEngineSync(engine);
6461
- activeEngine = engine;
6628
+ inst.engine = engine;
6462
6629
  // P2P integration: wire EasyBroadcast SDK when config.lib and config.manager are set
6463
6630
  if (mergedConfig.lib && mergedConfig.manager && video !== null) {
6464
6631
  const isDash = src.includes('.mpd') && mergedConfig.dashjs !== false;
6632
+ // Stop any P2P SDK from a previous open() before attaching a new one
6633
+ inst.p2p?.stop();
6465
6634
  const p2pManager = new P2PManager();
6635
+ inst.p2p = p2pManager;
6466
6636
  // Wait for the engine driver to be created before integrating P2P
6467
6637
  engine.driverReady.then(() => {
6468
6638
  return p2pManager.integrate({
@@ -6479,9 +6649,9 @@
6479
6649
  // Snapshot handler: create snapshot engine for seekbar preview thumbnails
6480
6650
  if (mergedConfig.showProgressThumb) {
6481
6651
  // Clean up any previous snapshot handler
6482
- if (activeSnapshotDestroy !== null) {
6483
- activeSnapshotDestroy();
6484
- activeSnapshotDestroy = null;
6652
+ if (inst.snapshotDestroy !== null) {
6653
+ inst.snapshotDestroy();
6654
+ inst.snapshotDestroy = null;
6485
6655
  }
6486
6656
  const isDash = src.includes('.mpd') && mergedConfig.dashjs !== false;
6487
6657
  // Wait for engine driver to be ready (CDN script loaded) before initializing snapshot
@@ -6492,7 +6662,7 @@
6492
6662
  const handler = new DashSnapshotHandler(src);
6493
6663
  handler.init(win.dashjs)
6494
6664
  .then((handle) => {
6495
- activeSnapshotDestroy = () => handle.destroy();
6665
+ inst.snapshotDestroy = () => handle.destroy();
6496
6666
  controller.bus.emit('snapshot-handler-ready', { take: handle.take, video: handle.video });
6497
6667
  })
6498
6668
  .catch((error) => {
@@ -6512,7 +6682,7 @@
6512
6682
  const handler = new HlsSnapshotHandler({ src, engineSettings: { ...mergedConfig.engineSettings, ...snapshotDrmConfig } }, sharedTokenManager);
6513
6683
  handler.init(win.Hls)
6514
6684
  .then(() => {
6515
- activeSnapshotDestroy = () => handler.destroy();
6685
+ inst.snapshotDestroy = () => handler.destroy();
6516
6686
  const snapshotVideo = handler.getVideo();
6517
6687
  if (snapshotVideo !== null) {
6518
6688
  controller.bus.emit('snapshot-handler-ready', { take: (time) => handler.take(time), video: snapshotVideo });
@@ -6547,15 +6717,19 @@
6547
6717
  }
6548
6718
  },
6549
6719
  close() {
6550
- if (activeSnapshotDestroy !== null) {
6551
- activeSnapshotDestroy();
6552
- activeSnapshotDestroy = null;
6720
+ if (inst.snapshotDestroy !== null) {
6721
+ inst.snapshotDestroy();
6722
+ inst.snapshotDestroy = null;
6553
6723
  }
6554
- if (activeEngine !== null) {
6555
- activeEngine.detach();
6556
- activeEngine = null;
6724
+ if (inst.engine !== null) {
6725
+ inst.engine.detach();
6726
+ inst.engine = null;
6557
6727
  }
6558
6728
  },
6729
+ destroy() {
6730
+ destroyInstance(inst);
6731
+ instances.delete(container);
6732
+ },
6559
6733
  get state() {
6560
6734
  return controller.state;
6561
6735
  }
@@ -6576,14 +6750,21 @@
6576
6750
  reference.open(currentSrc);
6577
6751
  // Restore selections after manifest loads (~2s pragmatic delay for async manifest parsing)
6578
6752
  setTimeout(() => {
6579
- if (activeEngine !== null) {
6580
- activeEngine.setQuality(saved.quality);
6581
- activeEngine.setAudioTrack(saved.audioTrack);
6582
- activeEngine.setSubtitle(saved.subtitleTrack);
6753
+ if (inst.engine !== null) {
6754
+ inst.engine.setQuality(saved.quality);
6755
+ inst.engine.setAudioTrack(saved.audioTrack);
6756
+ inst.engine.setSubtitle(saved.subtitleTrack);
6583
6757
  }
6584
6758
  }, 2000);
6585
6759
  }, 500);
6586
6760
  });
6761
+ // On an unrecoverable engine error (e.g. fatal DRM keySystemError), tear down
6762
+ // the P2P SDK. hls.js nulls its media element, so a still-running eblib would
6763
+ // otherwise loop on `media.currentTime`. The engine has already surfaced the
6764
+ // clean error via state.error and stopped its stall watchdog.
6765
+ controller.bus.on('error-fatal', () => {
6766
+ inst.p2p?.stop();
6767
+ }, { signal: controller.signal });
6587
6768
  // Auto-open the stream if src is provided in config (matches legacy player behaviour
6588
6769
  // where consumers call start({ src: '...' }) and expect playback to begin immediately).
6589
6770
  // When autoplay is false, defer open() until the user requests play — this avoids
@@ -6595,9 +6776,24 @@
6595
6776
  else {
6596
6777
  let deferredOpen = true;
6597
6778
  controller.bus.on('play', () => {
6598
- if (deferredOpen) {
6599
- deferredOpen = false;
6600
- reference.open(mergedConfig.src);
6779
+ if (!deferredOpen)
6780
+ return;
6781
+ deferredOpen = false;
6782
+ reference.open(mergedConfig.src);
6783
+ // Honor the user's click intent. CommandHandler's earlier bus.on('play')
6784
+ // listener fired before us with no source attached, so its video.play()
6785
+ // was a silent no-op. Wait for driverReady (attachMedia + loadSource
6786
+ // done) then start playback ourselves.
6787
+ const engine = inst.engine;
6788
+ if (engine !== null && video !== null) {
6789
+ engine.driverReady.then(() => {
6790
+ const playResult = video.play();
6791
+ if (playResult && typeof playResult.catch === 'function') {
6792
+ playResult.catch((error) => {
6793
+ console.warn('EBPlayer: deferred play() rejected', error);
6794
+ });
6795
+ }
6796
+ });
6601
6797
  }
6602
6798
  }, { signal: controller.signal });
6603
6799
  }
@@ -6605,28 +6801,28 @@
6605
6801
  return reference;
6606
6802
  }
6607
6803
  /**
6608
- * Stop the active stream.
6609
- * Detaches the engine without disposing the controller (skin stays mounted).
6804
+ * Stop all active streams.
6805
+ * Detaches engines without disposing controllers (skins stay mounted).
6806
+ * For single-instance usage this behaves identically to the old global stop().
6610
6807
  */
6611
6808
  function stop() {
6612
- if (activeEngine !== null) {
6613
- activeEngine.detach();
6614
- activeEngine = null;
6809
+ for (const inst of instances.values()) {
6810
+ if (inst.engine !== null) {
6811
+ inst.engine.detach();
6812
+ inst.engine = null;
6813
+ }
6615
6814
  }
6616
6815
  }
6617
6816
  /**
6618
- * Destroy the player completely.
6619
- * Disposes the controller (tears down skin, events, and all resources).
6817
+ * Destroy all player instances completely.
6818
+ * Disposes every controller (tears down skin, events, and all resources).
6819
+ * For single-instance usage this behaves identically to the old global destroy().
6620
6820
  */
6621
6821
  function destroy() {
6622
- if (activeEngine !== null) {
6623
- activeEngine.detach();
6624
- activeEngine = null;
6625
- }
6626
- if (activeController !== null) {
6627
- activeController.dispose();
6628
- activeController = null;
6822
+ for (const inst of instances.values()) {
6823
+ destroyInstance(inst);
6629
6824
  }
6825
+ instances.clear();
6630
6826
  }
6631
6827
  // ---------------------------------------------------------------------------
6632
6828
  // Version
@@ -6639,7 +6835,8 @@
6639
6835
  // ---------------------------------------------------------------------------
6640
6836
  if (typeof window !== 'undefined') {
6641
6837
  console.info(`%cEBPlayer v${VERSION}`, 'color: #1FA9DD; font-weight: bold');
6642
- window.EBPlayer = { start, stop, destroy, AVAILABLE_THEMES, THEME_LAYOUTS, version: VERSION };
6838
+ const win = window;
6839
+ win.EBPlayer = { start, stop, destroy, AVAILABLE_THEMES, THEME_LAYOUTS, version: VERSION };
6643
6840
  }
6644
6841
 
6645
6842
  /**