@scarlett-player/ui 1.12.0 → 1.14.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/README.md CHANGED
@@ -126,6 +126,40 @@ Any id a plugin registers through the control registry (`share`, `chapters`,
126
126
  `playlist-previous`, `playlist-next`, ...) can be placed in the same list. A
127
127
  control whose plugin is not loaded is skipped.
128
128
 
129
+ ## Timeline extensions
130
+
131
+ The control registry lets a plugin contribute a *button*. `registerTimelineExtension` lets one contribute an editing layer over the **playback timeline** - the clip plugin's in/out handles are the first, and the reason it exists: an editor with its own miniature track cannot express a 30-second selection on a two-hour source, and makes the viewer pick points on a rail that is not the one they were just scrubbing.
132
+
133
+ ```typescript
134
+ import { registerTimelineExtension } from '@scarlett-player/ui';
135
+
136
+ const release = registerTimelineExtension(api.container, (surface) => {
137
+ surface.element.appendChild(myHandles);
138
+
139
+ return {
140
+ update: () => reposition(surface.getRailRect()),
141
+ onSeekStart: () => suspendMyPreview(),
142
+ onSeekEnd: () => {},
143
+ destroy: () => myHandles.remove(),
144
+ };
145
+ });
146
+ ```
147
+
148
+ `surface` gives an extension four things and nothing else:
149
+
150
+ | Member | What it is |
151
+ |---|---|
152
+ | `element` | The layer to paint into: a sibling of the `role="slider"` seek element (never a child of it), positioned with exactly the rail's horizontal geometry and centred on it. `pointer-events: none`, so plain presses on the rail keep seeking; give your own hit targets `pointer-events: auto` |
153
+ | `getRailRect()` | The rail's box, in client coordinates - the same box the slider maps a press against, so a `clientX`-to-time mapping agrees with the player's own seeking to the pixel |
154
+ | `setEditing(active)` | Holds the control bar and progress bar visible and reserves the vertical room an editor needs around the rail. Independent of any control's `isMenuOpen()`, so an extension opened from a host's own button still holds the bar |
155
+ | `setDragging(active)` | Suppresses ordinary seeking while the extension owns the pointer, and gets the hover tooltip out of the way |
156
+
157
+ The extension gives back `update()` (called with the progress bar's own update, so it never has to observe the rail itself), `onSeekStart()` / `onSeekEnd()` for ordinary seeks it does **not** own - mouse, touch, keyboard and cancellation alike - and `destroy()`.
158
+
159
+ There is one active extension per player, keyed by container. Registering again replaces and destroys the previous one; the returned disposer only ever removes the registration it made, so a late cleanup cannot unmount its successor. Registration works before or after the UI plugin initialises. `unregisterTimelineExtension(container)` drops a player's registration wholesale, and `hasTimelineExtension(container)` reports whether one is present.
160
+
161
+ The UI package reads no plugin-specific state and gains no `IPluginAPI` surface for this: it offers a positioned layer and four lifecycle calls, and an extension paints into it.
162
+
129
163
  ## Keyboard Shortcuts
130
164
 
131
165
  | Key | Action |
package/dist/index.cjs CHANGED
@@ -26,15 +26,18 @@ __export(index_exports, {
26
26
  formatLiveTime: () => import_core2.formatLiveTime,
27
27
  formatTime: () => import_core2.formatTime,
28
28
  getControlFactory: () => getControlFactory,
29
+ hasTimelineExtension: () => hasTimelineExtension,
29
30
  icons: () => icons,
30
31
  planFit: () => planFit,
31
32
  registerControl: () => registerControl,
33
+ registerTimelineExtension: () => registerTimelineExtension,
32
34
  resetControlRegistry: () => resetControlRegistry,
33
35
  resolveFitItems: () => resolveFitItems,
34
36
  styles: () => styles,
35
37
  uiPlugin: () => uiPlugin,
36
38
  unregisterControl: () => unregisterControl,
37
- unregisterControlsFor: () => unregisterControlsFor
39
+ unregisterControlsFor: () => unregisterControlsFor,
40
+ unregisterTimelineExtension: () => unregisterTimelineExtension
38
41
  });
39
42
  module.exports = __toCommonJS(index_exports);
40
43
  var import_core4 = require("@scarlett-player/core");
@@ -302,6 +305,37 @@ var styles = `
302
305
  height: 5px;
303
306
  }
304
307
 
308
+ /* ============================================
309
+ Timeline extension layer (timeline-registry.ts)
310
+
311
+ A zero-height line lying exactly on the rail's centre, spanning exactly the
312
+ rail's width, so an extension can position by percentage and land on the
313
+ same pixels the seek slider maps a press to. The 10px offset is the rail
314
+ centre in BOTH wrapper modes: the fine-pointer wrapper is 20px tall with the
315
+ 3px rail centred (8.5..11.5 from the bottom), and the coarse-pointer wrapper
316
+ is 44px tall with 8.5px of bottom padding and align-items: flex-end, which
317
+ puts the rail in the same place.
318
+
319
+ Inert by default: only the explicit hit targets an extension puts inside it
320
+ take pointer input, so an ordinary press on the rail still seeks.
321
+ ============================================ */
322
+ .sp-progress__extension {
323
+ position: absolute;
324
+ left: 0;
325
+ right: 0;
326
+ bottom: 10px;
327
+ height: 0;
328
+ z-index: 3;
329
+ pointer-events: none;
330
+ }
331
+
332
+ /* Editing reserves a lane below the rail for an extension's second handle, by
333
+ lifting the whole wrapper clear of the control bar. The lane above needs no
334
+ reservation - it is over the picture. */
335
+ .sp-progress-wrapper--editing {
336
+ bottom: calc(92px + var(--sp-inset-bottom, 0px));
337
+ }
338
+
305
339
  .sp-progress__track {
306
340
  position: absolute;
307
341
  top: 0;
@@ -372,6 +406,19 @@ var styles = `
372
406
  transform: translate(-50%, -50%) scale(1);
373
407
  }
