@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 +5 -0
- package/dist/index.cjs +125 -21
- package/dist/index.d.cts +76 -4
- package/dist/index.d.ts +76 -4
- package/dist/index.js +120 -20
- package/package.json +2 -2
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
|
|
1809
|
-
this.supported = "pictureInPictureEnabled" in document || "webkitSetPresentationMode" in
|
|
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
|
-
|
|
1823
|
-
this.el.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
8
|
+
* Control slots this package implements itself.
|
|
9
9
|
*/
|
|
10
|
-
type
|
|
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
|
-
*
|
|
8
|
+
* Control slots this package implements itself.
|
|
9
9
|
*/
|
|
10
|
-
type
|
|
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
|
|
1778
|
-
this.supported = "pictureInPictureEnabled" in document || "webkitSetPresentationMode" in
|
|
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
|
-
|
|
1792
|
-
this.el.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
32
|
+
"@scarlett-player/core": "1.4.0"
|
|
33
33
|
},
|
|
34
34
|
"keywords": [
|
|
35
35
|
"video",
|