@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/dist/index.js CHANGED
@@ -269,6 +269,37 @@ var styles = `
269
269
  height: 5px;
270
270
  }
271
271
 
272
+ /* ============================================
273
+ Timeline extension layer (timeline-registry.ts)
274
+
275
+ A zero-height line lying exactly on the rail's centre, spanning exactly the
276
+ rail's width, so an extension can position by percentage and land on the
277
+ same pixels the seek slider maps a press to. The 10px offset is the rail
278
+ centre in BOTH wrapper modes: the fine-pointer wrapper is 20px tall with the
279
+ 3px rail centred (8.5..11.5 from the bottom), and the coarse-pointer wrapper
280
+ is 44px tall with 8.5px of bottom padding and align-items: flex-end, which
281
+ puts the rail in the same place.
282
+
283
+ Inert by default: only the explicit hit targets an extension puts inside it
284
+ take pointer input, so an ordinary press on the rail still seeks.
285
+ ============================================ */
286
+ .sp-progress__extension {
287
+ position: absolute;
288
+ left: 0;
289
+ right: 0;
290
+ bottom: 10px;
291
+ height: 0;
292
+ z-index: 3;
293
+ pointer-events: none;
294
+ }
295
+
296
+ /* Editing reserves a lane below the rail for an extension's second handle, by
297
+ lifting the whole wrapper clear of the control bar. The lane above needs no
298
+ reservation - it is over the picture. */
299
+ .sp-progress-wrapper--editing {
300
+ bottom: calc(92px + var(--sp-inset-bottom, 0px));
301
+ }
302
+
272
303
  .sp-progress__track {
273
304
  position: absolute;
274
305
  top: 0;
@@ -339,6 +370,19 @@ var styles = `
339
370
  transform: translate(-50%, -50%) scale(1);
340
371
  }
341
372
 
373
+ /* While an extension owns the pointer the bar must not also look like it is
374
+ scrubbing: the tooltip is suppressed inline by the control, and the handle
375
+ stops responding to hover growth.
376
+
377
+ This has to come after the :hover and --dragging rules AND match their
378
+ specificity, which is why the hover case is spelled out rather than left to
379
+ the bare class: an extension drag is a pointer drag, so the pointer is over
380
+ the wrapper the whole time and the hover rule is always the competing one. */
381
+ .sp-progress-wrapper--ext-dragging .sp-progress__handle,
382
+ .sp-progress-wrapper--ext-dragging:hover .sp-progress__handle {
383
+ transform: translate(-50%, -50%);
384
+ }
385
+
342
386
  /* Thumbnail Preview */
343
387
  .sp-thumbnail-preview {
344
388
  position: absolute;
@@ -1492,9 +1536,46 @@ var ThumbnailPreview = class {
1492
1536
  }
1493
1537
  };
1494
1538
 
1539
+ // src/timeline-registry.ts
1540
+ var registrations = /* @__PURE__ */ new WeakMap();
1541
+ var hosts = /* @__PURE__ */ new WeakMap();
1542
+ function registerTimelineExtension(owner, factory) {
1543
+ const token = {};
1544
+ registrations.set(owner, { factory, token });
1545
+ hosts.get(owner)?.setFactory(factory);
1546
+ return () => {
1547
+ const current = registrations.get(owner);
1548
+ if (!current || current.token !== token) return;
1549
+ registrations.delete(owner);
1550
+ hosts.get(owner)?.setFactory(null);
1551
+ };
1552
+ }
1553
+ function unregisterTimelineExtension(owner) {
1554
+ if (!registrations.has(owner)) return false;
1555
+ registrations.delete(owner);
1556
+ hosts.get(owner)?.setFactory(null);
1557
+ return true;
1558
+ }
1559
+ function hasTimelineExtension(owner) {
1560
+ return registrations.has(owner);
1561
+ }
1562
+ function attachTimelineHost(owner, host) {
1563
+ hosts.set(owner, host);
1564
+ const registration = registrations.get(owner);
1565
+ if (registration) host.setFactory(registration.factory);
1566
+ return () => {
1567
+ if (hosts.get(owner) === host) hosts.delete(owner);
1568
+ };
1569
+ }
1570
+
1495
1571
  // src/controls/ProgressBar.ts
