@scarlett-player/ui 1.1.1 → 1.4.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
@@ -30,9 +30,14 @@ const player = new ScarlettPlayer({
30
30
 
31
31
  - Play/pause, seek, volume controls
32
32
  - Fullscreen toggle
33
+ - Picture-in-Picture toggle (disabled until media metadata is loaded; hidden
34
+ when the browser has no PiP support; Safari webkit presentation mode
35
+ supported)
33
36
  - Quality selector
34
37
  - Progress bar with buffering indicator
35
38
  - Time display (current / duration)
39
+ - Error overlay with viewer-friendly copy per error code, a Try Again action,
40
+ and a reconnecting state while the player self-heals
36
41
  - Keyboard shortcuts
37
42
  - Customizable theming
38
43
  - Auto-hide controls
package/dist/index.cjs CHANGED
@@ -23,9 +23,13 @@ __export(index_exports, {
23
23
  default: () => index_default,
24
24
  formatLiveTime: () => formatLiveTime,
25
25
  formatTime: () => formatTime,
26
+ getControlFactory: () => getControlFactory,
26
27
  icons: () => icons,
28
+ registerControl: () => registerControl,
29
+ resetControlRegistry: () => resetControlRegistry,
27
30
  styles: () => styles,
28
- uiPlugin: () => uiPlugin
31
+ uiPlugin: () => uiPlugin,
32
+ unregisterControl: () => unregisterControl
29
33
  });
30
34
  module.exports = __toCommonJS(index_exports);
31
35
 
@@ -1802,25 +1806,38 @@ var CastButton = class {
1802
1806
  var PipButton = class {
1803
1807
  constructor(api) {
1804
1808
  this.clickHandler = () => {
1805
- this.toggle();
1809
+ void this.toggle().catch(() => {
1810
+ });
1806
1811
  };
1807
1812
  this.api = api;
1808
- const video = document.createElement("video");
1809
- this.supported = "pictureInPictureEnabled" in document || "webkitSetPresentationMode" in video;
1813
+ const probe = document.createElement("video");
1814
+ this.supported = "pictureInPictureEnabled" in document || "webkitSetPresentationMode" in probe;
1810
1815
  this.el = createButton("sp-pip", "Picture-in-Picture", icons.pip);
1811
1816
  this.el.addEventListener("click", this.clickHandler);
1812
1817
  if (!this.supported) {
1813
1818
  this.el.style.display = "none";
1819
+ } else {
1820
+ this.el.disabled = true;
1821
+ this.el.setAttribute("aria-disabled", "true");
1814
1822
  }
1815
1823
  }
1816
1824
  render() {
1817
1825
  return this.el;
1818
1826
  }
1827
+ /** Whether the media element is ready to enter PiP (metadata loaded). */
1828
+ isMediaReady() {
1829
+ const video = getVideo(this.api.container);
1830
+ return !!video && video.readyState >= HTMLMediaElement.HAVE_METADATA;
1831
+ }
1819
1832
  update() {
1820
1833
  if (!this.supported) return;
1821
- const pip = this.api.getState("pip");
1822
- this.el.setAttribute("aria-label", pip ? "Exit Picture-in-Picture" : "Picture-in-Picture");
1823
- this.el.classList.toggle("sp-pip--active", !!pip);
1834
+ const pip = !!this.api.getState("pip");
1835
+ const enabled = pip || this.isMediaReady();
1836
+ this.el.disabled = !enabled;
1837
+ setAttr(this.el, "aria-disabled", String(!enabled));
1838
+ setHTML(this.el, pip ? icons.exitPip : icons.pip);
1839
+ setAttr(this.el, "aria-label", pip ? "Exit Picture-in-Picture" : "Picture-in-Picture");
1840
+ this.el.classList.toggle("sp-pip--active", pip);
1824
1841
  }
1825
1842
  async toggle() {
1826
1843
  const video = getVideo(this.api.container);
@@ -1828,8 +1845,14 @@ var PipButton = class {
1828
1845
  this.api.logger.warn("PiP: video element not found");
1829
1846
  return;
1830
1847
  }
1848
+ const isInPip = document.pictureInPictureElement === video || video.webkitPresentationMode === "picture-in-picture";
1849
+ if (!isInPip && video.readyState < HTMLMediaElement.HAVE_METADATA) {
1850
+ this.api.logger.debug("PiP: ignored, media not ready", {
1851
+ readyState: video.readyState
1852
+ });
1853
+ return;
1854
+ }
1831
1855
  try {
1832
- const isInPip = document.pictureInPictureElement === video || video.webkitPresentationMode === "picture-in-picture";
1833
1856
  if (isInPip) {
1834
1857
  if (document.pictureInPictureElement) {
1835
1858
  await document.exitPictureInPicture();
@@ -1846,7 +1869,8 @@ var PipButton = class {
1846
1869
  this.api.logger.debug("PiP: entered");
1847
1870
  }
1848
1871
  } catch (e) {
1849
- this.api.logger.warn("PiP: failed", { error: e.message });
1872
+ const message = e instanceof Error ? e.message : String(e);
1873
+ this.api.logger.warn("PiP: failed", { error: message });
1850
1874
  }
1851
1875
  }
1852
1876
  destroy() {
@@ -1929,6 +1953,12 @@ function getUserMessage(error) {
1929
1953
  return "Unable to load video. Please try again.";
1930
1954
  case "PLAYBACK_FAILED":
1931
1955
  return "Playback stopped unexpectedly. Please try again.";
1956
+ case "MEDIA_APPEND_ERROR":
1957
+ return "Video playback was interrupted. Please try again.";
1958
+ case "MEDIA_BUFFER_FULL":
1959
+ return "Your device is low on video memory. Close other apps or tabs and try again.";
1960
+ case "PLAYLIST_INVALID":
1961
+ return "The stream is temporarily unavailable. Please try again.";
1932
1962
  }
1933
1963
  }
1934
1964
  const msg = error.message?.toLowerCase() || "";
@@ -2589,6 +2619,32 @@ var BandwidthIndicator = class {
2589
2619
  }
2590
2620
  };
2591
2621
 
2622
+ // src/control-registry.ts
2623
+ var registry = /* @__PURE__ */ new Map();
2624
+ var listeners = /* @__PURE__ */ new Set();
2625
+ function registerControl(id, factory) {
2626
+ registry.set(id, factory);
2627
+ for (const listener of listeners) {
2628
+ listener(id);
2629
+ }
2630
+ }
2631
+ function unregisterControl(id) {
2632
+ return registry.delete(id);
2633
+ }
2634
+ function getControlFactory(id) {
2635
+ return registry.get(id) ?? null;
2636
+ }
2637
+ function onControlRegistered(listener) {
2638
+ listeners.add(listener);
2639
+ return () => {
2640
+ listeners.delete(listener);
2641
+ };
2642
+ }
2643
+ function resetControlRegistry() {
2644
+ registry.clear();
2645
+ listeners.clear();
2646
+ }
2647
+
2592
2648
  // src/index.ts
2593
2649
  var DEFAULT_LAYOUT = [
2594
2650
  "play",
@@ -2618,6 +2674,7 @@ function uiPlugin(config = {}) {
2618
2674
  let controls = [];
2619
2675
  let hideTimeout = null;
2620
2676
  let stateUnsubscribe = null;
2677
+ let controlRegistryUnsubscribe = null;
2621
2678
  let errorUnsubscribe = null;
2622
2679
  let reconnectingUnsubscribe = null;
2623
2680
  let recoveredUnsubscribe = null;
@@ -2659,9 +2716,42 @@ function uiPlugin(config = {}) {
2659
2716
  return new FullscreenButton(api);
2660
2717
  case "spacer":
2661
2718
  return new Spacer();
2662
- default:
2719
+ default: {
2720
+ const factory = getControlFactory(slot);
2721
+ if (factory) {
2722
+ try {
2723
+ return factory(api);
2724
+ } catch (error) {
2725
+ api.logger.error(`Control factory for "${slot}" threw`, { error });
2726
+ return null;
2727
+ }
2728
+ }
2729
+ api.logger.warn(`Unknown control slot: ${slot}`);
2663
2730
  return null;
2731
+ }
2732
+ }
2733
+ };
2734
+ const populateControlBar = () => {
2735
+ if (!controlBar) {
2736
+ return;
2737
+ }
2738
+ for (const slot of layout) {
2739
+ const control = createControl(slot);
2740
+ if (control) {
2741
+ controls.push(control);
2742
+ controlBar.appendChild(control.render());
2743
+ }
2744
+ }
2745
+ };
2746
+ const rebuildControlBar = () => {
2747
+ if (!controlBar) {
2748
+ return;
2664
2749
  }
2750
+ controls.forEach((c) => c.destroy());
2751
+ controls = [];
2752
+ controlBar.replaceChildren();
2753
+ populateControlBar();
2754
+ updateControls();
2665
2755
  };
2666
2756
  const updateControls = () => {
2667
2757
  controls.forEach((c) => c.update());
@@ -2730,7 +2820,12 @@ function uiPlugin(config = {}) {
2730
2820
  case " ":
2731
2821
  case "k":
2732
2822
  e.preventDefault();
2733
- video.paused ? video.play() : video.pause();
2823
+ if (video.paused) {
2824
+ video.play().catch(() => {
2825
+ });
2826
+ } else {
2827
+ video.pause();
2828
+ }
2734
2829
  break;
2735
2830
  case "m":
2736
2831
  e.preventDefault();
@@ -2739,9 +2834,11 @@ function uiPlugin(config = {}) {
2739
2834
  case "f":
2740
2835
  e.preventDefault();
2741
2836
  if (document.fullscreenElement) {
2742
- document.exitFullscreen();
2837
+ document.exitFullscreen().catch(() => {
2838
+ });
2743
2839
  } else {
2744
- api.container.requestFullscreen?.();
2840
+ api.container.requestFullscreen?.().catch(() => {
2841
+ });
2745
2842
  }
2746
2843
  break;
2747
2844
  case "ArrowLeft":
@@ -2828,14 +2925,15 @@ function uiPlugin(config = {}) {
2828
2925
  controlBar.className = isPlaying ? "sp-controls sp-controls--hidden" : "sp-controls sp-controls--visible";
2829
2926
  controlBar.setAttribute("role", "toolbar");
2830
2927
  controlBar.setAttribute("aria-label", "Video controls");
2831
- for (const slot of layout) {
2832
- const control = createControl(slot);
2833
- if (control) {
2834
- controls.push(control);
2835
- controlBar.appendChild(control.render());
2836
- }
2837
- }
2928
+ populateControlBar();
2838
2929
  container.appendChild(controlBar);
2930
+ controlRegistryUnsubscribe = onControlRegistered((id) => {
2931
+ if (!layout.includes(id)) {
2932
+ return;
2933
+ }
2934
+ api.logger.debug(`Control "${id}" registered after init, rebuilding control bar`);
2935
+ rebuildControlBar();
2936
+ });
2839
2937
  container.addEventListener("mousemove", handleInteraction);
2840
2938
  container.addEventListener("mouseenter", handleInteraction);
2841
2939
  container.addEventListener("mouseleave", handleMouseLeave);
@@ -2881,6 +2979,8 @@ function uiPlugin(config = {}) {
2881
2979
  }
2882
2980
  document.removeEventListener("keydown", handleKeyDown);
2883
2981
  document.removeEventListener("fullscreenchange", scheduleUpdate);
2982
+ controlRegistryUnsubscribe?.();
2983
+ controlRegistryUnsubscribe = null;
2884
2984
  controls.forEach((c) => c.destroy());
2885
2985
  controls = [];
2886
2986
  progressBar?.destroy();
@@ -2937,7 +3037,11 @@ var index_default = uiPlugin;
2937
3037
  0 && (module.exports = {
2938
3038
  formatLiveTime,
2939
3039
  formatTime,
3040
+ getControlFactory,
2940
3041
  icons,
3042
+ registerControl,
3043
+ resetControlRegistry,
2941
3044
  styles,
2942
- uiPlugin
3045
+ uiPlugin,
3046
+ unregisterControl
2943
3047
  });
package/dist/index.d.cts CHANGED
@@ -1,13 +1,26 @@
1
- import { Plugin } from '@scarlett-player/core';
1
+ import { Plugin, IPluginAPI } from '@scarlett-player/core';
2
2
 
3
3
  /**
4
4
  * UI Controls Plugin Types
5
5
  */
6
6
 
7
7
  /**
8
- * Available control slot identifiers.
8
+ * Control slots this package implements itself.
9
9
  */
10
- type ControlSlot = 'play' | 'skip-backward' | 'skip-forward' | 'volume' | 'progress' | 'time' | 'live-indicator' | 'quality' | 'settings' | 'captions' | 'airplay' | 'chromecast' | 'pip' | 'fullscreen' | 'spacer' | 'bandwidth-indicator';
10
+ type BuiltinControlSlot = 'play' | 'skip-backward' | 'skip-forward' | 'volume' | 'progress' | 'time' | 'live-indicator' | 'quality' | 'settings' | 'captions' | 'airplay' | 'chromecast' | 'pip' | 'fullscreen' | 'spacer' | 'bandwidth-indicator';
11
+ /**
12
+ * A control slot: a built-in, or any id a plugin registered through
13
+ * {@link registerControl}.
14
+ *
15
+ * `string & {}` keeps editor autocomplete listing the built-ins while still
16
+ * accepting custom ids — a plain `| string` would collapse the union and lose
17
+ * the suggestions.
18
+ */
19
+ type ControlSlot = BuiltinControlSlot | (string & {});
20
+ /**
21
+ * Builds a control instance. Receives the same plugin API the built-ins get.
22
+ */
23
+ type ControlFactory = (api: IPluginAPI) => Control;
11
24
  /**
12
25
  * Layout configuration for the control bar.
13
26
  */
@@ -65,6 +78,65 @@ interface IUIPlugin extends Plugin {
65
78
  getControlBar(): HTMLElement | null;
66
79
  }
67
80
 
81
+ /**
82
+ * Custom control registry.
83
+ *
84
+ * The built-in controls are created by a switch in the UI plugin. Anything a
85
+ * plugin package contributes is registered here instead, so a control-bar
86
+ * button no longer requires editing this package.
87
+ *
88
+ * The registry is module-level and therefore shared by every player instance on
89
+ * the page. That matches how plugin packages register — once, at import time or
90
+ * in `init()` — and the factory receives the per-instance `IPluginAPI`, so the
91
+ * controls themselves stay properly scoped to their player.
92
+ */
93
+
94
+ /**
95
+ * Register a control factory under a slot id.
96
+ *
97
+ * The id only takes effect for players whose layout lists it — registering
98
+ * alone never adds a button anywhere. Hosts opt controls in through
99
+ * `uiPlugin({ controls: [...] })`.
100
+ *
101
+ * Registering an id that already exists replaces the factory and re-notifies,
102
+ * which keeps hot-reload workable.
103
+ *
104
+ * @param id - Slot id, conventionally the plugin's own name
105
+ * @param factory - Builds the control for a given player
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * registerControl('share', (api) => new ShareButton(api));
110
+ * ```
111
+ */
112
+ declare function registerControl(id: string, factory: ControlFactory): void;
113
+ /**
114
+ * Remove a registered control factory.
115
+ *
116
+ * Players already showing the control keep their existing instance until they
117
+ * rebuild; this only stops future ones being created.
118
+ *
119
+ * @param id - Slot id to remove
120
+ * @returns Whether a factory was registered under that id
121
+ */
122
+ declare function unregisterControl(id: string): boolean;
123
+ /**
124
+ * Look up a registered factory.
125
+ *
126
+ * @param id - Slot id
127
+ * @returns The factory, or null when nothing is registered for that id
128
+ */
129
+ declare function getControlFactory(id: string): ControlFactory | null;
130
+ /**
131
+ * Clear all registrations and listeners.
132
+ *
133
+ * Test-support only — the registry is module-level, so without this a
134
+ * registration in one test leaks into the next.
135
+ *
136
+ * @internal
137
+ */
138
+ declare function resetControlRegistry(): void;
139
+
68
140
  /**
69
141
  * SVG Icons for UI Controls
70
142
  *
@@ -148,4 +220,4 @@ declare function formatLiveTime(behindLive: number): string;
148
220
  */
149
221
  declare function uiPlugin(config?: UIPluginConfig): IUIPlugin;
150
222
 
151
- export { type Control, type ControlSlot, type IUIPlugin, type LayoutConfig, type ThemeConfig, type UIPluginConfig, uiPlugin as default, formatLiveTime, formatTime, icons, styles, uiPlugin };
223
+ export { type BuiltinControlSlot, type Control, type ControlFactory, type ControlSlot, type IUIPlugin, type LayoutConfig, type ThemeConfig, type UIPluginConfig, uiPlugin as default, formatLiveTime, formatTime, getControlFactory, icons, registerControl, resetControlRegistry, styles, uiPlugin, unregisterControl };
package/dist/index.d.ts CHANGED
@@ -1,13 +1,26 @@
1
- import { Plugin } from '@scarlett-player/core';
1
+ import { Plugin, IPluginAPI } from '@scarlett-player/core';
2
2
 
3
3
  /**
4
4
  * UI Controls Plugin Types
5
5
  */
6
6
 
7
7
  /**
8
- * Available control slot identifiers.
8
+ * Control slots this package implements itself.
9
9
  */
10
- type ControlSlot = 'play' | 'skip-backward' | 'skip-forward' | 'volume' | 'progress' | 'time' | 'live-indicator' | 'quality' | 'settings' | 'captions' | 'airplay' | 'chromecast' | 'pip' | 'fullscreen' | 'spacer' | 'bandwidth-indicator';
10
+ type BuiltinControlSlot = 'play' | 'skip-backward' | 'skip-forward' | 'volume' | 'progress' | 'time' | 'live-indicator' | 'quality' | 'settings' | 'captions' | 'airplay' | 'chromecast' | 'pip' | 'fullscreen' | 'spacer' | 'bandwidth-indicator';
11
+ /**
12
+ * A control slot: a built-in, or any id a plugin registered through
13
+ * {@link registerControl}.
14
+ *
15
+ * `string & {}` keeps editor autocomplete listing the built-ins while still
16
+ * accepting custom ids — a plain `| string` would collapse the union and lose
17
+ * the suggestions.
18
+ */
19
+ type ControlSlot = BuiltinControlSlot | (string & {});
20
+ /**
21
+ * Builds a control instance. Receives the same plugin API the built-ins get.
22
+ */
23
+ type ControlFactory = (api: IPluginAPI) => Control;
11
24
  /**
12
25
  * Layout configuration for the control bar.
13
26
  */
@@ -65,6 +78,65 @@ interface IUIPlugin extends Plugin {
65
78
  getControlBar(): HTMLElement | null;
66
79
  }
67
80
 
81
+ /**
82
+ * Custom control registry.
83
+ *
84
+ * The built-in controls are created by a switch in the UI plugin. Anything a
85
+ * plugin package contributes is registered here instead, so a control-bar
86
+ * button no longer requires editing this package.
87
+ *
88
+ * The registry is module-level and therefore shared by every player instance on
89
+ * the page. That matches how plugin packages register — once, at import time or
90
+ * in `init()` — and the factory receives the per-instance `IPluginAPI`, so the
91
+ * controls themselves stay properly scoped to their player.
92
+ */
93
+
94
+ /**
95
+ * Register a control factory under a slot id.
96
+ *
97
+ * The id only takes effect for players whose layout lists it — registering
98
+ * alone never adds a button anywhere. Hosts opt controls in through
99
+ * `uiPlugin({ controls: [...] })`.
100
+ *
101
+ * Registering an id that already exists replaces the factory and re-notifies,
102
+ * which keeps hot-reload workable.
103
+ *
104
+ * @param id - Slot id, conventionally the plugin's own name
105
+ * @param factory - Builds the control for a given player
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * registerControl('share', (api) => new ShareButton(api));
110
+ * ```
111
+ */
112
+ declare function registerControl(id: string, factory: ControlFactory): void;
113
+ /**
114
+ * Remove a registered control factory.
115
+ *
116
+ * Players already showing the control keep their existing instance until they
117
+ * rebuild; this only stops future ones being created.
118
+ *
119
+ * @param id - Slot id to remove
120
+ * @returns Whether a factory was registered under that id
121
+ */
122
+ declare function unregisterControl(id: string): boolean;
123
+ /**
124
+ * Look up a registered factory.
125
+ *
126
+ * @param id - Slot id
127
+ * @returns The factory, or null when nothing is registered for that id
128
+ */
129
+ declare function getControlFactory(id: string): ControlFactory | null;
130
+ /**
131
+ * Clear all registrations and listeners.
132
+ *
133
+ * Test-support only — the registry is module-level, so without this a
134
+ * registration in one test leaks into the next.
135
+ *
136
+ * @internal
137
+ */
138
+ declare function resetControlRegistry(): void;
139
+
68
140
  /**
69
141
  * SVG Icons for UI Controls
70
142
  *
@@ -148,4 +220,4 @@ declare function formatLiveTime(behindLive: number): string;
148
220
  */
149
221
  declare function uiPlugin(config?: UIPluginConfig): IUIPlugin;
150
222
 
151
- export { type Control, type ControlSlot, type IUIPlugin, type LayoutConfig, type ThemeConfig, type UIPluginConfig, uiPlugin as default, formatLiveTime, formatTime, icons, styles, uiPlugin };
223
+ export { type BuiltinControlSlot, type Control, type ControlFactory, type ControlSlot, type IUIPlugin, type LayoutConfig, type ThemeConfig, type UIPluginConfig, uiPlugin as default, formatLiveTime, formatTime, getControlFactory, icons, registerControl, resetControlRegistry, styles, uiPlugin, unregisterControl };
package/dist/index.js CHANGED
@@ -1771,25 +1771,38 @@ var CastButton = class {
1771
1771
  var PipButton = class {
1772
1772
  constructor(api) {
1773
1773
  this.clickHandler = () => {
1774
- this.toggle();
1774
+ void this.toggle().catch(() => {
1775
+ });
1775
1776
  };
1776
1777
  this.api = api;
1777
- const video = document.createElement("video");
1778
- this.supported = "pictureInPictureEnabled" in document || "webkitSetPresentationMode" in video;
1778
+ const probe = document.createElement("video");
1779
+ this.supported = "pictureInPictureEnabled" in document || "webkitSetPresentationMode" in probe;
1779
1780
  this.el = createButton("sp-pip", "Picture-in-Picture", icons.pip);
1780
1781
  this.el.addEventListener("click", this.clickHandler);
1781
1782
  if (!this.supported) {
1782
1783
  this.el.style.display = "none";
1784
+ } else {
1785
+ this.el.disabled = true;
1786
+ this.el.setAttribute("aria-disabled", "true");
1783
1787
  }
1784
1788
  }
1785
1789
  render() {
1786
1790
  return this.el;
1787
1791
  }
1792
+ /** Whether the media element is ready to enter PiP (metadata loaded). */
1793
+ isMediaReady() {
1794
+ const video = getVideo(this.api.container);
1795
+ return !!video && video.readyState >= HTMLMediaElement.HAVE_METADATA;
1796
+ }
1788
1797
  update() {
1789
1798
  if (!this.supported) return;
1790
- const pip = this.api.getState("pip");
1791
- this.el.setAttribute("aria-label", pip ? "Exit Picture-in-Picture" : "Picture-in-Picture");
1792
- this.el.classList.toggle("sp-pip--active", !!pip);
1799
+ const pip = !!this.api.getState("pip");
1800
+ const enabled = pip || this.isMediaReady();
1801
+ this.el.disabled = !enabled;
1802
+ setAttr(this.el, "aria-disabled", String(!enabled));
1803
+ setHTML(this.el, pip ? icons.exitPip : icons.pip);
1804
+ setAttr(this.el, "aria-label", pip ? "Exit Picture-in-Picture" : "Picture-in-Picture");
1805
+ this.el.classList.toggle("sp-pip--active", pip);
1793
1806
  }
1794
1807
  async toggle() {
1795
1808
  const video = getVideo(this.api.container);
@@ -1797,8 +1810,14 @@ var PipButton = class {
1797
1810
  this.api.logger.warn("PiP: video element not found");
1798
1811
  return;
1799
1812
  }
1813
+ const isInPip = document.pictureInPictureElement === video || video.webkitPresentationMode === "picture-in-picture";
1814
+ if (!isInPip && video.readyState < HTMLMediaElement.HAVE_METADATA) {
1815
+ this.api.logger.debug("PiP: ignored, media not ready", {
1816
+ readyState: video.readyState
1817
+ });
1818
+ return;
1819
+ }
1800
1820
  try {
1801
- const isInPip = document.pictureInPictureElement === video || video.webkitPresentationMode === "picture-in-picture";
1802
1821
  if (isInPip) {
1803
1822
  if (document.pictureInPictureElement) {
1804
1823
  await document.exitPictureInPicture();
@@ -1815,7 +1834,8 @@ var PipButton = class {
1815
1834
  this.api.logger.debug("PiP: entered");
1816
1835
  }
1817
1836
  } catch (e) {
1818
- this.api.logger.warn("PiP: failed", { error: e.message });
1837
+ const message = e instanceof Error ? e.message : String(e);
1838
+ this.api.logger.warn("PiP: failed", { error: message });
1819
1839
  }
1820
1840
  }
1821
1841
  destroy() {
@@ -1898,6 +1918,12 @@ function getUserMessage(error) {
1898
1918
  return "Unable to load video. Please try again.";
1899
1919
  case "PLAYBACK_FAILED":
1900
1920
  return "Playback stopped unexpectedly. Please try again.";
1921
+ case "MEDIA_APPEND_ERROR":
1922
+ return "Video playback was interrupted. Please try again.";
1923
+ case "MEDIA_BUFFER_FULL":
1924
+ return "Your device is low on video memory. Close other apps or tabs and try again.";
1925
+ case "PLAYLIST_INVALID":
1926
+ return "The stream is temporarily unavailable. Please try again.";
1901
1927
  }
1902
1928
  }
1903
1929
  const msg = error.message?.toLowerCase() || "";
@@ -2558,6 +2584,32 @@ var BandwidthIndicator = class {
2558
2584
  }
2559
2585
  };
2560
2586
 
2587
+ // src/control-registry.ts
2588
+ var registry = /* @__PURE__ */ new Map();
2589
+ var listeners = /* @__PURE__ */ new Set();
2590
+ function registerControl(id, factory) {
2591
+ registry.set(id, factory);
2592
+ for (const listener of listeners) {
2593
+ listener(id);
2594
+ }
2595
+ }
2596
+ function unregisterControl(id) {
2597
+ return registry.delete(id);
2598
+ }
2599
+ function getControlFactory(id) {
2600
+ return registry.get(id) ?? null;
2601
+ }
2602
+ function onControlRegistered(listener) {
2603
+ listeners.add(listener);
2604
+ return () => {
2605
+ listeners.delete(listener);
2606
+ };
2607
+ }
2608
+ function resetControlRegistry() {
2609
+ registry.clear();
2610
+ listeners.clear();
2611
+ }
2612
+
2561
2613
  // src/index.ts
2562
2614
  var DEFAULT_LAYOUT = [
2563
2615
  "play",
@@ -2587,6 +2639,7 @@ function uiPlugin(config = {}) {
2587
2639
  let controls = [];
2588
2640
  let hideTimeout = null;
2589
2641
  let stateUnsubscribe = null;
2642
+ let controlRegistryUnsubscribe = null;
2590
2643
  let errorUnsubscribe = null;
2591
2644
  let reconnectingUnsubscribe = null;
2592
2645
  let recoveredUnsubscribe = null;
@@ -2628,9 +2681,42 @@ function uiPlugin(config = {}) {
2628
2681
  return new FullscreenButton(api);
2629
2682
  case "spacer":
2630
2683
  return new Spacer();
2631
- default:
2684
+ default: {
2685
+ const factory = getControlFactory(slot);
2686
+ if (factory) {
2687
+ try {
2688
+ return factory(api);
2689
+ } catch (error) {
2690
+ api.logger.error(`Control factory for "${slot}" threw`, { error });
2691
+ return null;
2692
+ }
2693
+ }
2694
+ api.logger.warn(`Unknown control slot: ${slot}`);
2632
2695
  return null;
2696
+ }
2697
+ }
2698
+ };
2699
+ const populateControlBar = () => {
2700
+ if (!controlBar) {
2701
+ return;
2702
+ }
2703
+ for (const slot of layout) {
2704
+ const control = createControl(slot);
2705
+ if (control) {
2706
+ controls.push(control);
2707
+ controlBar.appendChild(control.render());
2708
+ }
2709
+ }
2710
+ };
2711
+ const rebuildControlBar = () => {
2712
+ if (!controlBar) {
2713
+ return;
2633
2714
  }
2715
+ controls.forEach((c) => c.destroy());
2716
+ controls = [];
2717
+ controlBar.replaceChildren();
2718
+ populateControlBar();
2719
+ updateControls();
2634
2720
  };
2635
2721
  const updateControls = () => {
2636
2722
  controls.forEach((c) => c.update());
@@ -2699,7 +2785,12 @@ function uiPlugin(config = {}) {
2699
2785
  case " ":
2700
2786
  case "k":
2701
2787
  e.preventDefault();
2702
- video.paused ? video.play() : video.pause();
2788
+ if (video.paused) {
2789
+ video.play().catch(() => {
2790
+ });
2791
+ } else {
2792
+ video.pause();
2793
+ }
2703
2794
  break;
2704
2795
  case "m":
2705
2796
  e.preventDefault();
@@ -2708,9 +2799,11 @@ function uiPlugin(config = {}) {
2708
2799
  case "f":
2709
2800
  e.preventDefault();
2710
2801
  if (document.fullscreenElement) {
2711
- document.exitFullscreen();
2802
+ document.exitFullscreen().catch(() => {
2803
+ });
2712
2804
  } else {
2713
- api.container.requestFullscreen?.();
2805
+ api.container.requestFullscreen?.().catch(() => {
2806
+ });
2714
2807
  }
2715
2808
  break;
2716
2809
  case "ArrowLeft":
@@ -2797,14 +2890,15 @@ function uiPlugin(config = {}) {
2797
2890
  controlBar.className = isPlaying ? "sp-controls sp-controls--hidden" : "sp-controls sp-controls--visible";
2798
2891
  controlBar.setAttribute("role", "toolbar");
2799
2892
  controlBar.setAttribute("aria-label", "Video controls");
2800
- for (const slot of layout) {
2801
- const control = createControl(slot);
2802
- if (control) {
2803
- controls.push(control);
2804
- controlBar.appendChild(control.render());
2805
- }
2806
- }
2893
+ populateControlBar();
2807
2894
  container.appendChild(controlBar);
2895
+ controlRegistryUnsubscribe = onControlRegistered((id) => {
2896
+ if (!layout.includes(id)) {
2897
+ return;
2898
+ }
2899
+ api.logger.debug(`Control "${id}" registered after init, rebuilding control bar`);
2900
+ rebuildControlBar();
2901
+ });
2808
2902
  container.addEventListener("mousemove", handleInteraction);
2809
2903
  container.addEventListener("mouseenter", handleInteraction);
2810
2904
  container.addEventListener("mouseleave", handleMouseLeave);
@@ -2850,6 +2944,8 @@ function uiPlugin(config = {}) {
2850
2944
  }
2851
2945
  document.removeEventListener("keydown", handleKeyDown);
2852
2946
  document.removeEventListener("fullscreenchange", scheduleUpdate);
2947
+ controlRegistryUnsubscribe?.();
2948
+ controlRegistryUnsubscribe = null;
2853
2949
  controls.forEach((c) => c.destroy());
2854
2950
  controls = [];
2855
2951
  progressBar?.destroy();
@@ -2906,7 +3002,11 @@ export {
2906
3002
  index_default as default,
2907
3003
  formatLiveTime,
2908
3004
  formatTime,
3005
+ getControlFactory,
2909
3006
  icons,
3007
+ registerControl,
3008
+ resetControlRegistry,
2910
3009
  styles,
2911
- uiPlugin
3010
+ uiPlugin,
3011
+ unregisterControl
2912
3012
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scarlett-player/ui",
3
- "version": "1.1.1",
3
+ "version": "1.4.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.1.1"
32
+ "@scarlett-player/core": "1.4.0"
33
33
  },
34
34
  "keywords": [
35
35
  "video",