@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.js CHANGED
@@ -1,5 +1,10 @@
1
1
  // src/index.ts
2
- import { enterFullscreen as enterFullscreen2, exitFullscreen as exitFullscreen2, isFullscreen as isFullscreen2 } from "@scarlett-player/core";
2
+ import {
3
+ enterFullscreen as enterFullscreen2,
4
+ exitFullscreen as exitFullscreen2,
5
+ injectSharedStyles,
6
+ isFullscreen as isFullscreen2
7
+ } from "@scarlett-player/core";
3
8
 
4
9
  // src/fit.ts
5
10
  var UNKNOWN_RANK = 3;
@@ -86,6 +91,10 @@ var styles = `
86
91
  ============================================ */
87
92
  .sp-container {
88
93
  position: relative;
94
+ /* Own stacking context: the control bar, menus and overlays all carry
95
+ z-index, and without this they compete with the host page's own layers
96
+ instead of staying inside the player. */
97
+ isolation: isolate;
89
98
  width: 100%;
90
99
  height: 100%;
91
100
  background: #000;
@@ -207,11 +216,13 @@ var styles = `
207
216
  cursor: pointer;
208
217
  z-index: 10;
209
218
  opacity: 0;
219
+ pointer-events: none;
210
220
  transition: opacity 0.25s ease;
211
221
  }
212
222
 
213
223
  .sp-progress-wrapper--visible {
214
224
  opacity: 1;
225
+ pointer-events: auto;
215
226
  }
216
227
 
217
228
  /* Touch: a 20px wrapper is not a 20px target. The control bar is a later
@@ -1160,24 +1171,30 @@ var styles = `
1160
1171
 
1161
1172
  /* ============================================
1162
1173
  CSS Custom Properties (Theming)
1174
+ ============================================
1175
+
1176
+ Token defaults live at the use sites as var() fallbacks, not in a :root
1177
+ block. A :root declaration wrote --sp-accent, --sp-color, --sp-bg,
1178
+ --sp-control-height and --sp-icon-size into the HOST document, so mounting a
1179
+ player changed variables the page may own. A .sp-container declaration would
1180
+ be no better: the container rule wins for every descendant, so it would
1181
+ override a host that themes the player from its own :root. A var() fallback
1182
+ does neither - it applies only where nothing else defines the token.
1183
+
1184
+ setTheme() writes the same names onto the player's container, which still
1185
+ wins over the fallbacks.
1163
1186
  ============================================ */
1164
- :root {
1165
- --sp-accent: #e50914;
1166
- --sp-color: #fff;
1167
- --sp-bg: rgba(0, 0, 0, 0.8);
1168
- --sp-control-height: 48px;
1169
- --sp-icon-size: 24px;
1170
- }
1171
1187
  `;
1172
1188
 
1173
1189
  // src/icons.ts
1190
+ import { SHARED_ICON_PATHS } from "@scarlett-player/core";
1174
1191
  var icons = {
1175
- play: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>`,
1192
+ play: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="${SHARED_ICON_PATHS.play}"/></svg>`,
1176
1193
  pause: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>`,
1177
1194
  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>`,
1178
- 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>`,
1195
+ volumeHigh: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="${SHARED_ICON_PATHS.volumeHigh}"/></svg>`,
1179
1196
  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>`,
1180
- 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>`,
1197
+ volumeMute: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="${SHARED_ICON_PATHS.volumeMuted}"/></svg>`,
1181
1198
  fullscreen: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>`,
1182
1199
  exitFullscreen: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>`,
