eb-player 2.0.2 → 2.0.6

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,6 +4,8 @@
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.6";
8
+
7
9
  /**
8
10
  * Finite State Machine for player playback state transitions.
9
11
  *
@@ -308,9 +310,25 @@
308
310
  right: ['forward']
309
311
  }
310
312
  };
313
+ const LEQUIPE_LAYOUT = {
314
+ topBar: {
315
+ left: [],
316
+ right: ['pip', 'settings']
317
+ },
318
+ bottomBar: {
319
+ left: ['play-pause', 'live-sync', 'time'],
320
+ center: ['seekbar'],
321
+ right: ['volume', 'fullscreen']
322
+ },
323
+ middleBar: {
324
+ left: ['rewind'],
325
+ center: [],
326
+ right: ['forward']
327
+ }
328
+ };
311
329
  const THEME_LAYOUTS = {
312
330
  v2: V2_LAYOUT,
313
- lequipe: V2_LAYOUT,
331
+ lequipe: LEQUIPE_LAYOUT,
314
332
  modern: V2_LAYOUT,
315
333
  };
316
334
  /**
@@ -532,6 +550,30 @@
532
550
  fr: 'La diffusion a échoué. Reprise de la lecture locale.',
533
551
  ar: 'Casting failed. Resuming local playback.',
534
552
  es: 'Error en la transmisión. Reanudando la reproducción local.'
553
+ },
554
+ 'settings.audio': {
555
+ en: 'Audio track', fr: 'Piste audio', ar: 'مسار صوتي', es: 'Pista de audio'
556
+ },
557
+ 'settings.subtitles': {
558
+ en: 'Subtitles', fr: 'Sous-titres', ar: 'ترجمات', es: 'Subtítulos'
559
+ },
560
+ 'settings.speed': {
561
+ en: 'Playback speed', fr: 'Vitesse de lecture', ar: 'سرعة التشغيل', es: 'Velocidad'
562
+ },
563
+ 'settings.quality': {
564
+ en: 'Quality', fr: 'Qualité', ar: 'الجودة', es: 'Calidad'
565
+ },
566
+ 'settings.default': {
567
+ en: 'Default', fr: 'Défaut', ar: 'افتراضي', es: 'Predeterminado'
568
+ },
569
+ 'settings.off': {
570
+ en: 'Off', fr: 'Désactivés', ar: 'إيقاف', es: 'Desactivado'
571
+ },
572
+ 'settings.normal': {
573
+ en: 'Normal', fr: 'Normale', ar: 'عادي', es: 'Normal'
574
+ },
575
+ 'settings.auto': {
576
+ en: 'Auto', fr: 'Auto', ar: 'تلقائي', es: 'Auto'
535
577
  }
536
578
  };
537
579
  /**
@@ -1270,6 +1312,8 @@
1270
1312
  this.tooltipX = 0;
1271
1313
  this.previewVideoEl = null;
1272
1314
  this.snapshotTake = null;
1315
+ this.trackEl = null;
1316
+ this.boundDocPointerMove = null;
1273
1317
  }
1274
1318
  onConnect() {
1275
1319
  // Subscribe to snapshot handler readiness (emitted by eb-player.ts)
@@ -1290,6 +1334,10 @@
1290
1334
  this.state.on('epgPrograms', () => this.scheduleRender(), { signal: this.signal });
1291
1335
  this.render();
1292
1336
  }
1337
+ disconnect() {
1338
+ this.stopDocumentTracking();
1339
+ super.disconnect();
1340
+ }
1293
1341
  // ---- rAF batching ----
1294
1342
  scheduleRender() {
1295
1343
  if (this.rafPending)
@@ -1312,11 +1360,47 @@
1312
1360
  const clamped = Math.min(1, Math.max(0, percent));
1313
1361
  return clamped * this.state.duration;
1314
1362
  }
1363
+ // ---- Document-level pointer tracking ----
1364
+ // pointerleave/mouseleave on the track are unreliable when the tooltip (a DOM child)
1365
+ // is absolutely positioned above the track — some browsers consider the pointer still
1366
+ // "inside" the track's subtree. Instead, we track pointer position at the document level
1367
+ // and hide the tooltip when the pointer moves outside the track's bounding rect.
1368
+ startDocumentTracking() {
1369
+ if (this.boundDocPointerMove)
1370
+ return;
1371
+ this.boundDocPointerMove = (event) => this.onDocPointerMove(event);
1372
+ document.addEventListener('pointermove', this.boundDocPointerMove);
1373
+ document.addEventListener('mouseleave', this.boundDocPointerMove);
1374
+ }
1375
+ stopDocumentTracking() {
1376
+ if (!this.boundDocPointerMove)
1377
+ return;
1378
+ document.removeEventListener('pointermove', this.boundDocPointerMove);
1379
+ document.removeEventListener('mouseleave', this.boundDocPointerMove);
1380
+ this.boundDocPointerMove = null;
1381
+ }
1382
+ onDocPointerMove(event) {
1383
+ if (this.isDragging)
1384
+ return;
1385
+ if (!this.trackEl)
1386
+ return;
1387
+ const rect = this.trackEl.getBoundingClientRect();
1388
+ const inBounds = event.clientX >= rect.left
1389
+ && event.clientX <= rect.right
1390
+ && event.clientY >= rect.top
1391
+ && event.clientY <= rect.bottom;
1392
+ if (!inBounds) {
1393
+ this.tooltipVisible = false;
1394
+ this.stopDocumentTracking();
1395
+ this.render();
1396
+ }
1397
+ }
1315
1398
  // ---- Drag handlers ----
1316
1399
  handlePointerDown(event) {
1317
1400
  if (this.state.adPlaying)
1318
1401
  return;
1319
1402
  const trackEl = event.currentTarget;
1403
+ this.trackEl = trackEl;
1320
1404
  // setPointerCapture ensures events continue firing even if pointer leaves the element
1321
1405
  if (typeof trackEl.setPointerCapture === 'function') {
1322
1406
  trackEl.setPointerCapture(event.pointerId);
@@ -1330,8 +1414,14 @@
1330
1414
  const trackEl = event.currentTarget;
1331
1415
  this.dragValue = this.eventToTime(event, trackEl);
1332
1416
  this.scheduleRender();
1417
+ // During drag with pointer capture, only update tooltip when pointer is within track bounds
1418
+ const rect = trackEl.getBoundingClientRect();
1419
+ if (event.clientX >= rect.left && event.clientX <= rect.right) {
1420
+ this.updateTooltip(event);
1421
+ }
1422
+ return;
1333
1423
  }
1334
- // Always update tooltip on pointermove over the track
1424
+ // Always update tooltip on pointermove over the track when not dragging
1335
1425
  this.updateTooltip(event);
1336
1426
  }
1337
1427
  handlePointerUp(event) {
@@ -1341,15 +1431,16 @@
1341
1431
  const seekTime = this.eventToTime(event, trackEl);
1342
1432
  this.isDragging = false;
1343
1433
  this.bus.emit('seek', { time: seekTime });
1344
- this.render();
1345
- }
1346
- handlePointerLeave() {
1434
+ // Always hide tooltip on pointer up — it reappears naturally via pointermove
1435
+ // if the pointer is still over the track.
1347
1436
  this.tooltipVisible = false;
1437
+ this.stopDocumentTracking();
1348
1438
  this.render();
1349
1439
  }
1350
1440
  // ---- Tooltip ----
1351
1441
  updateTooltip(event) {
1352
1442
  const trackEl = event.currentTarget;
1443
+ this.trackEl = trackEl;
1353
1444
  const rect = trackEl.getBoundingClientRect();
1354
1445
  // Compute hover time (use LTR calculation for tooltip position regardless of RTL)
1355
1446
  const rawPercent = (event.clientX - rect.left) / rect.width;
@@ -1360,6 +1451,8 @@
1360
1451
  // Position tooltip at pointer X relative to track, clamped to track edges
1361
1452
  this.tooltipX = Math.min(rect.width, Math.max(0, event.clientX - rect.left));
1362
1453
  this.tooltipVisible = true;
1454
+ // Start document-level tracking to detect when pointer leaves the track
1455
+ this.startDocumentTracking();
1363
1456
  // Request snapshot frame for seekbar preview thumbnail
1364
1457
  if (this.snapshotTake !== null) {
1365
1458
  this.snapshotTake(this.tooltipTime);
@@ -1452,11 +1545,10 @@
1452
1545
  const tooltip = b `
1453
1546
  <div
1454
1547
  class="eb-seekbar-tooltip"
1455
- style="left: ${this.tooltipX}px"
1456
- ?hidden="${!this.tooltipVisible}"
1548
+ style="left: ${this.tooltipX}px; visibility: ${this.tooltipVisible ? 'visible' : 'hidden'}"
1457
1549
  >
1458
- ${tooltipTimeText}
1459
1550
  ${this.previewVideoEl !== null ? this.previewVideoEl : b ``}
1551
+ ${tooltipTimeText}
1460
1552
  </div>
1461
1553
  `;
1462
1554
  return b `
@@ -1469,7 +1561,6 @@
1469
1561
  @pointerdown="${(event) => this.handlePointerDown(event)}"
1470
1562
  @pointermove="${(event) => this.handlePointerMove(event)}"
1471
1563
  @pointerup="${(event) => this.handlePointerUp(event)}"
1472
- @pointerleave="${() => this.handlePointerLeave()}"
1473
1564
  >
1474
1565
  <div class="eb-seekbar-buffered" style="width: ${bufferedPercent.toFixed(2)}%"></div>
1475
1566
  <div class="eb-seekbar-progress" style="width: ${progressPercent.toFixed(2)}%">
@@ -1655,16 +1746,20 @@
1655
1746
  this.mode = 'root';
1656
1747
  this.verticalDir = 'up';
1657
1748
  this.horizontalDir = 'right';
1749
+ this.outsideClickHandler = null;
1750
+ this.outsideClickTimer = null;
1658
1751
  }
1659
1752
  onConnect() {
1660
1753
  this.state.on('settingsOpen', () => {
1661
1754
  // When settings close, reset to root mode
1662
1755
  if (!this.state.settingsOpen) {
1663
1756
  this.mode = 'root';
1757
+ this.removeOutsideClickListener();
1664
1758
  }
1665
1759
  else {
1666
1760
  // Compute placement when opening
1667
1761
  this.computePlacement();
1762
+ this.addOutsideClickListener();
1668
1763
  }
1669
1764
  this.render();
1670
1765
  }, { signal: this.signal });
@@ -1675,6 +1770,8 @@
1675
1770
  this.state.on('subtitleTracks', () => this.render(), { signal: this.signal });
1676
1771
  this.state.on('currentSubtitleTrack', () => this.render(), { signal: this.signal });
1677
1772
  this.state.on('playbackRate', () => this.render(), { signal: this.signal });
1773
+ // Clean up outside-click listener when component disconnects
1774
+ this.signal.addEventListener('abort', () => this.removeOutsideClickListener());
1678
1775
  this.render();
1679
1776
  }
1680
1777
  /**
@@ -1702,53 +1799,76 @@
1702
1799
  this.mode = 'root';
1703
1800
  this.render();
1704
1801
  }
1802
+ /**
1803
+ * Returns the display label for the currently selected value in a category.
1804
+ */
1805
+ currentValueLabel(mode) {
1806
+ const i18n = this.i18n;
1807
+ if (mode === 'quality') {
1808
+ const levels = this.state.qualityLevels;
1809
+ const current = this.state.currentQuality;
1810
+ if (current === -1)
1811
+ return i18n.t('settings.auto');
1812
+ const level = levels[current];
1813
+ return level?.height ? `${level.height}p` : i18n.t('settings.auto');
1814
+ }
1815
+ if (mode === 'speed') {
1816
+ const rate = this.state.playbackRate;
1817
+ return rate === 1 ? i18n.t('settings.normal') : `${rate}x`;
1818
+ }
1819
+ if (mode === 'audio') {
1820
+ const tracks = this.state.audioTracks;
1821
+ const current = this.state.currentAudioTrack;
1822
+ const track = tracks[current];
1823
+ return track?.name || track?.lang || i18n.t('settings.default');
1824
+ }
1825
+ if (mode === 'subtitles') {
1826
+ const current = this.state.currentSubtitleTrack;
1827
+ if (current === -1)
1828
+ return i18n.t('settings.off');
1829
+ const tracks = this.state.subtitleTracks;
1830
+ const track = tracks[current];
1831
+ return track?.name || track?.lang || `Track ${current}`;
1832
+ }
1833
+ return '';
1834
+ }
1705
1835
  renderRootMenu() {
1706
1836
  const qualityLevels = this.state.qualityLevels;
1707
1837
  const audioTracks = this.state.audioTracks;
1708
1838
  const subtitleTracks = this.state.subtitleTracks;
1839
+ const i18n = this.i18n;
1709
1840
  const showQuality = qualityLevels.length > 0;
1710
1841
  const showSpeed = this.config.speed === true;
1711
1842
  const showAudio = audioTracks.length > 1;
1712
1843
  const showSubtitles = subtitleTracks.length > 0;
1844
+ const row = (iconName, label, value, mode) => b `
1845
+ <li>
1846
+ <button class="eb-settings-category" @click="${() => this.navigateTo(mode)}">
1847
+ <span class="eb-settings-category__icon">${icon(iconName)}</span>
1848
+ <span class="eb-settings-category__label">${label}</span>
1849
+ <span class="eb-settings-category__value">${value}</span>
1850
+ <span class="eb-settings-category__chevron">${icon('chevron-right')}</span>
1851
+ </button>
1852
+ </li>
1853
+ `;
1713
1854
  return b `
1714
1855
  <ul class="eb-settings-menu eb-settings-root">
1715
- ${showQuality ? b `
1716
- <li>
1717
- <button class="eb-settings-category" @click="${() => this.navigateTo('quality')}">
1718
- Quality
1719
- </button>
1720
- </li>
1721
- ` : ''}
1722
- ${showSpeed ? b `
1723
- <li>
1724
- <button class="eb-settings-category" @click="${() => this.navigateTo('speed')}">
1725
- Speed
1726
- </button>
1727
- </li>
1728
- ` : ''}
1729
- ${showAudio ? b `
1730
- <li>
1731
- <button class="eb-settings-category" @click="${() => this.navigateTo('audio')}">
1732
- Audio
1733
- </button>
1734
- </li>
1735
- ` : ''}
1736
- ${showSubtitles ? b `
1737
- <li>
1738
- <button class="eb-settings-category" @click="${() => this.navigateTo('subtitles')}">
1739
- Subtitles
1740
- </button>
1741
- </li>
1742
- ` : ''}
1856
+ ${showAudio ? row('audio', i18n.t('settings.audio'), this.currentValueLabel('audio'), 'audio') : ''}
1857
+ ${showSubtitles ? row('subtitle', i18n.t('settings.subtitles'), this.currentValueLabel('subtitles'), 'subtitles') : ''}
1858
+ ${showSpeed ? row('speed', i18n.t('settings.speed'), this.currentValueLabel('speed'), 'speed') : ''}
1859
+ ${showQuality ? row('quality', i18n.t('settings.quality'), this.currentValueLabel('quality'), 'quality') : ''}
1743
1860
  </ul>
1744
1861
  `;
1745
1862
  }