374
408
 
409
+ /* While an extension owns the pointer the bar must not also look like it is
410
+ scrubbing: the tooltip is suppressed inline by the control, and the handle
411
+ stops responding to hover growth.
412
+
413
+ This has to come after the :hover and --dragging rules AND match their
414
+ specificity, which is why the hover case is spelled out rather than left to
415
+ the bare class: an extension drag is a pointer drag, so the pointer is over
416
+ the wrapper the whole time and the hover rule is always the competing one. */
417
+ .sp-progress-wrapper--ext-dragging .sp-progress__handle,
418
+ .sp-progress-wrapper--ext-dragging:hover .sp-progress__handle {
419
+ transform: translate(-50%, -50%);
420
+ }
421
+
375
422
  /* Thumbnail Preview */
376
423
  .sp-thumbnail-preview {
377
424
  position: absolute;
@@ -1525,9 +1572,46 @@ var ThumbnailPreview = class {
1525
1572
  }
1526
1573
  };
1527
1574
 
1575
+ // src/timeline-registry.ts
1576
+ var registrations = /* @__PURE__ */ new WeakMap();
1577
+ var hosts = /* @__PURE__ */ new WeakMap();
1578
+ function registerTimelineExtension(owner, factory) {
1579
+ const token = {};
1580
+ registrations.set(owner, { factory, token });
1581
+ hosts.get(owner)?.setFactory(factory);
1582
+ return () => {
1583
+ const current = registrations.get(owner);
1584
+ if (!current || current.token !== token) return;
1585
+ registrations.delete(owner);
1586
+ hosts.get(owner)?.setFactory(null);
1587
+ };
1588
+ }
1589
+ function unregisterTimelineExtension(owner) {
1590
+ if (!registrations.has(owner)) return false;
1591
+ registrations.delete(owner);
1592
+ hosts.get(owner)?.setFactory(null);
1593
+ return true;
1594
+ }
1595
+ function hasTimelineExtension(owner) {
1596
+ return registrations.has(owner);
1597
+ }
1598
+ function attachTimelineHost(owner, host) {
1599
+ hosts.set(owner, host);
1600
+ const registration = registrations.get(owner);
1601
+ if (registration) host.setFactory(registration.factory);
1602
+ return () => {
1603
+ if (hosts.get(owner) === host) hosts.delete(owner);
1604
+ };
1605
+ }
1606
+
1528
1607
  // src/controls/ProgressBar.ts
