@scarlett-player/ui 1.2.0 → 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/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
 
@@ -2615,6 +2619,32 @@ var BandwidthIndicator = class {
2615
2619
  }
2616
2620
  };
2617
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
+
2618
2648
  // src/index.ts
2619
2649
  var DEFAULT_LAYOUT = [
2620
2650
  "play",
@@ -2644,6 +2674,7 @@ function uiPlugin(config = {}) {
2644
2674
  let controls = [];
2645
2675
  let hideTimeout = null;
2646
2676
  let stateUnsubscribe = null;
2677
+ let controlRegistryUnsubscribe = null;
2647
2678
  let errorUnsubscribe = null;
2648
2679
  let reconnectingUnsubscribe = null;
2649
2680
  let recoveredUnsubscribe = null;
@@ -2685,10 +2716,43 @@ function uiPlugin(config = {}) {
2685
2716
  return new FullscreenButton(api);
2686
2717
  case "spacer":
2687
2718
  return new Spacer();
2688
- 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}`);
2689
2730
  return null;
2731
+ }
2690
2732
  }
2691
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;
2749
+ }
2750
+ controls.forEach((c) => c.destroy());
2751
+ controls = [];
2752
+ controlBar.replaceChildren();
2753
+ populateControlBar();
2754
+ updateControls();
2755
+ };
2692
2756
  const updateControls = () => {
2693
2757
  controls.forEach((c) => c.update());
2694
2758
  progressBar?.update();
@@ -2861,14 +2925,15 @@ function uiPlugin(config = {}) {
2861
2925
  controlBar.className = isPlaying ? "sp-controls sp-controls--hidden" : "sp-controls sp-controls--visible";
2862
2926
  controlBar.setAttribute("role", "toolbar");
2863
2927
  controlBar.setAttribute("aria-label", "Video controls");
2864
- for (const slot of layout) {
2865
- const control = createControl(slot);
2866
- if (control) {
2867
- controls.push(control);
2868
- controlBar.appendChild(control.render());
2869
- }
2870
- }
2928
+ populateControlBar();
2871
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
+ });
2872
2937
  container.addEventListener("mousemove", handleInteraction);
2873
2938
  container.addEventListener("mouseenter", handleInteraction);
2874
2939
  container.addEventListener("mouseleave", handleMouseLeave);
@@ -2914,6 +2979,8 @@ function uiPlugin(config = {}) {
2914
2979
  }
2915
2980
  document.removeEventListener("keydown", handleKeyDown);
2916
2981
  document.removeEventListener("fullscreenchange", scheduleUpdate);
2982
+ controlRegistryUnsubscribe?.();
2983
+ controlRegistryUnsubscribe = null;
2917
2984
  controls.forEach((c) => c.destroy());
2918
2985
  controls = [];
2919
2986
  progressBar?.destroy();
@@ -2970,7 +3037,11 @@ var index_default = uiPlugin;
2970
3037
  0 && (module.exports = {
2971
3038
  formatLiveTime,
2972
3039
  formatTime,
3040
+ getControlFactory,
2973
3041
  icons,
3042
+ registerControl,
3043
+ resetControlRegistry,
2974
3044
  styles,
2975
- uiPlugin
3045
+ uiPlugin,
3046
+ unregisterControl
2976
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
@@ -2584,6 +2584,32 @@ var BandwidthIndicator = class {
2584
2584
  }
2585
2585
  };
2586
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
+
2587
2613
  // src/index.ts
2588
2614
  var DEFAULT_LAYOUT = [
2589
2615
  "play",
@@ -2613,6 +2639,7 @@ function uiPlugin(config = {}) {
2613
2639
  let controls = [];
2614
2640
  let hideTimeout = null;
2615
2641
  let stateUnsubscribe = null;
2642
+ let controlRegistryUnsubscribe = null;
2616
2643
  let errorUnsubscribe = null;
2617
2644
  let reconnectingUnsubscribe = null;
2618
2645
  let recoveredUnsubscribe = null;
@@ -2654,10 +2681,43 @@ function uiPlugin(config = {}) {
2654
2681
  return new FullscreenButton(api);
2655
2682
  case "spacer":
2656
2683
  return new Spacer();
2657
- 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}`);
2658
2695
  return null;
2696
+ }
2659
2697
  }
2660
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;
2714
+ }
2715
+ controls.forEach((c) => c.destroy());
2716
+ controls = [];
2717
+ controlBar.replaceChildren();
2718
+ populateControlBar();
2719
+ updateControls();
2720
+ };
2661
2721
  const updateControls = () => {
2662
2722
  controls.forEach((c) => c.update());
2663
2723
  progressBar?.update();
@@ -2830,14 +2890,15 @@ function uiPlugin(config = {}) {
2830
2890
  controlBar.className = isPlaying ? "sp-controls sp-controls--hidden" : "sp-controls sp-controls--visible";
2831
2891
  controlBar.setAttribute("role", "toolbar");
2832
2892
  controlBar.setAttribute("aria-label", "Video controls");
2833
- for (const slot of layout) {
2834
- const control = createControl(slot);
2835
- if (control) {
2836
- controls.push(control);
2837
- controlBar.appendChild(control.render());
2838
- }
2839
- }
2893
+ populateControlBar();
2840
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
+ });
2841
2902
  container.addEventListener("mousemove", handleInteraction);
2842
2903
  container.addEventListener("mouseenter", handleInteraction);
2843
2904
  container.addEventListener("mouseleave", handleMouseLeave);
@@ -2883,6 +2944,8 @@ function uiPlugin(config = {}) {
2883
2944
  }
2884
2945
  document.removeEventListener("keydown", handleKeyDown);
2885
2946
  document.removeEventListener("fullscreenchange", scheduleUpdate);
2947
+ controlRegistryUnsubscribe?.();
2948
+ controlRegistryUnsubscribe = null;
2886
2949
  controls.forEach((c) => c.destroy());
2887
2950
  controls = [];
2888
2951
  progressBar?.destroy();
@@ -2939,7 +3002,11 @@ export {
2939
3002
  index_default as default,
2940
3003
  formatLiveTime,
2941
3004
  formatTime,
3005
+ getControlFactory,
2942
3006
  icons,
3007
+ registerControl,
3008
+ resetControlRegistry,
2943
3009
  styles,
2944
- uiPlugin
3010
+ uiPlugin,
3011
+ unregisterControl
2945
3012
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scarlett-player/ui",
3
- "version": "1.2.0",
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.2.0"
32
+ "@scarlett-player/core": "1.4.0"
33
33
  },
34
34
  "keywords": [
35
35
  "video",