1572
+ var SEEK_KEYS = /* @__PURE__ */ new Set(["ArrowLeft", "ArrowRight", "Home", "End"]);
1496
1573
  var ProgressBar = class {
1497
- constructor(api) {
1574
+ /**
1575
+ * @param api - The per-player plugin API
1576
+ * @param options - Optional hooks; see {@link ProgressBarOptions}
1577
+ */
1578
+ constructor(api, options = {}) {
1498
1579
  this.isDragging = false;
1499
1580
  this.lastSeekTime = 0;
1500
1581
  this.seekThrottleMs = 100;
@@ -1504,8 +1585,18 @@ var ProgressBar = class {
1504
1585
  this.renderedChapters = null;
1505
1586
  /** Duration the marker layer was last built against, since positions are a percentage of it. */
1506
1587
  this.renderedDuration = 0;
1588
+ /** The mounted extension, or null when none is registered. */
1589
+ this.extension = null;
1590
+ /** Detaches this bar from the registry on destroy. */
1591
+ this.detachTimelineHost = null;
1592
+ /** True while the extension holds the pointer: ordinary seeking is suppressed. */
1593
+ this.extensionDragging = false;
1594
+ /** True while the extension is editing: the bar is held open and geometry reserved. */
1595
+ this.extensionEditing = false;
1507
1596
  this.onMouseDown = (e) => {
1597
+ if (this.isExtensionInput(e)) return;
1508
1598
  e.preventDefault();
1599
+ this.extension?.onSeekStart();
1509
1600
  const video = getVideo(this.api.container);
1510
1601
  this.wasPlayingBeforeDrag = video ? !video.paused : false;
1511
1602
  this.isDragging = true;
@@ -1524,6 +1615,7 @@ var ProgressBar = class {
1524
1615
  this.seek(e.clientX, true);
1525
1616
  this.isDragging = false;
1526
1617
  this.el.classList.remove("sp-progress--dragging");
1618
+ this.extension?.onSeekEnd();
1527
1619
  if (this.wasPlayingBeforeDrag) {
1528
1620
  const video = getVideo(this.api.container);
1529
1621
  if (video && video.paused) {
@@ -1538,7 +1630,9 @@ var ProgressBar = class {
1538
1630
  }
1539
1631
  };
1540
1632
  this.onTouchStart = (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;
@@ -1561,6 +1655,7 @@ var ProgressBar = class {
1561
1655
  }
1562
1656
  this.isDragging = false;
1563
1657
  this.el.classList.remove("sp-progress--dragging");
1658
+ this.extension?.onSeekEnd();
1564
1659
  if (this.wasPlayingBeforeDrag) {
1565
1660
  const video = getVideo(this.api.container);
1566
1661
  if (video && video.paused) {
@@ -1577,6 +1672,7 @@ var ProgressBar = class {
1577
1672
  }
1578
1673
  };
1579
1674
  this.onMouseMove = (e) => {
1675
+ if (this.isExtensionInput(e)) return;
1580
1676
  this.updateTooltip(e.clientX);
1581
1677
  };
1582
1678
  this.onMouseLeave = () => {
@@ -1588,51 +1684,16 @@ var ProgressBar = class {
1588
1684
  this.onKeyDown = (e) => {
1589
1685
  const video = getVideo(this.api.container);
1590
1686
  if (!video) return;
1591
- const step = 5;
1592
- const live = this.api.getState("live");
1593
- const seekableRange = this.api.getState("seekableRange");
1594
- if (live && seekableRange) {
1595
- switch (e.key) {
1596
- case "ArrowLeft":
1597
- e.preventDefault();
1598
- video.currentTime = Math.max(seekableRange.start, video.currentTime - step);
1599
- break;
1600
- case "ArrowRight":
1601
- e.preventDefault();
1602
- video.currentTime = Math.min(seekableRange.end, video.currentTime + step);
1603
- break;
1604
- case "Home":
1605
- e.preventDefault();
1606
- video.currentTime = seekableRange.start;
1607
- break;
1608
- case "End":
1609
- e.preventDefault();
1610
- video.currentTime = seekableRange.end;
1611
- break;
1612
- }
1613
- } else {
1614
- const duration = this.api.getState("duration") || 0;
1615
- switch (e.key) {
1616
- case "ArrowLeft":
1617
- e.preventDefault();
1618
- video.currentTime = Math.max(0, video.currentTime - step);
1619
- break;
1620
- case "ArrowRight":
1621
- e.preventDefault();
1622
- video.currentTime = Math.min(duration, video.currentTime + step);
1623
- break;
1624
- case "Home":
1625
- e.preventDefault();
1626
- video.currentTime = 0;
1627
- break;
1628
- case "End":
1629
- e.preventDefault();
1630
- video.currentTime = duration;
1631
- break;
1632
- }
1687
+ if (!SEEK_KEYS.has(e.key)) return;
1688
+ this.extension?.onSeekStart();
1689
+ try {
1690
+ this.applyKeyboardSeek(e, video);
1691
+ } finally {
1692
+ this.extension?.onSeekEnd();
1633
1693
  }
1634
1694
  };
1635
1695
  this.api = api;
1696
+ this.options = options;
1636
1697
  this.wrapper = createElement("div", { className: "sp-progress-wrapper" });
1637
1698
  this.el = createElement("div", { className: "sp-progress" });
1638
1699
  const track = createElement("div", { className: "sp-progress__track" });
@@ -1651,6 +1712,8 @@ var ProgressBar = class {
1651
1712
  this.el.appendChild(this.thumbnailPreview.getElement());
1652
1713
  this.el.appendChild(this.tooltip);
1653
1714
  this.wrapper.appendChild(this.el);
1715
+ this.extensionLayer = createElement("div", { className: "sp-progress__extension" });
1716
+ this.wrapper.appendChild(this.extensionLayer);
1654
1717
  this.el.setAttribute("role", "slider");
1655
1718
  this.el.setAttribute("aria-label", "Seek");
1656
1719
  this.el.setAttribute("aria-valuemin", "0");
@@ -1668,6 +1731,90 @@ var ProgressBar = class {
1668
1731
  document.addEventListener("touchmove", this.onDocTouchMove, { passive: false });
1669
1732
  document.addEventListener("touchend", this.onTouchEnd);
1670
1733
  document.addEventListener("touchcancel", this.onTouchEnd);
1734
+ this.detachTimelineHost = attachTimelineHost(this.api.container, {
1735
+ setFactory: (factory) => this.mountExtension(factory)
1736
+ });
1737
+ }
1738
+ // --------------------------------------------------------------------------
1739
+ // Timeline extension seam
1740
+ // --------------------------------------------------------------------------
1741
+ /**
1742
+ * Mount a registered extension factory, replacing any predecessor.
1743
+ *
1744
+ * The previous extension is destroyed and every lease it held is released
1745
+ * first, so a replacement never inherits a stale editing or dragging state.
1746
+ *
1747
+ * @param factory - The factory to mount, or null to unmount
1748
+ */
1749
+ mountExtension(factory) {
1750
+ if (this.extension) {
1751
+ this.extension.destroy();
1752
+ this.extension = null;
1753
+ this.setExtensionDragging(false);
1754
+ this.setExtensionEditing(false);
1755
+ this.extensionLayer.replaceChildren();
1756
+ }
1757
+ if (!factory) return;
1758
+ const surface = {
1759
+ element: this.extensionLayer,
1760
+ getRailRect: () => this.el.getBoundingClientRect(),
1761
+ setEditing: (active) => this.setExtensionEditing(active),
1762
+ setDragging: (active) => this.setExtensionDragging(active)
1763
+ };
1764
+ this.extension = factory(surface);
1765
+ this.extension.update();
1766
+ }
1767
+ /** Whether a registered extension is currently editing. */
1768
+ isTimelineEditing() {
1769
+ return this.extensionEditing;
1770
+ }
1771
+ /**
1772
+ * Apply the editing lease: reserve the handle lanes and hold the bar open.
1773
+ *
1774
+ * @param active - Whether the extension is editing
1775
+ */
1776
+ setExtensionEditing(active) {
1777
+ if (this.extensionEditing === active) return;
1778
+ this.extensionEditing = active;
1779
+ this.wrapper.classList.toggle("sp-progress-wrapper--editing", active);
1780
+ if (active) this.show();
1781
+ this.options.onEditingChange?.(active);
1782
+ }
1783
+ /**
1784
+ * Apply the pointer lease: suppress ordinary seeking while the extension
1785
+ * owns the gesture, and get the hover tooltip out of the way.
1786
+ *
1787
+ * @param active - Whether the extension owns the pointer
1788
+ */
1789
+ setExtensionDragging(active) {
1790
+ if (this.extensionDragging === active) return;
1791
+ this.extensionDragging = active;
1792
+ this.wrapper.classList.toggle("sp-progress-wrapper--ext-dragging", active);
1793
+ if (active) {
1794
+ this.isDragging = false;
1795
+ this.el.classList.remove("sp-progress--dragging");
1796
+ this.tooltip.style.opacity = "0";
1797
+ this.thumbnailPreview.hide();
1798
+ } else {
1799
+ this.tooltip.style.opacity = "";
1800
+ }
1801
+ }
1802
+ /**
1803
+ * Whether an input event belongs to the extension rather than to seeking.
1804
+ *
1805
+ * Two independent reasons, and both are needed: the extension holds the
1806
+ * pointer lease (its drag has moved off its handle and onto the rail), or
1807
+ * the event started inside the extension layer at all. Touch and mouse
1808
+ * compatibility events replay a handle press as a wrapper press, and only
1809
+ * the target check catches those.
1810
+ *
1811
+ * @param event - The incoming pointer, mouse or touch event
1812
+ * @returns True when the timeline must not act on it
1813
+ */
1814
+ isExtensionInput(event) {
1815
+ if (this.extensionDragging) return true;
1816
+ const target = event.target;
1817
+ return target instanceof Node && this.extensionLayer.contains(target);
1671
1818
  }
1672
1819
  render() {
1673
1820
  return this.wrapper;
@@ -1727,6 +1874,7 @@ var ProgressBar = class {
1727
1874
  this.el.setAttribute("aria-valuenow", String(Math.floor(currentTime)));
1728
1875
  this.el.setAttribute("aria-valuetext", formatTime(currentTime));
1729
1876
  }
1877
+ this.extension?.update();
1730
1878
  }
1731
1879
  /**
1732
1880
  * Label of the chapter containing a point on the timeline.
@@ -1835,6 +1983,57 @@ var ProgressBar = class {
1835
1983
  this.filled.style.width = `${percent * 100}%`;
1836
1984
  this.handle.style.left = `${percent * 100}%`;
1837
1985
  }
1986
+ /**
1987
+ * Apply one keyboard seek to the element.
1988
+ *
1989
+ * @param e - The key event (already known to be a seek key)
1990
+ * @param video - The player's media element
1991
+ */
1992
+ applyKeyboardSeek(e, video) {
1993
+ const step = 5;
1994
+ const live = this.api.getState("live");
1995
+ const seekableRange = this.api.getState("seekableRange");
1996
+ if (live && seekableRange) {
1997
+ switch (e.key) {
1998
+ case "ArrowLeft":
1999
+ e.preventDefault();
2000
+ video.currentTime = Math.max(seekableRange.start, video.currentTime - step);
2001
+ break;
2002
+ case "ArrowRight":
2003
+ e.preventDefault();
2004
+ video.currentTime = Math.min(seekableRange.end, video.currentTime + step);
2005
+ break;
2006
+ case "Home":
2007
+ e.preventDefault();
2008
+ video.currentTime = seekableRange.start;
2009
+ break;
2010
+ case "End":
2011
+ e.preventDefault();
2012
+ video.currentTime = seekableRange.end;
2013
+ break;
2014
+ }
2015
+ } else {
2016
+ const duration = this.api.getState("duration") || 0;
2017
+ switch (e.key) {
2018
+ case "ArrowLeft":
2019
+ e.preventDefault();
2020
+ video.currentTime = Math.max(0, video.currentTime - step);
2021
+ break;
2022
+ case "ArrowRight":
2023
+ e.preventDefault();
2024
+ video.currentTime = Math.min(duration, video.currentTime + step);
2025
+ break;
2026
+ case "Home":
2027
+ e.preventDefault();
2028
+ video.currentTime = 0;
2029
+ break;
2030
+ case "End":
2031
+ e.preventDefault();
2032
+ video.currentTime = duration;
2033
+ break;
2034
+ }
2035
+ }
2036
+ }
1838
2037
  seek(clientX, force = false) {
1839
2038
  const video = getVideo(this.api.container);
1840
2039
  if (!video) return;
@@ -1849,6 +2048,10 @@ var ProgressBar = class {
1849
2048
  }
1850
2049
  }
1851
2050
  destroy() {
2051
+ this.detachTimelineHost?.();
2052
+ this.detachTimelineHost = null;
2053
+ this.extension?.destroy();
2054
+ this.extension = null;
1852
2055
  this.wrapper.removeEventListener("mousedown", this.onMouseDown);
1853
2056
  this.wrapper.removeEventListener("mousemove", this.onMouseMove);
1854
2057
  this.wrapper.removeEventListener("mouseleave", this.onMouseLeave);
@@ -3403,7 +3606,7 @@ function resetControlRegistry() {
3403
3606
  }
3404
3607
 
3405
3608
  // src/version.ts
3406
- var PKG_VERSION = true ? "1.12.0" : "0.0.0-dev";
3609
+ var PKG_VERSION = true ? "1.13.0" : "0.0.0-dev";
3407
3610
 
3408
3611
  // src/index.ts
3409
3612
  var DEFAULT_LAYOUT = [
@@ -3744,10 +3947,13 @@ function uiPlugin(config = {}) {
3744
3947
  updateControls();
3745
3948
  });
3746
3949
  };
3747
- const hasOpenMenu = () => controls.some((control) => {
3748
- const menu = control;
3749
- return typeof menu.isMenuOpen === "function" && menu.isMenuOpen();
3750
- });
3950
+ const hasOpenMenu = () => {
3951
+ if (progressBar?.isTimelineEditing()) return true;
3952
+ return controls.some((control) => {
3953
+ const menu = control;
3954
+ return typeof menu.isMenuOpen === "function" && menu.isMenuOpen();
3955
+ });
3956
+ };
3751
3957
  const showControls = () => {
3752
3958
  if (controlsVisible) {
3753
3959
  resetHideTimer();
@@ -3929,7 +4135,17 @@ function uiPlugin(config = {}) {
3929
4135
  bigPlayButton = new BigPlayButton(api, () => errorOverlay?.isVisible() ?? false);
3930
4136
  container.appendChild(bigPlayButton.render());
3931
4137
  }
3932
- progressBar = new ProgressBar(api);
4138
+ progressBar = new ProgressBar(api, {
4139
+ // A timeline editor is on screen for as long as the viewer needs it,
4140
+ // which is longer than any hide delay. Holding visibility here rather
4141
+ // than through a control's isMenuOpen() is deliberate: an extension
4142
+ // can be opened from a host's own button, in a layout that lists no
4143
+ // matching control at all.
4144
+ onEditingChange: (active) => {
4145
+ if (active) showControls();
4146
+ else resetHideTimer();
4147
+ }
4148
+ });
3933
4149
  container.appendChild(progressBar.render());
3934
4150
  if (!isPlaying) {
3935
4151
  progressBar.show();
@@ -4099,13 +4315,16 @@ export {
4099
4315
  formatLiveTime,
4100
4316
  formatTime,
4101
4317
  getControlFactory,
4318
+ hasTimelineExtension,
4102
4319
  icons,
4103
4320
  planFit,
4104
4321
  registerControl,
4322
+ registerTimelineExtension,
4105
4323
  resetControlRegistry,
4106
4324
  resolveFitItems,
4107
4325
  styles,
4108
4326
  uiPlugin,
4109
4327
  unregisterControl,
4110
- unregisterControlsFor
4328
+ unregisterControlsFor,
4329
+ unregisterTimelineExtension
4111
4330
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scarlett-player/ui",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "UI Controls Plugin for Scarlett Player",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -29,7 +29,7 @@
29
29
  "typescript": "^5.3.0",
30
30
  "vitest": "^1.6.0",
31
31
  "jsdom": "^24.0.0",
32
- "@scarlett-player/core": "1.12.0"
32
+ "@scarlett-player/core": "1.14.0"
33
33
  },
34
34
  "keywords": [
35
35
  "video",