1608
+ var SEEK_KEYS = /* @__PURE__ */ new Set(["ArrowLeft", "ArrowRight", "Home", "End"]);
1529
1609
  var ProgressBar = class {
1530
- constructor(api) {
1610
+ /**
1611
+ * @param api - The per-player plugin API
1612
+ * @param options - Optional hooks; see {@link ProgressBarOptions}
1613
+ */
1614
+ constructor(api, options = {}) {
1531
1615
  this.isDragging = false;
1532
1616
  this.lastSeekTime = 0;
1533
1617
  this.seekThrottleMs = 100;
@@ -1537,8 +1621,18 @@ var ProgressBar = class {
1537
1621
  this.renderedChapters = null;
1538
1622
  /** Duration the marker layer was last built against, since positions are a percentage of it. */
1539
1623
  this.renderedDuration = 0;
1624
+ /** The mounted extension, or null when none is registered. */
1625
+ this.extension = null;
1626
+ /** Detaches this bar from the registry on destroy. */
1627
+ this.detachTimelineHost = null;
1628
+ /** True while the extension holds the pointer: ordinary seeking is suppressed. */
1629
+ this.extensionDragging = false;
1630
+ /** True while the extension is editing: the bar is held open and geometry reserved. */
1631
+ this.extensionEditing = false;
1540
1632
  this.onMouseDown = (e) => {
1633
+ if (this.isExtensionInput(e)) return;
1541
1634
  e.preventDefault();
1635
+ this.extension?.onSeekStart();
1542
1636
  const video = getVideo(this.api.container);
1543
1637
  this.wasPlayingBeforeDrag = video ? !video.paused : false;
1544
1638
  this.isDragging = true;
@@ -1557,6 +1651,7 @@ var ProgressBar = class {
1557
1651
  this.seek(e.clientX, true);
1558
1652
  this.isDragging = false;
1559
1653
  this.el.classList.remove("sp-progress--dragging");
1654
+ this.extension?.onSeekEnd();
1560
1655
  if (this.wasPlayingBeforeDrag) {
1561
1656
  const video = getVideo(this.api.container);
1562
1657
  if (video && video.paused) {
@@ -1571,7 +1666,9 @@ var ProgressBar = class {
1571
1666
  }
1572
1667
  };
1573
1668
  this.onTouchStart = (e) => {
1669
+ if (this.isExtensionInput(e)) return;
1574
1670
  e.preventDefault();
1671
+ this.extension?.onSeekStart();
1575
1672
  const video = getVideo(this.api.container);
1576
1673
  this.wasPlayingBeforeDrag = video ? !video.paused : false;
1577
1674
  this.isDragging = true;
@@ -1594,6 +1691,7 @@ var ProgressBar = class {
1594
1691
  }
1595
1692
  this.isDragging = false;
1596
1693
  this.el.classList.remove("sp-progress--dragging");
1694
+ this.extension?.onSeekEnd();
1597
1695
  if (this.wasPlayingBeforeDrag) {
1598
1696
  const video = getVideo(this.api.container);
1599
1697
  if (video && video.paused) {
@@ -1610,6 +1708,7 @@ var ProgressBar = class {
1610
1708
  }
1611
1709
  };
1612
1710
  this.onMouseMove = (e) => {
1711
+ if (this.isExtensionInput(e)) return;
1613
1712
  this.updateTooltip(e.clientX);
1614
1713
  };
1615
1714
  this.onMouseLeave = () => {
@@ -1621,51 +1720,16 @@ var ProgressBar = class {
1621
1720
  this.onKeyDown = (e) => {
1622
1721
  const video = getVideo(this.api.container);
1623
1722
  if (!video) return;
1624
- const step = 5;
1625
- const live = this.api.getState("live");
1626
- const seekableRange = this.api.getState("seekableRange");
1627
- if (live && seekableRange) {
1628
- switch (e.key) {
1629
- case "ArrowLeft":
1630
- e.preventDefault();
1631
- video.currentTime = Math.max(seekableRange.start, video.currentTime - step);
1632
- break;
1633
- case "ArrowRight":
1634
- e.preventDefault();
1635
- video.currentTime = Math.min(seekableRange.end, video.currentTime + step);
1636
- break;
1637
- case "Home":
1638
- e.preventDefault();
1639
- video.currentTime = seekableRange.start;
1640
- break;
1641
- case "End":
1642
- e.preventDefault();
1643
- video.currentTime = seekableRange.end;
1644
- break;
1645
- }
1646
- } else {
1647
- const duration = this.api.getState("duration") || 0;
1648
- switch (e.key) {
1649
- case "ArrowLeft":
1650
- e.preventDefault();
1651
- video.currentTime = Math.max(0, video.currentTime - step);
1652
- break;
1653
- case "ArrowRight":
1654
- e.preventDefault();
1655
- video.currentTime = Math.min(duration, video.currentTime + step);
1656
- break;
1657
- case "Home":
1658
- e.preventDefault();
1659
- video.currentTime = 0;
1660
- break;
1661
- case "End":
1662
- e.preventDefault();
1663
- video.currentTime = duration;
1664
- break;
1665
- }
1723
+ if (!SEEK_KEYS.has(e.key)) return;
1724
+ this.extension?.onSeekStart();
1725
+ try {
1726
+ this.applyKeyboardSeek(e, video);
1727
+ } finally {
1728
+ this.extension?.onSeekEnd();
1666
1729
  }
1667
1730
  };
1668
1731
  this.api = api;
1732
+ this.options = options;
1669
1733
  this.wrapper = createElement("div", { className: "sp-progress-wrapper" });
1670
1734
  this.el = createElement("div", { className: "sp-progress" });
1671
1735
  const track = createElement("div", { className: "sp-progress__track" });
@@ -1684,6 +1748,8 @@ var ProgressBar = class {
1684
1748
  this.el.appendChild(this.thumbnailPreview.getElement());
1685
1749
  this.el.appendChild(this.tooltip);
1686
1750
  this.wrapper.appendChild(this.el);
1751
+ this.extensionLayer = createElement("div", { className: "sp-progress__extension" });
1752
+ this.wrapper.appendChild(this.extensionLayer);
1687
1753
  this.el.setAttribute("role", "slider");
1688
1754
  this.el.setAttribute("aria-label", "Seek");
1689
1755
  this.el.setAttribute("aria-valuemin", "0");
@@ -1701,6 +1767,90 @@ var ProgressBar = class {
1701
1767
  document.addEventListener("touchmove", this.onDocTouchMove, { passive: false });
1702
1768
  document.addEventListener("touchend", this.onTouchEnd);
1703
1769
  document.addEventListener("touchcancel", this.onTouchEnd);
1770
+ this.detachTimelineHost = attachTimelineHost(this.api.container, {
1771
+ setFactory: (factory) => this.mountExtension(factory)
1772
+ });
1773
+ }
1774
+ // --------------------------------------------------------------------------
1775
+ // Timeline extension seam
1776
+ // --------------------------------------------------------------------------
1777
+ /**
1778
+ * Mount a registered extension factory, replacing any predecessor.
1779
+ *
1780
+ * The previous extension is destroyed and every lease it held is released
1781
+ * first, so a replacement never inherits a stale editing or dragging state.
1782
+ *
1783
+ * @param factory - The factory to mount, or null to unmount
1784
+ */
1785
+ mountExtension(factory) {
1786
+ if (this.extension) {
1787
+ this.extension.destroy();
1788
+ this.extension = null;
1789
+ this.setExtensionDragging(false);
1790
+ this.setExtensionEditing(false);
1791
+ this.extensionLayer.replaceChildren();
1792
+ }
1793
+ if (!factory) return;
1794
+ const surface = {
1795
+ element: this.extensionLayer,
1796
+ getRailRect: () => this.el.getBoundingClientRect(),
1797
+ setEditing: (active) => this.setExtensionEditing(active),
1798
+ setDragging: (active) => this.setExtensionDragging(active)
1799
+ };
1800
+ this.extension = factory(surface);
1801
+ this.extension.update();
1802
+ }
1803
+ /** Whether a registered extension is currently editing. */
1804
+ isTimelineEditing() {
1805
+ return this.extensionEditing;
1806
+ }
1807
+ /**
1808
+ * Apply the editing lease: reserve the handle lanes and hold the bar open.
1809
+ *
1810
+ * @param active - Whether the extension is editing
1811
+ */
1812
+ setExtensionEditing(active) {
1813
+ if (this.extensionEditing === active) return;
1814
+ this.extensionEditing = active;
1815
+ this.wrapper.classList.toggle("sp-progress-wrapper--editing", active);
1816
+ if (active) this.show();
1817
+ this.options.onEditingChange?.(active);
1818
+ }
1819
+ /**
1820
+ * Apply the pointer lease: suppress ordinary seeking while the extension
1821
+ * owns the gesture, and get the hover tooltip out of the way.
1822
+ *
1823
+ * @param active - Whether the extension owns the pointer
1824
+ */
1825
+ setExtensionDragging(active) {
1826
+ if (this.extensionDragging === active) return;
1827
+ this.extensionDragging = active;
1828
+ this.wrapper.classList.toggle("sp-progress-wrapper--ext-dragging", active);
1829
+ if (active) {
1830
+ this.isDragging = false;
1831
+ this.el.classList.remove("sp-progress--dragging");
1832
+ this.tooltip.style.opacity = "0";
1833
+ this.thumbnailPreview.hide();
1834
+ } else {
1835
+ this.tooltip.style.opacity = "";
1836
+ }
1837
+ }
1838
+ /**
1839
+ * Whether an input event belongs to the extension rather than to seeking.
1840
+ *
1841
+ * Two independent reasons, and both are needed: the extension holds the
1842
+ * pointer lease (its drag has moved off its handle and onto the rail), or
1843
+ * the event started inside the extension layer at all. Touch and mouse
1844
+ * compatibility events replay a handle press as a wrapper press, and only
1845
+ * the target check catches those.
1846
+ *
1847
+ * @param event - The incoming pointer, mouse or touch event
1848
+ * @returns True when the timeline must not act on it
1849
+ */
1850
+ isExtensionInput(event) {
1851
+ if (this.extensionDragging) return true;
1852
+ const target = event.target;
1853
+ return target instanceof Node && this.extensionLayer.contains(target);
1704
1854
  }
1705
1855
  render() {
1706
1856
  return this.wrapper;
@@ -1760,6 +1910,7 @@ var ProgressBar = class {
1760
1910
  this.el.setAttribute("aria-valuenow", String(Math.floor(currentTime)));
1761
1911
  this.el.setAttribute("aria-valuetext", (0, import_core2.formatTime)(currentTime));
1762
1912
  }
1913
+ this.extension?.update();
1763
1914
  }
1764
1915
  /**
1765
1916
  * Label of the chapter containing a point on the timeline.
@@ -1868,6 +2019,57 @@ var ProgressBar = class {
1868
2019
  this.filled.style.width = `${percent * 100}%`;
1869
2020
  this.handle.style.left = `${percent * 100}%`;
1870
2021
  }
2022
+ /**
2023
+ * Apply one keyboard seek to the element.
2024
+ *
2025
+ * @param e - The key event (already known to be a seek key)
2026
+ * @param video - The player's media element
2027
+ */
2028
+ applyKeyboardSeek(e, video) {
2029
+ const step = 5;
2030
+ const live = this.api.getState("live");
2031
+ const seekableRange = this.api.getState("seekableRange");
2032
+ if (live && seekableRange) {
2033
+ switch (e.key) {
2034
+ case "ArrowLeft":
2035
+ e.preventDefault();
2036
+ video.currentTime = Math.max(seekableRange.start, video.currentTime - step);
2037
+ break;
2038
+ case "ArrowRight":
2039
+ e.preventDefault();
2040
+ video.currentTime = Math.min(seekableRange.end, video.currentTime + step);
2041
+ break;
2042
+ case "Home":
2043
+ e.preventDefault();
2044
+ video.currentTime = seekableRange.start;
2045
+ break;
2046
+ case "End":
2047
+ e.preventDefault();
2048
+ video.currentTime = seekableRange.end;
2049
+ break;
2050
+ }
2051
+ } else {
2052
+ const duration = this.api.getState("duration") || 0;
2053
+ switch (e.key) {
2054
+ case "ArrowLeft":
2055
+ e.preventDefault();
2056
+ video.currentTime = Math.max(0, video.currentTime - step);
2057
+ break;
2058
+ case "ArrowRight":
2059
+ e.preventDefault();
2060
+ video.currentTime = Math.min(duration, video.currentTime + step);
2061
+ break;
2062
+ case "Home":
2063
+ e.preventDefault();
2064
+ video.currentTime = 0;
2065
+ break;
2066
+ case "End":
2067
+ e.preventDefault();
2068
+ video.currentTime = duration;
2069
+ break;
2070
+ }
2071
+ }
2072
+ }
1871
2073
  seek(clientX, force = false) {
1872
2074
  const video = getVideo(this.api.container);
1873
2075
  if (!video) return;
@@ -1882,6 +2084,10 @@ var ProgressBar = class {
1882
2084
  }
1883
2085
  }
1884
2086
  destroy() {
2087
+ this.detachTimelineHost?.();
2088
+ this.detachTimelineHost = null;
2089
+ this.extension?.destroy();
2090
+ this.extension = null;
1885
2091
  this.wrapper.removeEventListener("mousedown", this.onMouseDown);
1886
2092
  this.wrapper.removeEventListener("mousemove", this.onMouseMove);
1887
2093
  this.wrapper.removeEventListener("mouseleave", this.onMouseLeave);
@@ -3436,7 +3642,7 @@ function resetControlRegistry() {
3436
3642
  }
3437
3643
 
3438
3644
  // src/version.ts
3439
- var PKG_VERSION = true ? "1.12.0" : "0.0.0-dev";
3645
+ var PKG_VERSION = true ? "1.13.0" : "0.0.0-dev";
3440
3646
 
3441
3647
  // src/index.ts
3442
3648
  var DEFAULT_LAYOUT = [
@@ -3777,10 +3983,13 @@ function uiPlugin(config = {}) {
3777
3983
  updateControls();
3778
3984
  });
3779
3985
  };
3780
- const hasOpenMenu = () => controls.some((control) => {
3781
- const menu = control;
3782
- return typeof menu.isMenuOpen === "function" && menu.isMenuOpen();
3783
- });
3986
+ const hasOpenMenu = () => {
3987
+ if (progressBar?.isTimelineEditing()) return true;
3988
+ return controls.some((control) => {
3989
+ const menu = control;
3990
+ return typeof menu.isMenuOpen === "function" && menu.isMenuOpen();
3991
+ });
3992
+ };
3784
3993
  const showControls = () => {
3785
3994
  if (controlsVisible) {
3786
3995
  resetHideTimer();
@@ -3962,7 +4171,17 @@ function uiPlugin(config = {}) {
3962
4171
  bigPlayButton = new BigPlayButton(api, () => errorOverlay?.isVisible() ?? false);
3963
4172
  container.appendChild(bigPlayButton.render());
3964
4173
  }
3965
- progressBar = new ProgressBar(api);
4174
+ progressBar = new ProgressBar(api, {
4175
+ // A timeline editor is on screen for as long as the viewer needs it,
4176
+ // which is longer than any hide delay. Holding visibility here rather
4177
+ // than through a control's isMenuOpen() is deliberate: an extension
4178
+ // can be opened from a host's own button, in a layout that lists no
4179
+ // matching control at all.
4180
+ onEditingChange: (active) => {
4181
+ if (active) showControls();
4182
+ else resetHideTimer();
4183
+ }
4184
+ });
3966
4185
  container.appendChild(progressBar.render());
3967
4186
  if (!isPlaying) {
3968
4187
  progressBar.show();
@@ -4132,13 +4351,16 @@ var index_default = uiPlugin;
4132
4351
  formatLiveTime,
4133
4352
  formatTime,
4134
4353
  getControlFactory,
4354
+ hasTimelineExtension,
4135
4355
  icons,
4136
4356
  planFit,
4137
4357
  registerControl,
4358
+ registerTimelineExtension,
4138
4359
  resetControlRegistry,
4139
4360
  resolveFitItems,
4140
4361
  styles,
4141
4362
  uiPlugin,
4142
4363
  unregisterControl,
4143
- unregisterControlsFor
4364
+ unregisterControlsFor,
4365
+ unregisterTimelineExtension
4144
4366
  });