1183
1200
  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>`,
@@ -1250,29 +1267,7 @@ function setAttr(el, name, value) {
1250
1267
  }
1251
1268
 
1252
1269
  // src/utils/format.ts
1253
- function formatTime(seconds) {
1254
- if (!isFinite(seconds) || isNaN(seconds)) {
1255
- return "0:00";
1256
- }
1257
- const absSeconds = Math.abs(seconds);
1258
- const h = Math.floor(absSeconds / 3600);
1259
- const m = Math.floor(absSeconds % 3600 / 60);
1260
- const s = Math.floor(absSeconds % 60);
1261
- const sign = seconds < 0 ? "-" : "";
1262
- if (h > 0) {
1263
- return `${sign}${h}:${pad(m)}:${pad(s)}`;
1264
- }
1265
- return `${sign}${m}:${pad(s)}`;
1266
- }
1267
- function pad(n) {
1268
- return n < 10 ? `0${n}` : `${n}`;
1269
- }
1270
- function formatLiveTime(behindLive) {
1271
- if (behindLive <= 0) {
1272
- return "LIVE";
1273
- }
1274
- return `-${formatTime(behindLive)}`;
1275
- }
1270
+ import { formatTime, formatLiveTime } from "@scarlett-player/core";
1276
1271
 
1277
1272
  // src/controls/PlayButton.ts
1278
1273
  var PlayButton = class {
@@ -1334,7 +1329,7 @@ var BigPlayButton = class {
1334
1329
  * must not sit on top of it, and `error` state alone does not say (a
1335
1330
  * dismissed overlay leaves the error behind)
1336
1331
  */
1337
- constructor(api, isOverlayVisible = () => false) {
1332
+ constructor(api, isOverlayVisible) {
1338
1333
  /**
1339
1334
  * Latched on the first `playing`.
1340
1335
  *
@@ -1348,7 +1343,8 @@ var BigPlayButton = class {
1348
1343
  this.start();
1349
1344
  };
1350
1345
  this.api = api;
1351
- this.isOverlayVisible = isOverlayVisible;
1346
+ this.hasOverlayProbe = typeof isOverlayVisible === "function";
1347
+ this.isOverlayVisible = isOverlayVisible ?? (() => false);
1352
1348
  const btn = document.createElement("button");
1353
1349
  btn.className = "sp-big-play";
1354
1350
  btn.setAttribute("type", "button");
@@ -1377,7 +1373,7 @@ var BigPlayButton = class {
1377
1373
  this.hasStarted = true;
1378
1374
  }
1379
1375
  let visible;
1380
- if (error || this.isOverlayVisible()) {
1376
+ if (this.hasOverlayProbe ? this.isOverlayVisible() : Boolean(error)) {
1381
1377
  visible = false;
1382
1378
  } else if (playbackState === "loading") {
1383
1379
  visible = false;
@@ -1576,7 +1572,7 @@ var ProgressBar = class {
1576
1572
  video.addEventListener("seeked", resumePlayback);
1577
1573
  }
1578
1574
  }
1579
- this.tooltip.style.opacity = "0";
1575
+ this.tooltip.style.opacity = "";
1580
1576
  this.thumbnailPreview.hide();
1581
1577
  }
1582
1578
  };
@@ -1585,7 +1581,7 @@ var ProgressBar = class {
1585
1581
  };
1586
1582
  this.onMouseLeave = () => {
1587
1583
  if (!this.isDragging) {
1588
- this.tooltip.style.opacity = "0";
1584
+ this.tooltip.style.opacity = "";
1589
1585
  this.thumbnailPreview.hide();
1590
1586
  }
1591
1587
  };
@@ -1803,15 +1799,17 @@ var ProgressBar = class {
1803
1799
  const seekableRange = this.api.getState("seekableRange");
1804
1800
  if (live && seekableRange) {
1805
1801
  const rangeLength = seekableRange.end - seekableRange.start;
1806
- return seekableRange.start + percent * rangeLength;
1802
+ const time2 = seekableRange.start + percent * rangeLength;
1803
+ return Number.isFinite(time2) ? time2 : null;
1807
1804
  }
1808
1805
  const duration = this.api.getState("duration") || 0;
1809
- return percent * duration;
1806
+ const time = percent * duration;
1807
+ return Number.isFinite(time) ? time : null;
1810
1808
  }
1811
1809
  updateTooltip(clientX) {
1812
1810
  const rect = this.el.getBoundingClientRect();
1813
1811
  const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
1814
- const time = this.getTimeFromPosition(clientX);
1812
+ const time = this.getTimeFromPosition(clientX) ?? 0;
1815
1813
  const live = this.api.getState("live");
1816
1814
  const seekableRange = this.api.getState("seekableRange");
1817
1815
  if (live && seekableRange) {
@@ -1846,13 +1844,16 @@ var ProgressBar = class {
1846
1844
  }
1847
1845
  this.lastSeekTime = now;
1848
1846
  const time = this.getTimeFromPosition(clientX);
1849
- video.currentTime = time;
1847
+ if (time !== null && Number.isFinite(time)) {
1848
+ video.currentTime = time;
1849
+ }
1850
1850
  }
1851
1851
  destroy() {
1852
1852
  this.wrapper.removeEventListener("mousedown", this.onMouseDown);
1853
1853
  this.wrapper.removeEventListener("mousemove", this.onMouseMove);
1854
1854
  this.wrapper.removeEventListener("mouseleave", this.onMouseLeave);
1855
1855
  this.wrapper.removeEventListener("touchstart", this.onTouchStart);
1856
+ this.el.removeEventListener("keydown", this.onKeyDown);
1856
1857
  document.removeEventListener("mousemove", this.onDocMouseMove);
1857
1858
  document.removeEventListener("mouseup", this.onMouseUp);
1858
1859
  document.removeEventListener("touchmove", this.onDocTouchMove);
@@ -2077,11 +2078,9 @@ var LiveIndicator = class {
2077
2078
  }
2078
2079
  }
2079
2080
  seekToLive() {
2080
- const video = getVideo(this.api.container);
2081
- if (!video) return;
2082
2081
  const seekableRange = this.api.getState("seekableRange");
2083
2082
  if (seekableRange) {
2084
- video.currentTime = seekableRange.end;
2083
+ this.api.emit("playback:seeking", { time: seekableRange.end });
2085
2084
  }
2086
2085
  }
2087
2086
  destroy() {
@@ -2127,6 +2126,9 @@ var QualityMenu = class {
2127
2126
  const qualities = this.api.getState("qualities") || [];
2128
2127
  const currentQuality = this.api.getState("currentQuality");
2129
2128
  this.el.style.display = qualities.length > 0 ? "" : "none";
2129
+ if (qualities.length === 0 && this.isOpen) {
2130
+ this.close();
2131
+ }
2130
2132
  this.btnLabel.textContent = currentQuality?.label || "Auto";
2131
2133
  const qualitiesJson = JSON.stringify(qualities.map((q) => q.id));
2132
2134
  const currentId = currentQuality?.id || "auto";
@@ -2191,6 +2193,16 @@ var QualityMenu = class {
2191
2193
  this.menu.classList.remove("sp-quality-menu--open");
2192
2194
  this.btn.setAttribute("aria-expanded", "false");
2193
2195
  }
2196
+ /**
2197
+ * Whether the dropdown is open.
2198
+ *
2199
+ * Read by the UI plugin so the control bar's auto-hide waits for it.
2200
+ *
2201
+ * @returns True while the menu is showing
2202
+ */
2203
+ isMenuOpen() {
2204
+ return this.isOpen;
2205
+ }
2194
2206
  destroy() {
2195
2207
  document.removeEventListener("click", this.closeHandler);
2196
2208
  this.el.remove();
@@ -2615,7 +2627,7 @@ var SettingsMenu = class {
2615
2627
  this.el.appendChild(this.panel);
2616
2628
  this.closeHandler = (e) => {
2617
2629
  if (!this.el.contains(e.target)) {
2618
- this.close();
2630
+ this.close(false);
2619
2631
  }
2620
2632
  };
2621
2633
  document.addEventListener("click", this.closeHandler);
@@ -2628,7 +2640,6 @@ var SettingsMenu = class {
2628
2640
  this.showPanel("main");
2629
2641
  } else {
2630
2642
  this.close();
2631
- this.btn.focus();
2632
2643
  }
2633
2644
  return;
2634
2645
  }
@@ -2665,6 +2676,8 @@ var SettingsMenu = class {
2665
2676
  this.updateSpeedActiveStates();
2666
2677
  } else if (this.currentPanel === "captions") {
2667
2678
  this.updateCaptionsActiveStates();
2679
+ } else if (this.currentPanel === "audio") {
2680
+ this.updateAudioActiveStates();
2668
2681
  }
2669
2682
  }
2670
2683
  }
@@ -2679,11 +2692,25 @@ var SettingsMenu = class {
2679
2692
  this.btn.setAttribute("aria-expanded", "true");
2680
2693
  this.focusFirstItem();
2681
2694
  }
2682
- close() {
2695
+ /**
2696
+ * Close the panel.
2697
+ *
2698
+ * Choosing an item tears down the row that had focus, which drops focus to
2699
+ * `<body>` and strands keyboard viewers outside the player entirely. Hand it
2700
+ * back to the button that opened the menu instead.
2701
+ *
2702
+ * @param restoreFocus - Return focus to the gear button. The outside-click
2703
+ * handler passes false, so a click elsewhere is not pulled back into the bar.
2704
+ */
2705
+ close(restoreFocus = true) {
2706
+ const wasOpen = this.isOpen;
2683
2707
  this.isOpen = false;
2684
2708
  this.currentPanel = "main";
2685
2709
  this.panel.classList.remove("sp-settings-panel--open");
2686
2710
  this.btn.setAttribute("aria-expanded", "false");
2711
+ if (wasOpen && restoreFocus) {
2712
+ this.btn.focus();
2713
+ }
2687
2714
  }
2688
2715
  showPanel(panel) {
2689
2716
  this.currentPanel = panel;
@@ -2700,6 +2727,9 @@ var SettingsMenu = class {
2700
2727
  case "captions":
2701
2728
  this.renderCaptionsPanel();
2702
2729
  break;
2730
+ case "audio":
2731
+ this.renderAudioPanel();
2732
+ break;
2703
2733
  }
2704
2734
  this.focusFirstItem();
2705
2735
  }
@@ -2728,6 +2758,16 @@ var SettingsMenu = class {
2728
2758
  );
2729
2759
  this.panel.appendChild(captionsRow);
2730
2760
  }
2761
+ const audioTracks = this.api.getState("audioTracks") || [];
2762
+ if (audioTracks.length > 1) {
2763
+ const currentAudioTrack = this.api.getState("currentAudioTrack");
2764
+ const audioRow = this.createMainRow(
2765
+ "Audio",
2766
+ currentAudioTrack?.label || "Default",
2767
+ () => this.showPanel("audio")
2768
+ );
2769
+ this.panel.appendChild(audioRow);
2770
+ }
2731
2771
  const speedLabel = playbackRate === 1 ? "Normal" : `${playbackRate}x`;
2732
2772
  const speedRow = this.createMainRow(
2733
2773
  "Speed",
@@ -2828,6 +2868,36 @@ var SettingsMenu = class {
2828
2868
  this.panel.appendChild(item);
2829
2869
  }
2830
2870
  }
2871
+ renderAudioPanel() {
2872
+ this.panel.innerHTML = "";
2873
+ this.panel.className = "sp-settings-panel sp-settings-panel--open sp-settings-panel--sub";
2874
+ const header = this.createSubHeader("Audio");
2875
+ this.panel.appendChild(header);
2876
+ const audioTracks = this.api.getState("audioTracks") || [];
2877
+ const currentAudioTrack = this.api.getState("currentAudioTrack");
2878
+ const activeId = currentAudioTrack?.id ?? null;
2879
+ for (const track of audioTracks) {
2880
+ const item = this.createMenuItem(track.label, track.id, track.id === activeId);
2881
+ item.addEventListener("click", (e) => {
2882
+ e.preventDefault();
2883
+ this.selectAudioTrack(track.id);
2884
+ });
2885
+ this.panel.appendChild(item);
2886
+ }
2887
+ }
2888
+ selectAudioTrack(trackId) {
2889
+ this.api.emit("track:audio", { trackId });
2890
+ this.close();
2891
+ }
2892
+ updateAudioActiveStates() {
2893
+ const currentAudioTrack = this.api.getState("currentAudioTrack");
2894
+ const activeId = currentAudioTrack?.id ?? null;
2895
+ const items = this.panel.querySelectorAll(".sp-settings-panel__item");
2896
+ items.forEach((item) => {
2897
+ const id = item.getAttribute("data-id");
2898
+ item.classList.toggle("sp-settings-panel__item--active", id === activeId);
2899
+ });
2900
+ }
2831
2901
  selectCaption(trackId) {
2832
2902
  this.api.emit("track:text", { trackId });
2833
2903
  this.close();
@@ -3217,6 +3287,17 @@ var OverflowTray = class {
3217
3287
  this.panel.classList.remove("sp-overflow-tray--open");
3218
3288
  this.btn.setAttribute("aria-expanded", "false");
3219
3289
  }
3290
+ /**
3291
+ * Whether the strip is open.
3292
+ *
3293
+ * Read by the UI plugin so the control bar's auto-hide waits for it: hiding
3294
+ * the bar would take the open tray with it, mid-use.
3295
+ *
3296
+ * @returns True while the strip is showing
3297
+ */
3298
+ isMenuOpen() {
3299
+ return this.isOpen;
3300
+ }
3220
3301
  /**
3221
3302
  * Show the button only while the tray holds something the viewer can see.
3222
3303
  *
@@ -3256,19 +3337,53 @@ var OverflowTray = class {
3256
3337
  };
3257
3338
 
3258
3339
  // src/control-registry.ts
3259
- var registry = /* @__PURE__ */ new Map();
3340
+ var globalRegistry = /* @__PURE__ */ new Map();
3341
+ var scopedRegistries = /* @__PURE__ */ new Map();
3260
3342
  var listeners = /* @__PURE__ */ new Set();
3261
- function registerControl(id, factory) {
3262
- registry.set(id, factory);
3343
+ function registerControl(id, factory, options = {}) {
3344
+ const { owner } = options;
3345
+ if (owner) {
3346
+ let scoped = scopedRegistries.get(owner);
3347
+ if (!scoped) {
3348
+ scoped = /* @__PURE__ */ new Map();
3349
+ scopedRegistries.set(owner, scoped);
3350
+ }
3351
+ scoped.set(id, factory);
3352
+ } else {
3353
+ globalRegistry.set(id, factory);
3354
+ }
3263
3355
  for (const listener of listeners) {
3264
- listener(id);
3356
+ listener(id, owner);
3265
3357
  }
3266
3358
  }
3267
- function unregisterControl(id) {
3268
- return registry.delete(id);
3359
+ function unregisterControl(id, options = {}) {
3360
+ const { owner } = options;
3361
+ if (!owner) {
3362
+ return globalRegistry.delete(id);
3363
+ }
3364
+ const scoped = scopedRegistries.get(owner);
3365
+ if (!scoped) {
3366
+ return false;
3367
+ }
3368
+ const removed = scoped.delete(id);
3369
+ if (scoped.size === 0) {
3370
+ scopedRegistries.delete(owner);
3371
+ }
3372
+ return removed;
3373
+ }
3374
+ function unregisterControlsFor(owner) {
3375
+ const removed = scopedRegistries.get(owner)?.size ?? 0;
3376
+ scopedRegistries.delete(owner);
3377
+ return removed;
3269
3378
  }
3270
- function getControlFactory(id) {
3271
- return registry.get(id) ?? null;
3379
+ function getControlFactory(id, owner) {
3380
+ if (owner) {
3381
+ const scoped = scopedRegistries.get(owner)?.get(id);
3382
+ if (scoped) {
3383
+ return scoped;
3384
+ }
3385
+ }
3386
+ return globalRegistry.get(id) ?? null;
3272
3387
  }
3273
3388
  function onControlRegistered(listener) {
3274
3389
  listeners.add(listener);
@@ -3277,12 +3392,13 @@ function onControlRegistered(listener) {
3277
3392
  };
3278
3393
  }
3279
3394
  function resetControlRegistry() {
3280
- registry.clear();
3395
+ globalRegistry.clear();
3396
+ scopedRegistries.clear();
3281
3397
  listeners.clear();
3282
3398
  }
3283
3399
 
3284
3400
  // src/version.ts
3285
- var PKG_VERSION = true ? "1.8.1" : "0.0.0-dev";
3401
+ var PKG_VERSION = true ? "1.10.0" : "0.0.0-dev";
3286
3402
 
3287
3403
  // src/index.ts
3288
3404
  var DEFAULT_LAYOUT = [
@@ -3301,12 +3417,25 @@ var DEFAULT_LAYOUT = [
3301
3417
  "pip",
3302
3418
  "fullscreen"
3303
3419
  ];
3420
+ var STYLE_ID = "sp-ui-styles";
3304
3421
  var DEFAULT_HIDE_DELAY = 3e3;
3422
+ var ACTIVATION_KEYS = /* @__PURE__ */ new Set([" ", "Enter"]);
3423
+ var SLIDER_KEYS = /* @__PURE__ */ new Set([
3424
+ "ArrowLeft",
3425
+ "ArrowRight",
3426
+ "ArrowUp",
3427
+ "ArrowDown",
3428
+ "Home",
3429
+ "End",
3430
+ "PageUp",
3431
+ "PageDown"
3432
+ ]);
3305
3433
  var UNMEASURED_CONTROL_WIDTH = 48;
3306
3434
  var FALLBACK_VOLUME_SLIDER_WIDTH = 64;
3307
3435
  var OVERFLOW_BUTTON_WIDTH = 44;
3308
3436
  var FALLBACK_BAR_PADDING_X = 24;
3309
3437
  var FALLBACK_BAR_GAP = 4;
3438
+ var RESIZE_FALLBACK_DEBOUNCE_MS = 100;
3310
3439
  var MENU_HEIGHT_RESERVE = 16;
3311
3440
  var FALLBACK_BAR_HEIGHT = 56;
3312
3441
  var MIN_MENU_HEIGHT = 120;
@@ -3318,7 +3447,7 @@ function uiPlugin(config = {}) {
3318
3447
  let bufferingIndicator = null;
3319
3448
  let errorOverlay = null;
3320
3449
  let bigPlayButton = null;
3321
- let styleEl = null;
3450
+ let releaseStyles = null;
3322
3451
  let controls = [];
3323
3452
  let hideTimeout = null;
3324
3453
  let stateUnsubscribe = null;
@@ -3326,17 +3455,21 @@ function uiPlugin(config = {}) {
3326
3455
  let errorUnsubscribe = null;
3327
3456
  let reconnectingUnsubscribe = null;
3328
3457
  let recoveredUnsubscribe = null;
3458
+ let loadedUnsubscribe = null;
3329
3459
  let controlsVisible = true;
3330
3460
  let rafHandle = null;
3331
3461
  let tray = null;
3332
3462
  let entries = [];
3333
3463
  let timeEntry = null;
3334
3464
  let resizeObserver = null;
3465
+ let windowResizeTimer = null;
3466
+ let onWindowResize = null;
3335
3467
  let barPaddingX = FALLBACK_BAR_PADDING_X;
3336
3468
  let barGap = FALLBACK_BAR_GAP;
3337
3469
  let volumeSliderWidth = FALLBACK_VOLUME_SLIDER_WIDTH;
3338
3470
  let lastFitSignature = null;
3339
3471
  let fitPending = true;
3472
+ let addedContainerClass = false;
3340
3473
  const layout = config.controls || DEFAULT_LAYOUT;
3341
3474
  const hideDelay = config.hideDelay ?? DEFAULT_HIDE_DELAY;
3342
3475
  const showBigPlayButton = config.bigPlayButton !== false;
@@ -3379,7 +3512,7 @@ function uiPlugin(config = {}) {
3379
3512
  case "spacer":
3380
3513
  return new Spacer();
3381
3514
  default: {
3382
- const factory = getControlFactory(slot);
3515
+ const factory = getControlFactory(slot, api.container);
3383
3516
  if (factory) {
3384
3517
  try {
3385
3518
  return factory(api);
@@ -3606,6 +3739,10 @@ function uiPlugin(config = {}) {
3606
3739
  updateControls();
3607
3740
  });
3608
3741
  };
3742
+ const hasOpenMenu = () => controls.some((control) => {
3743
+ const menu = control;
3744
+ return typeof menu.isMenuOpen === "function" && menu.isMenuOpen();
3745
+ });
3609
3746
  const showControls = () => {
3610
3747
  if (controlsVisible) {
3611
3748
  resetHideTimer();
@@ -3622,6 +3759,10 @@ function uiPlugin(config = {}) {
3622
3759
  const hideControls = () => {
3623
3760
  const paused = api?.getState("paused");
3624
3761
  if (paused) return;
3762
+ if (hasOpenMenu()) {
3763
+ resetHideTimer();
3764
+ return;
3765
+ }
3625
3766
  controlsVisible = false;
3626
3767
  controlBar?.classList.remove("sp-controls--visible");
3627
3768
  controlBar?.classList.add("sp-controls--hidden");
@@ -3639,22 +3780,33 @@ function uiPlugin(config = {}) {
3639
3780
  const handlePointerActivity = (event) => {
3640
3781
  last_pointer_type = event.pointerType;
3641
3782
  };
3642
- const handleInteraction = () => {
3643
- if (last_pointer_type === "touch") {
3783
+ const handleInteraction = (event) => {
3784
+ if (last_pointer_type === "touch" && !isOnControls(event)) {
3644
3785
  const gestures = api?.getPlugin("gestures");
3645
3786
  if (gestures?.ownsTapInteraction()) return;
3646
3787
  }
3647
3788
  showControls();
3648
3789
  };
3790
+ const isOnControls = (event) => {
3791
+ const target = event?.target;
3792
+ if (!(target instanceof Node)) return false;
3793
+ return Boolean(controlBar?.contains(target)) || Boolean(progressBar?.render().contains(target));
3794
+ };
3649
3795
  const handleMouseLeave = () => {
3650
3796
  hideControls();
3651
3797
  };
3652
3798
  const handleKeyDown = (e) => {
3653
3799
  if (!api.container.contains(document.activeElement)) return;
3800
+ if (e.defaultPrevented) return;
3801
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
3654
3802
  const activeEl = document.activeElement;
3655
3803
  if (activeEl instanceof HTMLInputElement || activeEl instanceof HTMLTextAreaElement || activeEl instanceof HTMLSelectElement || activeEl?.isContentEditable) {
3656
3804
  return;
3657
3805
  }
3806
+ const role = activeEl?.getAttribute("role");
3807
+ const isActivatable = activeEl instanceof HTMLButtonElement || role === "button" || role === "menuitem";
3808
+ if (isActivatable && ACTIVATION_KEYS.has(e.key)) return;
3809
+ if (role === "slider" && SLIDER_KEYS.has(e.key)) return;
3658
3810
  const video = api.container.querySelector("video");
3659
3811
  if (!video) return;
3660
3812
  const live = api.getState("live");
@@ -3721,9 +3873,7 @@ function uiPlugin(config = {}) {
3721
3873
  version: PKG_VERSION,
3722
3874
  async init(pluginApi) {
3723
3875
  api = pluginApi;
3724
- styleEl = document.createElement("style");
3725
- styleEl.textContent = styles;
3726
- document.head.appendChild(styleEl);
3876
+ releaseStyles = injectSharedStyles(STYLE_ID, styles);
3727
3877
  if (config.theme) {
3728
3878
  this.setTheme(config.theme);
3729
3879
  }
@@ -3736,6 +3886,10 @@ function uiPlugin(config = {}) {
3736
3886
  if (containerStyle.position === "static") {
3737
3887
  container.style.position = "relative";
3738
3888
  }
3889
+ if (!container.classList.contains("sp-container")) {
3890
+ container.classList.add("sp-container");
3891
+ addedContainerClass = true;
3892
+ }
3739
3893
  const isPlaying = api.getState("playing");
3740
3894
  gradient = document.createElement("div");
3741
3895
  gradient.className = isPlaying ? "sp-gradient" : "sp-gradient sp-gradient--visible";
@@ -3751,13 +3905,20 @@ function uiPlugin(config = {}) {
3751
3905
  if (payload?.fatal) {
3752
3906
  const error = api.getState("error") || payload;
3753
3907
  errorOverlay?.show(error);
3908
+ scheduleUpdate();
3754
3909
  }
3755
3910
  });
3756
3911
  reconnectingUnsubscribe = api.on("error:reconnecting", () => {
3757
3912
  errorOverlay?.showReconnecting();
3913
+ scheduleUpdate();
3758
3914
  });
3759
3915
  recoveredUnsubscribe = api.on("error:recovered", () => {
3760
3916
  errorOverlay?.hide();
3917
+ scheduleUpdate();
3918
+ });
3919
+ loadedUnsubscribe = api.on("media:loaded", () => {
3920
+ errorOverlay?.hide();
3921
+ scheduleUpdate();
3761
3922
  });
3762
3923
  if (showBigPlayButton) {
3763
3924
  bigPlayButton = new BigPlayButton(api, () => errorOverlay?.isVisible() ?? false);
@@ -3781,11 +3942,25 @@ function uiPlugin(config = {}) {
3781
3942
  scheduleUpdate();
3782
3943
  });
3783
3944
  resizeObserver.observe(container);
3945
+ } else if (responsive && typeof window !== "undefined") {
3946
+ onWindowResize = () => {
3947
+ if (windowResizeTimer) clearTimeout(windowResizeTimer);
3948
+ windowResizeTimer = setTimeout(() => {
3949
+ windowResizeTimer = null;
3950
+ applyMenuBounds(container.clientHeight);
3951
+ fitPending = true;
3952
+ scheduleUpdate();
3953
+ }, RESIZE_FALLBACK_DEBOUNCE_MS);
3954
+ };
3955
+ window.addEventListener("resize", onWindowResize);
3784
3956
  }
3785
- controlRegistryUnsubscribe = onControlRegistered((id) => {
3957
+ controlRegistryUnsubscribe = onControlRegistered((id, owner) => {
3786
3958
  if (!layout.includes(id)) {
3787
3959
  return;
3788
3960
  }
3961
+ if (owner && owner !== api.container) {
3962
+ return;
3963
+ }
3789
3964
  api.logger.debug(`Control "${id}" registered after init, rebuilding control bar`);
3790
3965
  rebuildControlBar();
3791
3966
  });
@@ -3820,6 +3995,14 @@ function uiPlugin(config = {}) {
3820
3995
  }
3821
3996
  resizeObserver?.disconnect();
3822
3997
  resizeObserver = null;
3998
+ if (onWindowResize) {
3999
+ window.removeEventListener("resize", onWindowResize);
4000
+ onWindowResize = null;
4001
+ }
4002
+ if (windowResizeTimer) {
4003
+ clearTimeout(windowResizeTimer);
4004
+ windowResizeTimer = null;
4005
+ }
3823
4006
  api?.container?.style.removeProperty("--sp-menu-max-height");
3824
4007
  stateUnsubscribe?.();
3825
4008
  stateUnsubscribe = null;
@@ -3829,6 +4012,8 @@ function uiPlugin(config = {}) {
3829
4012
  reconnectingUnsubscribe = null;
3830
4013
  recoveredUnsubscribe?.();
3831
4014
  recoveredUnsubscribe = null;
4015
+ loadedUnsubscribe?.();
4016
+ loadedUnsubscribe = null;
3832
4017
  if (api?.container) {
3833
4018
  api.container.removeEventListener("pointerdown", handlePointerActivity);
3834
4019
  api.container.removeEventListener("pointermove", handlePointerActivity);
@@ -3858,8 +4043,12 @@ function uiPlugin(config = {}) {
3858
4043
  gradient = null;
3859
4044
  bufferingIndicator?.remove();
3860
4045
  bufferingIndicator = null;
3861
- styleEl?.remove();
3862
- styleEl = null;
4046
+ releaseStyles?.();
4047
+ releaseStyles = null;
4048
+ if (addedContainerClass) {
4049
+ api?.container?.classList.remove("sp-container");
4050
+ addedContainerClass = false;
4051
+ }
3863
4052
  api?.logger.debug("UI controls plugin destroyed");
3864
4053
  },
3865
4054
  // Public API
@@ -3912,5 +4101,6 @@ export {
3912
4101
  resolveFitItems,
3913
4102
  styles,
3914
4103
  uiPlugin,
3915
- unregisterControl
4104
+ unregisterControl,
4105
+ unregisterControlsFor
3916
4106
  };