@scarlett-player/ui 1.8.1 → 1.10.0

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.
package/dist/index.cjs CHANGED
@@ -23,8 +23,8 @@ __export(index_exports, {
23
23
  DEFAULT_PRIORITY: () => DEFAULT_PRIORITY,
24
24
  assertFitLayout: () => assertFitLayout,
25
25
  default: () => index_default,
26
- formatLiveTime: () => formatLiveTime,
27
- formatTime: () => formatTime,
26
+ formatLiveTime: () => import_core2.formatLiveTime,
27
+ formatTime: () => import_core2.formatTime,
28
28
  getControlFactory: () => getControlFactory,
29
29
  icons: () => icons,
30
30
  planFit: () => planFit,
@@ -33,10 +33,11 @@ __export(index_exports, {
33
33
  resolveFitItems: () => resolveFitItems,
34
34
  styles: () => styles,
35
35
  uiPlugin: () => uiPlugin,
36
- unregisterControl: () => unregisterControl
36
+ unregisterControl: () => unregisterControl,
37
+ unregisterControlsFor: () => unregisterControlsFor
37
38
  });
38
39
  module.exports = __toCommonJS(index_exports);
39
- var import_core2 = require("@scarlett-player/core");
40
+ var import_core4 = require("@scarlett-player/core");
40
41
 
41
42
  // src/fit.ts
42
43
  var UNKNOWN_RANK = 3;
@@ -123,6 +124,10 @@ var styles = `
123
124
  ============================================ */
124
125
  .sp-container {
125
126
  position: relative;
127
+ /* Own stacking context: the control bar, menus and overlays all carry
128
+ z-index, and without this they compete with the host page's own layers
129
+ instead of staying inside the player. */
130
+ isolation: isolate;
126
131
  width: 100%;
127
132
  height: 100%;
128
133
  background: #000;
@@ -244,11 +249,13 @@ var styles = `
244
249
  cursor: pointer;
245
250
  z-index: 10;
246
251
  opacity: 0;
252
+ pointer-events: none;
247
253
  transition: opacity 0.25s ease;
248
254
  }
249
255
 
250
256
  .sp-progress-wrapper--visible {
251
257
  opacity: 1;
258
+ pointer-events: auto;
252
259
  }
253
260
 
254
261
  /* Touch: a 20px wrapper is not a 20px target. The control bar is a later
@@ -1197,24 +1204,30 @@ var styles = `
1197
1204
 
1198
1205
  /* ============================================
1199
1206
  CSS Custom Properties (Theming)
1207
+ ============================================
1208
+
1209
+ Token defaults live at the use sites as var() fallbacks, not in a :root
1210
+ block. A :root declaration wrote --sp-accent, --sp-color, --sp-bg,
1211
+ --sp-control-height and --sp-icon-size into the HOST document, so mounting a
1212
+ player changed variables the page may own. A .sp-container declaration would
1213
+ be no better: the container rule wins for every descendant, so it would
1214
+ override a host that themes the player from its own :root. A var() fallback
1215
+ does neither - it applies only where nothing else defines the token.
1216
+
1217
+ setTheme() writes the same names onto the player's container, which still
1218
+ wins over the fallbacks.
1200
1219
  ============================================ */
1201
- :root {
1202
- --sp-accent: #e50914;
1203
- --sp-color: #fff;
1204
- --sp-bg: rgba(0, 0, 0, 0.8);
1205
- --sp-control-height: 48px;
1206
- --sp-icon-size: 24px;
1207
- }
1208
1220
  `;
1209
1221
 
1210
1222
  // src/icons.ts
1223
+ var import_core = require("@scarlett-player/core");
1211
1224
  var icons = {
1212
- play: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>`,
1225
+ play: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="${import_core.SHARED_ICON_PATHS.play}"/></svg>`,
1213
1226
  pause: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>`,
1214
1227
  replay: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/></svg>`,
1215
- volumeHigh: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>`,
1228
+ volumeHigh: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="${import_core.SHARED_ICON_PATHS.volumeHigh}"/></svg>`,
1216
1229
  volumeLow: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/></svg>`,
1217
- volumeMute: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>`,
1230
+ volumeMute: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="${import_core.SHARED_ICON_PATHS.volumeMuted}"/></svg>`,
1218
1231
  fullscreen: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>`,
1219
1232
  exitFullscreen: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>`,
1220
1233
  pip: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14z"/></svg>`,
@@ -1287,29 +1300,7 @@ function setAttr(el, name, value) {
1287
1300
  }
1288
1301
 
1289
1302
  // src/utils/format.ts
1290
- function formatTime(seconds) {
1291
- if (!isFinite(seconds) || isNaN(seconds)) {
1292
- return "0:00";
1293
- }
1294
- const absSeconds = Math.abs(seconds);
1295
- const h = Math.floor(absSeconds / 3600);
1296
- const m = Math.floor(absSeconds % 3600 / 60);
1297
- const s = Math.floor(absSeconds % 60);
1298
- const sign = seconds < 0 ? "-" : "";
1299
- if (h > 0) {
1300
- return `${sign}${h}:${pad(m)}:${pad(s)}`;
1301
- }
1302
- return `${sign}${m}:${pad(s)}`;
1303
- }
1304
- function pad(n) {
1305
- return n < 10 ? `0${n}` : `${n}`;
1306
- }
1307
- function formatLiveTime(behindLive) {
1308
- if (behindLive <= 0) {
1309
- return "LIVE";
1310
- }
1311
- return `-${formatTime(behindLive)}`;
1312
- }
1303
+ var import_core2 = require("@scarlett-player/core");
1313
1304
 
1314
1305
  // src/controls/PlayButton.ts
1315
1306
  var PlayButton = class {
@@ -1371,7 +1362,7 @@ var BigPlayButton = class {
1371
1362
  * must not sit on top of it, and `error` state alone does not say (a
1372
1363
  * dismissed overlay leaves the error behind)
1373
1364
  */
1374
- constructor(api, isOverlayVisible = () => false) {
1365
+ constructor(api, isOverlayVisible) {
1375
1366
  /**
1376
1367
  * Latched on the first `playing`.
1377
1368
  *
@@ -1385,7 +1376,8 @@ var BigPlayButton = class {
1385
1376
  this.start();
1386
1377
  };
1387
1378
  this.api = api;
1388
- this.isOverlayVisible = isOverlayVisible;
1379
+ this.hasOverlayProbe = typeof isOverlayVisible === "function";
1380
+ this.isOverlayVisible = isOverlayVisible ?? (() => false);
1389
1381
  const btn = document.createElement("button");
1390
1382
  btn.className = "sp-big-play";
1391
1383
  btn.setAttribute("type", "button");
@@ -1414,7 +1406,7 @@ var BigPlayButton = class {
1414
1406
  this.hasStarted = true;
1415
1407
  }
1416
1408
  let visible;
1417
- if (error || this.isOverlayVisible()) {
1409
+ if (this.hasOverlayProbe ? this.isOverlayVisible() : Boolean(error)) {
1418
1410
  visible = false;
1419
1411
  } else if (playbackState === "loading") {
1420
1412
  visible = false;
@@ -1613,7 +1605,7 @@ var ProgressBar = class {
1613
1605
  video.addEventListener("seeked", resumePlayback);
1614
1606
  }
1615
1607
  }
1616
- this.tooltip.style.opacity = "0";
1608
+ this.tooltip.style.opacity = "";
1617
1609
  this.thumbnailPreview.hide();
1618
1610
  }
1619
1611
  };
@@ -1622,7 +1614,7 @@ var ProgressBar = class {
1622
1614
  };
1623
1615
  this.onMouseLeave = () => {
1624
1616
  if (!this.isDragging) {
1625
- this.tooltip.style.opacity = "0";
1617
+ this.tooltip.style.opacity = "";
1626
1618
  this.thumbnailPreview.hide();
1627
1619
  }
1628
1620
  };
@@ -1766,7 +1758,7 @@ var ProgressBar = class {
1766
1758
  }
1767
1759
  this.el.setAttribute("aria-valuemax", String(Math.floor(duration)));
1768
1760
  this.el.setAttribute("aria-valuenow", String(Math.floor(currentTime)));
1769
- this.el.setAttribute("aria-valuetext", formatTime(currentTime));
1761
+ this.el.setAttribute("aria-valuetext", (0, import_core2.formatTime)(currentTime));
1770
1762
  }
1771
1763
  }
1772
1764
  /**
@@ -1840,22 +1832,24 @@ var ProgressBar = class {
1840
1832
  const seekableRange = this.api.getState("seekableRange");
1841
1833
  if (live && seekableRange) {
1842
1834
  const rangeLength = seekableRange.end - seekableRange.start;
1843
- return seekableRange.start + percent * rangeLength;
1835
+ const time2 = seekableRange.start + percent * rangeLength;
1836
+ return Number.isFinite(time2) ? time2 : null;
1844
1837
  }
1845
1838
  const duration = this.api.getState("duration") || 0;
1846
- return percent * duration;
1839
+ const time = percent * duration;
1840
+ return Number.isFinite(time) ? time : null;
1847
1841
  }
1848
1842
  updateTooltip(clientX) {
1849
1843
  const rect = this.el.getBoundingClientRect();
1850
1844
  const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
1851
- const time = this.getTimeFromPosition(clientX);
1845
+ const time = this.getTimeFromPosition(clientX) ?? 0;
1852
1846
  const live = this.api.getState("live");
1853
1847
  const seekableRange = this.api.getState("seekableRange");
1854
1848
  if (live && seekableRange) {
1855
1849
  const behindLive = seekableRange.end - time;
1856
- this.tooltip.textContent = formatLiveTime(behindLive);
1850
+ this.tooltip.textContent = (0, import_core2.formatLiveTime)(behindLive);
1857
1851
  } else {
1858
- this.tooltip.textContent = formatTime(time);
1852
+ this.tooltip.textContent = (0, import_core2.formatTime)(time);
1859
1853
  }
1860
1854
  const chapterLabel = this.chapterLabelAt(time);
1861
1855
  if (chapterLabel) {
@@ -1883,13 +1877,16 @@ var ProgressBar = class {
1883
1877
  }
1884
1878
  this.lastSeekTime = now;
1885
1879
  const time = this.getTimeFromPosition(clientX);
1886
- video.currentTime = time;
1880
+ if (time !== null && Number.isFinite(time)) {
1881
+ video.currentTime = time;
1882
+ }
1887
1883
  }
1888
1884
  destroy() {
1889
1885
  this.wrapper.removeEventListener("mousedown", this.onMouseDown);
1890
1886
  this.wrapper.removeEventListener("mousemove", this.onMouseMove);
1891
1887
  this.wrapper.removeEventListener("mouseleave", this.onMouseLeave);
1892
1888
  this.wrapper.removeEventListener("touchstart", this.onTouchStart);
1889
+ this.el.removeEventListener("keydown", this.onKeyDown);
1893
1890
  document.removeEventListener("mousemove", this.onDocMouseMove);
1894
1891
  document.removeEventListener("mouseup", this.onMouseUp);
1895
1892
  document.removeEventListener("touchmove", this.onDocTouchMove);
@@ -1918,12 +1915,12 @@ var TimeDisplay = class {
1918
1915
  const seekableRange = this.api.getState("seekableRange");
1919
1916
  if (seekableRange) {
1920
1917
  const behindLive = seekableRange.end - currentTime;
1921
- this.el.textContent = formatLiveTime(behindLive);
1918
+ this.el.textContent = (0, import_core2.formatLiveTime)(behindLive);
1922
1919
  } else {
1923
- this.el.textContent = formatLiveTime(0);
1920
+ this.el.textContent = (0, import_core2.formatLiveTime)(0);
1924
1921
  }
1925
1922
  } else {
1926
- this.el.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`;
1923
+ this.el.textContent = `${(0, import_core2.formatTime)(currentTime)} / ${(0, import_core2.formatTime)(duration)}`;
1927
1924
  }
1928
1925
  }
1929
1926
  destroy() {
@@ -2114,11 +2111,9 @@ var LiveIndicator = class {
2114
2111
  }
2115
2112
  }
2116
2113
  seekToLive() {
2117
- const video = getVideo(this.api.container);
2118
- if (!video) return;
2119
2114
  const seekableRange = this.api.getState("seekableRange");
2120
2115
  if (seekableRange) {
2121
- video.currentTime = seekableRange.end;
2116
+ this.api.emit("playback:seeking", { time: seekableRange.end });
2122
2117
  }
2123
2118
  }
2124
2119
  destroy() {
@@ -2164,6 +2159,9 @@ var QualityMenu = class {
2164
2159
  const qualities = this.api.getState("qualities") || [];
2165
2160
  const currentQuality = this.api.getState("currentQuality");
2166
2161
  this.el.style.display = qualities.length > 0 ? "" : "none";
2162
+ if (qualities.length === 0 && this.isOpen) {
2163
+ this.close();
2164
+ }
2167
2165
  this.btnLabel.textContent = currentQuality?.label || "Auto";
2168
2166
  const qualitiesJson = JSON.stringify(qualities.map((q) => q.id));
2169
2167
  const currentId = currentQuality?.id || "auto";
@@ -2228,6 +2226,16 @@ var QualityMenu = class {
2228
2226
  this.menu.classList.remove("sp-quality-menu--open");
2229
2227
  this.btn.setAttribute("aria-expanded", "false");
2230
2228
  }
2229
+ /**
2230
+ * Whether the dropdown is open.
2231
+ *
2232
+ * Read by the UI plugin so the control bar's auto-hide waits for it.
2233
+ *
2234
+ * @returns True while the menu is showing
2235
+ */
2236
+ isMenuOpen() {
2237
+ return this.isOpen;
2238
+ }
2231
2239
  destroy() {
2232
2240
  document.removeEventListener("click", this.closeHandler);
2233
2241
  this.el.remove();
@@ -2397,7 +2405,7 @@ var PipButton = class {
2397
2405
  };
2398
2406
 
2399
2407
  // src/controls/FullscreenButton.ts
2400
- var import_core = require("@scarlett-player/core");
2408
+ var import_core3 = require("@scarlett-player/core");
2401
2409
  var FullscreenButton = class {
2402
2410
  constructor(api) {
2403
2411
  this.clickHandler = () => {
@@ -2432,10 +2440,10 @@ var FullscreenButton = class {
2432
2440
  async toggle() {
2433
2441
  const container = this.api.container;
2434
2442
  try {
2435
- if ((0, import_core.isFullscreen)(container)) {
2436
- await (0, import_core.exitFullscreen)(container);
2443
+ if ((0, import_core3.isFullscreen)(container)) {
2444
+ await (0, import_core3.exitFullscreen)(container);
2437
2445
  } else {
2438
- await (0, import_core.enterFullscreen)(container);
2446
+ await (0, import_core3.enterFullscreen)(container);
2439
2447
  }
2440
2448
  } catch {
2441
2449
  }
@@ -2652,7 +2660,7 @@ var SettingsMenu = class {
2652
2660
  this.el.appendChild(this.panel);
2653
2661
  this.closeHandler = (e) => {
2654
2662
  if (!this.el.contains(e.target)) {
2655
- this.close();
2663
+ this.close(false);
2656
2664
  }
2657
2665
  };
2658
2666
  document.addEventListener("click", this.closeHandler);
@@ -2665,7 +2673,6 @@ var SettingsMenu = class {
2665
2673
  this.showPanel("main");
2666
2674
  } else {
2667
2675
  this.close();
2668
- this.btn.focus();
2669
2676
  }
2670
2677
  return;
2671
2678
  }
@@ -2702,6 +2709,8 @@ var SettingsMenu = class {
2702
2709
  this.updateSpeedActiveStates();
2703
2710
  } else if (this.currentPanel === "captions") {
2704
2711
  this.updateCaptionsActiveStates();
2712
+ } else if (this.currentPanel === "audio") {
2713
+ this.updateAudioActiveStates();
2705
2714
  }
2706
2715
  }
2707
2716
  }
@@ -2716,11 +2725,25 @@ var SettingsMenu = class {
2716
2725
  this.btn.setAttribute("aria-expanded", "true");
2717
2726
  this.focusFirstItem();
2718
2727
  }
2719
- close() {
2728
+ /**
2729
+ * Close the panel.
2730
+ *
2731
+ * Choosing an item tears down the row that had focus, which drops focus to
2732
+ * `<body>` and strands keyboard viewers outside the player entirely. Hand it
2733
+ * back to the button that opened the menu instead.
2734
+ *
2735
+ * @param restoreFocus - Return focus to the gear button. The outside-click
2736
+ * handler passes false, so a click elsewhere is not pulled back into the bar.
2737
+ */
2738
+ close(restoreFocus = true) {
2739
+ const wasOpen = this.isOpen;
2720
2740
  this.isOpen = false;
2721
2741
  this.currentPanel = "main";
2722
2742
  this.panel.classList.remove("sp-settings-panel--open");
2723
2743
  this.btn.setAttribute("aria-expanded", "false");
2744
+ if (wasOpen && restoreFocus) {
2745
+ this.btn.focus();
2746
+ }
2724
2747
  }
2725
2748
  showPanel(panel) {
2726
2749
  this.currentPanel = panel;
@@ -2737,6 +2760,9 @@ var SettingsMenu = class {
2737
2760
  case "captions":
2738
2761
  this.renderCaptionsPanel();
2739
2762
  break;
2763
+ case "audio":
2764
+ this.renderAudioPanel();
2765
+ break;
2740
2766
  }
2741
2767
  this.focusFirstItem();
2742
2768
  }
@@ -2765,6 +2791,16 @@ var SettingsMenu = class {
2765
2791
  );
2766
2792
  this.panel.appendChild(captionsRow);
2767
2793
  }
2794
+ const audioTracks = this.api.getState("audioTracks") || [];
2795
+ if (audioTracks.length > 1) {
2796
+ const currentAudioTrack = this.api.getState("currentAudioTrack");
2797
+ const audioRow = this.createMainRow(
2798
+ "Audio",
2799
+ currentAudioTrack?.label || "Default",
2800
+ () => this.showPanel("audio")
2801
+ );
2802
+ this.panel.appendChild(audioRow);
2803
+ }
2768
2804
  const speedLabel = playbackRate === 1 ? "Normal" : `${playbackRate}x`;
2769
2805
  const speedRow = this.createMainRow(
2770
2806
  "Speed",
@@ -2865,6 +2901,36 @@ var SettingsMenu = class {
2865
2901
  this.panel.appendChild(item);
2866
2902
  }
2867
2903
  }
2904
+ renderAudioPanel() {
2905
+ this.panel.innerHTML = "";
2906
+ this.panel.className = "sp-settings-panel sp-settings-panel--open sp-settings-panel--sub";
2907
+ const header = this.createSubHeader("Audio");
2908
+ this.panel.appendChild(header);
2909
+ const audioTracks = this.api.getState("audioTracks") || [];
2910
+ const currentAudioTrack = this.api.getState("currentAudioTrack");
2911
+ const activeId = currentAudioTrack?.id ?? null;
2912
+ for (const track of audioTracks) {
2913
+ const item = this.createMenuItem(track.label, track.id, track.id === activeId);
2914
+ item.addEventListener("click", (e) => {
2915
+ e.preventDefault();
2916
+ this.selectAudioTrack(track.id);
2917
+ });
2918
+ this.panel.appendChild(item);
2919
+ }
2920
+ }
2921
+ selectAudioTrack(trackId) {
2922
+ this.api.emit("track:audio", { trackId });
2923
+ this.close();
2924
+ }
2925
+ updateAudioActiveStates() {
2926
+ const currentAudioTrack = this.api.getState("currentAudioTrack");
2927
+ const activeId = currentAudioTrack?.id ?? null;
2928
+ const items = this.panel.querySelectorAll(".sp-settings-panel__item");
2929
+ items.forEach((item) => {
2930
+ const id = item.getAttribute("data-id");
2931
+ item.classList.toggle("sp-settings-panel__item--active", id === activeId);
2932
+ });
2933
+ }
2868
2934
  selectCaption(trackId) {
2869
2935
  this.api.emit("track:text", { trackId });
2870
2936
  this.close();
@@ -3254,6 +3320,17 @@ var OverflowTray = class {
3254
3320
  this.panel.classList.remove("sp-overflow-tray--open");
3255
3321
  this.btn.setAttribute("aria-expanded", "false");
3256
3322
  }
3323
+ /**
3324
+ * Whether the strip is open.
3325
+ *
3326
+ * Read by the UI plugin so the control bar's auto-hide waits for it: hiding
3327
+ * the bar would take the open tray with it, mid-use.
3328
+ *
3329
+ * @returns True while the strip is showing
3330
+ */
3331
+ isMenuOpen() {
3332
+ return this.isOpen;
3333
+ }
3257
3334
  /**
3258
3335
  * Show the button only while the tray holds something the viewer can see.
3259
3336
  *
@@ -3293,19 +3370,53 @@ var OverflowTray = class {
3293
3370
  };
3294
3371
 
3295
3372
  // src/control-registry.ts
3296
- var registry = /* @__PURE__ */ new Map();
3373
+ var globalRegistry = /* @__PURE__ */ new Map();
3374
+ var scopedRegistries = /* @__PURE__ */ new Map();
3297
3375
  var listeners = /* @__PURE__ */ new Set();
3298
- function registerControl(id, factory) {
3299
- registry.set(id, factory);
3376
+ function registerControl(id, factory, options = {}) {
3377
+ const { owner } = options;
3378
+ if (owner) {
3379
+ let scoped = scopedRegistries.get(owner);
3380
+ if (!scoped) {
3381
+ scoped = /* @__PURE__ */ new Map();
3382
+ scopedRegistries.set(owner, scoped);
3383
+ }
3384
+ scoped.set(id, factory);
3385
+ } else {
3386
+ globalRegistry.set(id, factory);
3387
+ }
3300
3388
  for (const listener of listeners) {
3301
- listener(id);
3389
+ listener(id, owner);
3302
3390
  }
3303
3391
  }
3304
- function unregisterControl(id) {
3305
- return registry.delete(id);
3392
+ function unregisterControl(id, options = {}) {
3393
+ const { owner } = options;
3394
+ if (!owner) {
3395
+ return globalRegistry.delete(id);
3396
+ }
3397
+ const scoped = scopedRegistries.get(owner);
3398
+ if (!scoped) {
3399
+ return false;
3400
+ }
3401
+ const removed = scoped.delete(id);
3402
+ if (scoped.size === 0) {
3403
+ scopedRegistries.delete(owner);
3404
+ }
3405
+ return removed;
3306
3406
  }
3307
- function getControlFactory(id) {
3308
- return registry.get(id) ?? null;
3407
+ function unregisterControlsFor(owner) {
3408
+ const removed = scopedRegistries.get(owner)?.size ?? 0;
3409
+ scopedRegistries.delete(owner);
3410
+ return removed;
3411
+ }
3412
+ function getControlFactory(id, owner) {
3413
+ if (owner) {
3414
+ const scoped = scopedRegistries.get(owner)?.get(id);
3415
+ if (scoped) {
3416
+ return scoped;
3417
+ }
3418
+ }
3419
+ return globalRegistry.get(id) ?? null;
3309
3420
  }
3310
3421
  function onControlRegistered(listener) {
3311
3422
  listeners.add(listener);
@@ -3314,12 +3425,13 @@ function onControlRegistered(listener) {
3314
3425
  };
3315
3426
  }
3316
3427
  function resetControlRegistry() {
3317
- registry.clear();
3428
+ globalRegistry.clear();
3429
+ scopedRegistries.clear();
3318
3430
  listeners.clear();
3319
3431
  }
3320
3432
 
3321
3433
  // src/version.ts
3322
- var PKG_VERSION = true ? "1.8.1" : "0.0.0-dev";
3434
+ var PKG_VERSION = true ? "1.10.0" : "0.0.0-dev";
3323
3435
 
3324
3436
  // src/index.ts
3325
3437
  var DEFAULT_LAYOUT = [
@@ -3338,12 +3450,25 @@ var DEFAULT_LAYOUT = [
3338
3450
  "pip",
3339
3451
  "fullscreen"
3340
3452
  ];
3453
+ var STYLE_ID = "sp-ui-styles";
3341
3454
  var DEFAULT_HIDE_DELAY = 3e3;
3455
+ var ACTIVATION_KEYS = /* @__PURE__ */ new Set([" ", "Enter"]);
3456
+ var SLIDER_KEYS = /* @__PURE__ */ new Set([
3457
+ "ArrowLeft",
3458
+ "ArrowRight",
3459
+ "ArrowUp",
3460
+ "ArrowDown",
3461
+ "Home",
3462
+ "End",
3463
+ "PageUp",
3464
+ "PageDown"
3465
+ ]);
3342
3466
  var UNMEASURED_CONTROL_WIDTH = 48;
3343
3467
  var FALLBACK_VOLUME_SLIDER_WIDTH = 64;
3344
3468
  var OVERFLOW_BUTTON_WIDTH = 44;
3345
3469
  var FALLBACK_BAR_PADDING_X = 24;
3346
3470
  var FALLBACK_BAR_GAP = 4;
3471
+ var RESIZE_FALLBACK_DEBOUNCE_MS = 100;
3347
3472
  var MENU_HEIGHT_RESERVE = 16;
3348
3473
  var FALLBACK_BAR_HEIGHT = 56;
3349
3474
  var MIN_MENU_HEIGHT = 120;
@@ -3355,7 +3480,7 @@ function uiPlugin(config = {}) {
3355
3480
  let bufferingIndicator = null;
3356
3481
  let errorOverlay = null;
3357
3482
  let bigPlayButton = null;
3358
- let styleEl = null;
3483
+ let releaseStyles = null;
3359
3484
  let controls = [];
3360
3485
  let hideTimeout = null;
3361
3486
  let stateUnsubscribe = null;
@@ -3363,17 +3488,21 @@ function uiPlugin(config = {}) {
3363
3488
  let errorUnsubscribe = null;
3364
3489
  let reconnectingUnsubscribe = null;
3365
3490
  let recoveredUnsubscribe = null;
3491
+ let loadedUnsubscribe = null;
3366
3492
  let controlsVisible = true;
3367
3493
  let rafHandle = null;
3368
3494
  let tray = null;
3369
3495
  let entries = [];
3370
3496
  let timeEntry = null;
3371
3497
  let resizeObserver = null;
3498
+ let windowResizeTimer = null;
3499
+ let onWindowResize = null;
3372
3500
  let barPaddingX = FALLBACK_BAR_PADDING_X;
3373
3501
  let barGap = FALLBACK_BAR_GAP;
3374
3502
  let volumeSliderWidth = FALLBACK_VOLUME_SLIDER_WIDTH;
3375
3503
  let lastFitSignature = null;
3376
3504
  let fitPending = true;
3505
+ let addedContainerClass = false;
3377
3506
  const layout = config.controls || DEFAULT_LAYOUT;
3378
3507
  const hideDelay = config.hideDelay ?? DEFAULT_HIDE_DELAY;
3379
3508
  const showBigPlayButton = config.bigPlayButton !== false;
@@ -3416,7 +3545,7 @@ function uiPlugin(config = {}) {
3416
3545
  case "spacer":
3417
3546
  return new Spacer();
3418
3547
  default: {
3419
- const factory = getControlFactory(slot);
3548
+ const factory = getControlFactory(slot, api.container);
3420
3549
  if (factory) {
3421
3550
  try {
3422
3551
  return factory(api);
@@ -3643,6 +3772,10 @@ function uiPlugin(config = {}) {
3643
3772
  updateControls();
3644
3773
  });
3645
3774
  };
3775
+ const hasOpenMenu = () => controls.some((control) => {
3776
+ const menu = control;
3777
+ return typeof menu.isMenuOpen === "function" && menu.isMenuOpen();
3778
+ });
3646
3779
  const showControls = () => {
3647
3780
  if (controlsVisible) {
3648
3781
  resetHideTimer();
@@ -3659,6 +3792,10 @@ function uiPlugin(config = {}) {
3659
3792
  const hideControls = () => {
3660
3793
  const paused = api?.getState("paused");
3661
3794
  if (paused) return;
3795
+ if (hasOpenMenu()) {
3796
+ resetHideTimer();
3797
+ return;
3798
+ }
3662
3799
  controlsVisible = false;
3663
3800
  controlBar?.classList.remove("sp-controls--visible");
3664
3801
  controlBar?.classList.add("sp-controls--hidden");
@@ -3676,22 +3813,33 @@ function uiPlugin(config = {}) {
3676
3813
  const handlePointerActivity = (event) => {
3677
3814
  last_pointer_type = event.pointerType;
3678
3815
  };
3679
- const handleInteraction = () => {
3680
- if (last_pointer_type === "touch") {
3816
+ const handleInteraction = (event) => {
3817
+ if (last_pointer_type === "touch" && !isOnControls(event)) {
3681
3818
  const gestures = api?.getPlugin("gestures");
3682
3819
  if (gestures?.ownsTapInteraction()) return;
3683
3820
  }
3684
3821
  showControls();
3685
3822
  };
3823
+ const isOnControls = (event) => {
3824
+ const target = event?.target;
3825
+ if (!(target instanceof Node)) return false;
3826
+ return Boolean(controlBar?.contains(target)) || Boolean(progressBar?.render().contains(target));
3827
+ };
3686
3828
  const handleMouseLeave = () => {
3687
3829
  hideControls();
3688
3830
  };
3689
3831
  const handleKeyDown = (e) => {
3690
3832
  if (!api.container.contains(document.activeElement)) return;
3833
+ if (e.defaultPrevented) return;
3834
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
3691
3835
  const activeEl = document.activeElement;
3692
3836
  if (activeEl instanceof HTMLInputElement || activeEl instanceof HTMLTextAreaElement || activeEl instanceof HTMLSelectElement || activeEl?.isContentEditable) {
3693
3837
  return;
3694
3838
  }
3839
+ const role = activeEl?.getAttribute("role");
3840
+ const isActivatable = activeEl instanceof HTMLButtonElement || role === "button" || role === "menuitem";
3841
+ if (isActivatable && ACTIVATION_KEYS.has(e.key)) return;
3842
+ if (role === "slider" && SLIDER_KEYS.has(e.key)) return;
3695
3843
  const video = api.container.querySelector("video");
3696
3844
  if (!video) return;
3697
3845
  const live = api.getState("live");
@@ -3713,11 +3861,11 @@ function uiPlugin(config = {}) {
3713
3861
  break;
3714
3862
  case "f":
3715
3863
  e.preventDefault();
3716
- if ((0, import_core2.isFullscreen)(api.container)) {
3717
- (0, import_core2.exitFullscreen)(api.container).catch(() => {
3864
+ if ((0, import_core4.isFullscreen)(api.container)) {
3865
+ (0, import_core4.exitFullscreen)(api.container).catch(() => {
3718
3866
  });
3719
3867
  } else {
3720
- (0, import_core2.enterFullscreen)(api.container).catch(() => {
3868
+ (0, import_core4.enterFullscreen)(api.container).catch(() => {
3721
3869
  });
3722
3870
  }
3723
3871
  break;
@@ -3758,9 +3906,7 @@ function uiPlugin(config = {}) {
3758
3906
  version: PKG_VERSION,
3759
3907
  async init(pluginApi) {
3760
3908
  api = pluginApi;
3761
- styleEl = document.createElement("style");
3762
- styleEl.textContent = styles;
3763
- document.head.appendChild(styleEl);
3909
+ releaseStyles = (0, import_core4.injectSharedStyles)(STYLE_ID, styles);
3764
3910
  if (config.theme) {
3765
3911
  this.setTheme(config.theme);
3766
3912
  }
@@ -3773,6 +3919,10 @@ function uiPlugin(config = {}) {
3773
3919
  if (containerStyle.position === "static") {
3774
3920
  container.style.position = "relative";
3775
3921
  }
3922
+ if (!container.classList.contains("sp-container")) {
3923
+ container.classList.add("sp-container");
3924
+ addedContainerClass = true;
3925
+ }
3776
3926
  const isPlaying = api.getState("playing");
3777
3927
  gradient = document.createElement("div");
3778
3928
  gradient.className = isPlaying ? "sp-gradient" : "sp-gradient sp-gradient--visible";
@@ -3788,13 +3938,20 @@ function uiPlugin(config = {}) {
3788
3938
  if (payload?.fatal) {
3789
3939
  const error = api.getState("error") || payload;
3790
3940
  errorOverlay?.show(error);
3941
+ scheduleUpdate();
3791
3942
  }
3792
3943
  });
3793
3944
  reconnectingUnsubscribe = api.on("error:reconnecting", () => {
3794
3945
  errorOverlay?.showReconnecting();
3946
+ scheduleUpdate();
3795
3947
  });
3796
3948
  recoveredUnsubscribe = api.on("error:recovered", () => {
3797
3949
  errorOverlay?.hide();
3950
+ scheduleUpdate();
3951
+ });
3952
+ loadedUnsubscribe = api.on("media:loaded", () => {
3953
+ errorOverlay?.hide();
3954
+ scheduleUpdate();
3798
3955
  });
3799
3956
  if (showBigPlayButton) {
3800
3957
  bigPlayButton = new BigPlayButton(api, () => errorOverlay?.isVisible() ?? false);
@@ -3818,11 +3975,25 @@ function uiPlugin(config = {}) {
3818
3975
  scheduleUpdate();
3819
3976
  });
3820
3977
  resizeObserver.observe(container);
3978
+ } else if (responsive && typeof window !== "undefined") {
3979
+ onWindowResize = () => {
3980
+ if (windowResizeTimer) clearTimeout(windowResizeTimer);
3981
+ windowResizeTimer = setTimeout(() => {
3982
+ windowResizeTimer = null;
3983
+ applyMenuBounds(container.clientHeight);
3984
+ fitPending = true;
3985
+ scheduleUpdate();
3986
+ }, RESIZE_FALLBACK_DEBOUNCE_MS);
3987
+ };
3988
+ window.addEventListener("resize", onWindowResize);
3821
3989
  }
3822
- controlRegistryUnsubscribe = onControlRegistered((id) => {
3990
+ controlRegistryUnsubscribe = onControlRegistered((id, owner) => {
3823
3991
  if (!layout.includes(id)) {
3824
3992
  return;
3825
3993
  }
3994
+ if (owner && owner !== api.container) {
3995
+ return;
3996
+ }
3826
3997
  api.logger.debug(`Control "${id}" registered after init, rebuilding control bar`);
3827
3998
  rebuildControlBar();
3828
3999
  });
@@ -3857,6 +4028,14 @@ function uiPlugin(config = {}) {
3857
4028
  }
3858
4029
  resizeObserver?.disconnect();
3859
4030
  resizeObserver = null;
4031
+ if (onWindowResize) {
4032
+ window.removeEventListener("resize", onWindowResize);
4033
+ onWindowResize = null;
4034
+ }
4035
+ if (windowResizeTimer) {
4036
+ clearTimeout(windowResizeTimer);
4037
+ windowResizeTimer = null;
4038
+ }
3860
4039
  api?.container?.style.removeProperty("--sp-menu-max-height");
3861
4040
  stateUnsubscribe?.();
3862
4041
  stateUnsubscribe = null;
@@ -3866,6 +4045,8 @@ function uiPlugin(config = {}) {
3866
4045
  reconnectingUnsubscribe = null;
3867
4046
  recoveredUnsubscribe?.();
3868
4047
  recoveredUnsubscribe = null;
4048
+ loadedUnsubscribe?.();
4049
+ loadedUnsubscribe = null;
3869
4050
  if (api?.container) {
3870
4051
  api.container.removeEventListener("pointerdown", handlePointerActivity);
3871
4052
  api.container.removeEventListener("pointermove", handlePointerActivity);
@@ -3895,8 +4076,12 @@ function uiPlugin(config = {}) {
3895
4076
  gradient = null;
3896
4077
  bufferingIndicator?.remove();
3897
4078
  bufferingIndicator = null;
3898
- styleEl?.remove();
3899
- styleEl = null;
4079
+ releaseStyles?.();
4080
+ releaseStyles = null;
4081
+ if (addedContainerClass) {
4082
+ api?.container?.classList.remove("sp-container");
4083
+ addedContainerClass = false;
4084
+ }
3900
4085
  api?.logger.debug("UI controls plugin destroyed");
3901
4086
  },
3902
4087
  // Public API
@@ -3949,5 +4134,6 @@ var index_default = uiPlugin;
3949
4134
  resolveFitItems,
3950
4135
  styles,
3951
4136
  uiPlugin,
3952
- unregisterControl
4137
+ unregisterControl,
4138
+ unregisterControlsFor
3953
4139
  });