1746
1863
  renderSubMenu(title, items, onSelect) {
1747
1864
  return b `
1748
1865
  <div class="eb-settings-submenu">
1749
- <button class="eb-settings-back" @click="${() => this.navigateBack()}">
1750
- ${title}
1751
- </button>
1866
+ <div class="eb-settings-header">
1867
+ <button class="eb-settings-back" @click="${() => this.navigateBack()}">
1868
+ ${icon('chevron-left')}
1869
+ </button>
1870
+ <span class="eb-settings-header__title">${title}</span>
1871
+ </div>
1752
1872
  <ul class="eb-settings-menu">
1753
1873
  ${items.map((item) => b `
1754
1874
  <li>
@@ -1768,14 +1888,14 @@
1768
1888
  const levels = this.state.qualityLevels;
1769
1889
  const currentQuality = this.state.currentQuality;
1770
1890
  const items = getQualityItems(levels, currentQuality);
1771
- return this.renderSubMenu('Quality', items, (item) => {
1891
+ return this.renderSubMenu(this.i18n.t('settings.quality'), items, (item) => {
1772
1892
  this.bus.emit('settings-select-quality', { index: item.value });
1773
1893
  });
1774
1894
  }
1775
1895
  renderSpeedMenu() {
1776
1896
  const currentRate = this.state.playbackRate;
1777
1897
  const items = getSpeedItems(currentRate);
1778
- return this.renderSubMenu('Speed', items, (item) => {
1898
+ return this.renderSubMenu(this.i18n.t('settings.speed'), items, (item) => {
1779
1899
  this.bus.emit('settings-select-speed', { rate: item.value });
1780
1900
  });
1781
1901
  }
@@ -1783,7 +1903,7 @@
1783
1903
  const tracks = this.state.audioTracks;
1784
1904
  const currentTrack = this.state.currentAudioTrack;
1785
1905
  const items = getAudioItems(tracks, currentTrack);
1786
- return this.renderSubMenu('Audio', items, (item) => {
1906
+ return this.renderSubMenu(this.i18n.t('settings.audio'), items, (item) => {
1787
1907
  this.bus.emit('settings-select-audio', { index: item.value });
1788
1908
  });
1789
1909
  }
@@ -1791,10 +1911,37 @@
1791
1911
  const tracks = this.state.subtitleTracks;
1792
1912
  const currentTrack = this.state.currentSubtitleTrack;
1793
1913
  const items = getSubtitleItems(tracks, currentTrack);
1794
- return this.renderSubMenu('Subtitles', items, (item) => {
1914
+ return this.renderSubMenu(this.i18n.t('settings.subtitles'), items, (item) => {
1795
1915
  this.bus.emit('settings-select-subtitle', { index: item.value });
1796
1916
  });
1797
1917
  }
1918
+ addOutsideClickListener() {
1919
+ this.removeOutsideClickListener();
1920
+ // Defer to next tick so the opening click doesn't immediately close
1921
+ this.outsideClickTimer = setTimeout(() => {
1922
+ this.outsideClickTimer = null;
1923
+ // Guard: panel may have closed before this timer fires
1924
+ if (!this.state?.settingsOpen)
1925
+ return;
1926
+ this.outsideClickHandler = (event) => {
1927
+ const wrapper = this.el?.querySelector('.eb-settings-wrapper');
1928
+ if (wrapper && !wrapper.contains(event.target)) {
1929
+ this.state.settingsOpen = false;
1930
+ }
1931
+ };
1932
+ document.addEventListener('click', this.outsideClickHandler, { capture: true });
1933
+ }, 0);
1934
+ }
1935
+ removeOutsideClickListener() {
1936
+ if (this.outsideClickTimer !== null) {
1937
+ clearTimeout(this.outsideClickTimer);
1938
+ this.outsideClickTimer = null;
1939
+ }
1940
+ if (this.outsideClickHandler !== null) {
1941
+ document.removeEventListener('click', this.outsideClickHandler, { capture: true });
1942
+ this.outsideClickHandler = null;
1943
+ }
1944
+ }
1798
1945
  toggleSettings() {
1799
1946
  this.state.settingsOpen = !this.state.settingsOpen;
1800
1947
  }
@@ -2111,13 +2258,24 @@
2111
2258
  *
2112
2259
  * - Calls screenfull.toggle() with the closest .eb-player ancestor on click
2113
2260
  * - Subscribes to screenfull 'change' event to update state.isFullscreen
2114
- * - Hidden when screenfull.isEnabled is false (e.g. iOS Safari)
2261
+ * - Falls back to video-element fullscreen on mobile (iOS Safari webkitEnterFullscreen,
2262
+ * Android Chrome video.requestFullscreen) when the Fullscreen API is not available
2263
+ * for arbitrary elements
2115
2264
  * - Re-renders when state.isFullscreen changes
2116
2265
  */
2117
2266
  class FullscreenButton extends BaseComponent {
2118
2267
  constructor() {
2119
2268
  super(...arguments);
2120
2269
  this.changeHandler = null;
2270
+ this.videoEl = null;
2271
+ this.videoFullscreenBeginHandler = null;
2272
+ this.videoFullscreenEndHandler = null;
2273
+ this.useVideoFallback = false;
2274
+ this.handleDocFullscreenChange = () => {
2275
+ const isFullscreen = document.fullscreenElement === this.videoEl;
2276
+ this.state.isFullscreen = isFullscreen;
2277
+ this.render();
2278
+ };
2121
2279
  }
2122
2280
  onConnect() {
2123
2281
  this.state.on('isFullscreen', () => this.render(), { signal: this.signal });
@@ -2127,25 +2285,90 @@
2127
2285
  this.render();
2128
2286
  };
2129
2287
  screenfull$1.on('change', this.changeHandler);
2130
- // Unsubscribe when signal aborts (on disconnect)
2131
2288
  this.signal.addEventListener('abort', () => {
2132
2289
  screenfull$1.off('change', this.changeHandler);
2133
2290
  this.changeHandler = null;
2134
2291
  });
2135
2292
  }
2293
+ else {
2294
+ // Fullscreen API not available for elements — try video-element fallback (mobile)
2295
+ this.initVideoFallback();
2296
+ }
2136
2297
  this.render();
2137
2298
  }
2299
+ /**
2300
+ * On mobile browsers the Fullscreen API may not work on arbitrary elements,
2301
+ * but the <video> element itself supports fullscreen via webkitEnterFullscreen
2302
+ * (iOS Safari) or video.requestFullscreen() (Android Chrome).
2303
+ */
2304
+ initVideoFallback() {
2305
+ const playerRoot = this.el?.closest('.eb-player');
2306
+ if (!playerRoot)
2307
+ return;
2308
+ const video = playerRoot.querySelector('video.eb-video');
2309
+ if (!video)
2310
+ return;
2311
+ // iOS Safari: webkitSupportsFullscreen + webkitEnterFullscreen
2312
+ const hasWebkitFullscreen = typeof video.webkitEnterFullscreen === 'function';
2313
+ // Android Chrome: standard requestFullscreen on the video element
2314
+ const hasRequestFullscreen = typeof video.requestFullscreen === 'function';
2315
+ if (!hasWebkitFullscreen && !hasRequestFullscreen)
2316
+ return;
2317
+ this.videoEl = video;
2318
+ this.useVideoFallback = true;
2319
+ // Track fullscreen state changes on the video element
2320
+ this.videoFullscreenBeginHandler = () => {
2321
+ this.state.isFullscreen = true;
2322
+ this.render();
2323
+ };
2324
+ this.videoFullscreenEndHandler = () => {
2325
+ this.state.isFullscreen = false;
2326
+ this.render();
2327
+ };
2328
+ // iOS Safari fires these events on the video element
2329
+ video.addEventListener('webkitbeginfullscreen', this.videoFullscreenBeginHandler);
2330
+ video.addEventListener('webkitendfullscreen', this.videoFullscreenEndHandler);
2331
+ // Android Chrome fires standard fullscreenchange on the document
2332
+ document.addEventListener('fullscreenchange', this.handleDocFullscreenChange);
2333
+ this.signal.addEventListener('abort', () => {
2334
+ video.removeEventListener('webkitbeginfullscreen', this.videoFullscreenBeginHandler);
2335
+ video.removeEventListener('webkitendfullscreen', this.videoFullscreenEndHandler);
2336
+ document.removeEventListener('fullscreenchange', this.handleDocFullscreenChange);
2337
+ this.videoFullscreenBeginHandler = null;
2338
+ this.videoFullscreenEndHandler = null;
2339
+ this.videoEl = null;
2340
+ });
2341
+ }
2138
2342
  handleClick() {
2139
- if (!screenfull$1.isEnabled)
2343
+ if (screenfull$1.isEnabled) {
2344
+ const container = (this.el.closest('.eb-player') ?? this.el);
2345
+ screenfull$1.toggle(container);
2140
2346
  return;
2141
- // Use the closest .eb-player ancestor as the fullscreen target,
2142
- // falling back to this component's own element.
2143
- // this.el is always non-null when this handler is called from a mounted button.
2144
- const container = (this.el.closest('.eb-player') ?? this.el);
2145
- screenfull$1.toggle(container);
2347
+ }
2348
+ if (this.useVideoFallback && this.videoEl) {
2349
+ if (this.state.isFullscreen) {
2350
+ // Exit fullscreen
2351
+ if (typeof this.videoEl.webkitExitFullscreen === 'function') {
2352
+ this.videoEl.webkitExitFullscreen();
2353
+ }
2354
+ else if (document.exitFullscreen) {
2355
+ document.exitFullscreen();
2356
+ }
2357
+ }
2358
+ else {
2359
+ // Enter fullscreen
2360
+ if (typeof this.videoEl.webkitEnterFullscreen === 'function') {
2361
+ this.videoEl.webkitEnterFullscreen();
2362
+ }
2363
+ else if (typeof this.videoEl.requestFullscreen === 'function') {
2364
+ this.videoEl.requestFullscreen();
2365
+ }
2366
+ }
2367
+ }
2146
2368
  }
2147
2369
  template() {
2148
- if (!screenfull$1.isEnabled) {
2370
+ const canFullscreen = screenfull$1.isEnabled || this.useVideoFallback;
2371
+ if (!canFullscreen) {
2149
2372
  return b `<button class="eb-fullscreen" hidden aria-hidden="true">${icon('fullscreen')}</button>`;
2150
2373
  }
2151
2374
  const isFullscreen = this.state.isFullscreen;
@@ -5034,7 +5257,7 @@
5034
5257
  // Build driver config — only spread known engineSettings keys that hls.js recognises,
5035
5258
  // not the entire engineSettings bag (which may contain player-specific keys like
5036
5259
  // extraParamsCallback that should NOT leak into the hls.js constructor config).
5037
- const { emeEnabled, drmSystems, ...hlsEngineSettings } = config.engineSettings;
5260
+ const { emeEnabled: _emeEnabled, drmSystems: _drmSystems, ...hlsEngineSettings } = config.engineSettings;
5038
5261
  // Remove player-specific keys that are NOT hls.js config options
5039
5262
  const hlsSafeSettings = { ...hlsEngineSettings };
5040
5263
  delete hlsSafeSettings['extraParamsCallback'];
@@ -5771,7 +5994,7 @@
5771
5994
  init(HlsConstructor) {
5772
5995
  // Create an off-screen video element for the snapshot player
5773
5996
  const offscreenVideo = document.createElement('video');
5774
- offscreenVideo.preload = 'none';
5997
+ offscreenVideo.preload = 'metadata';
5775
5998
  this.offscreenVideo = offscreenVideo;
5776
5999
  // Capture tokenManager via closure (Pitfall 6)
5777
6000
  const tokenManager = this.tokenManager;
@@ -5802,11 +6025,17 @@
5802
6025
  }
5803
6026
  PLoader = SnapshotPLoader;
5804
6027
  }
6028
+ // Strip player-specific keys that are NOT hls.js config options — they contain
6029
+ // non-serializable functions that cause DataCloneError when hls.js posts config to its worker.
6030
+ const rawSettings = { ...(this.config.engineSettings ?? {}) };
6031
+ delete rawSettings['extraParamsCallback'];
6032
+ delete rawSettings['onCDNTokenError'];
5805
6033
  const driverConfig = {
5806
6034
  startLevel: 0,
5807
6035
  enableWebVTT: false,
6036
+ enableWorker: false,
5808
6037
  maxBufferLength: 1,
5809
- ...(this.config.engineSettings ?? {}),
6038
+ ...rawSettings,
5810
6039
  ...(PLoader ? { pLoader: PLoader } : {})
5811
6040
  };
5812
6041
  const driver = new HlsConstructor(driverConfig);
@@ -6137,8 +6366,30 @@
6137
6366
  else {
6138
6367
  const win = window;
6139
6368
  if (win.Hls) {
6140
- const handler = new HlsSnapshotHandler({ src, engineSettings: mergedConfig.engineSettings }, null);
6141
- handler.init(win.Hls)
6369
+ // Create a dedicated token manager for the snapshot handler (DRM license + manifest tokens)
6370
+ let snapshotTokenManager = null;
6371
+ if (mergedConfig.token) {
6372
+ snapshotTokenManager = new CDNTokenManager({
6373
+ token: mergedConfig.token,
6374
+ tokenType: mergedConfig.tokenType,
6375
+ srcInTokenRequest: mergedConfig.srcInTokenRequest,
6376
+ extraParamsCallback: (mergedConfig.engineSettings.extraParamsCallback ?? mergedConfig.extraParamsCallback),
6377
+ onCDNTokenError: mergedConfig.engineSettings.onCDNTokenError
6378
+ });
6379
+ }
6380
+ // Build DRM config (emeEnabled, drmSystems, licenseXhrSetup) for the snapshot hls.js instance
6381
+ const snapshotDrmConfigurator = new DrmConfigurator(snapshotTokenManager);
6382
+ const snapshotDrmConfig = snapshotDrmConfigurator.buildHlsConfig(mergedConfig.engineSettings);
6383
+ const handler = new HlsSnapshotHandler({ src, engineSettings: { ...mergedConfig.engineSettings, ...snapshotDrmConfig } }, snapshotTokenManager);
6384
+ // Fetch initial token before init (needed for manifest request)
6385
+ const tokenReady = snapshotTokenManager && src
6386
+ ? snapshotTokenManager.fetchToken({ src }).catch((error) => {
6387
+ console.warn('EBPlayer: Snapshot token fetch failed:', error);
6388
+ })
6389
+ : Promise.resolve();
6390
+ tokenReady.then(() => {
6391
+ return handler.init(win.Hls);
6392
+ })
6142
6393
  .then(() => {
6143
6394
  activeSnapshotDestroy = () => handler.destroy();
6144
6395
  const snapshotVideo = handler.getVideo();
@@ -6226,10 +6477,17 @@
6226
6477
  }
6227
6478
  }
6228
6479
  // ---------------------------------------------------------------------------
6480
+ // Version
6481
+ // ---------------------------------------------------------------------------
6482
+ // Injected at build time by Rollup's @rollup/plugin-virtual or replaced by bundler.
6483
+ // Falls back to 'dev' when running unbundled (tests, dev server).
6484
+ const VERSION = typeof __EB_PLAYER_VERSION__ !== 'undefined' ? __EB_PLAYER_VERSION__ : 'dev';
6485
+ // ---------------------------------------------------------------------------
6229
6486
  // window.EBPlayer assignment
6230
6487
  // ---------------------------------------------------------------------------
6231
6488
  if (typeof window !== 'undefined') {
6232
- window.EBPlayer = { start, stop, destroy, AVAILABLE_THEMES, THEME_LAYOUTS };
6489
+ console.info(`%cEBPlayer v${VERSION}`, 'color: #1FA9DD; font-weight: bold');
6490
+ window.EBPlayer = { start, stop, destroy, AVAILABLE_THEMES, THEME_LAYOUTS, version: VERSION };
6233
6491
  }
6234
6492
 
6235
6493
  /**