@scarlett-player/playlist 1.5.1 → 1.6.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 +322 -0
- package/dist/index.d.cts +65 -1
- package/dist/index.d.ts +65 -1
- package/dist/index.js +307 -0
- package/package.json +10 -3
package/dist/index.cjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
2
3
|
var __defProp = Object.defineProperty;
|
|
3
4
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
5
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
8
|
var __export = (target, all) => {
|
|
7
9
|
for (var name in all)
|
|
@@ -15,15 +17,298 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
15
17
|
}
|
|
16
18
|
return to;
|
|
17
19
|
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
18
28
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
29
|
|
|
20
30
|
// src/index.ts
|
|
21
31
|
var index_exports = {};
|
|
22
32
|
__export(index_exports, {
|
|
33
|
+
PLAYLIST_ICONS: () => PLAYLIST_ICONS,
|
|
34
|
+
PlaylistPanel: () => PlaylistPanel,
|
|
35
|
+
PlaylistSkipButton: () => PlaylistSkipButton,
|
|
23
36
|
createPlaylistPlugin: () => createPlaylistPlugin,
|
|
24
37
|
default: () => index_default
|
|
25
38
|
});
|
|
26
39
|
module.exports = __toCommonJS(index_exports);
|
|
40
|
+
|
|
41
|
+
// src/controls.ts
|
|
42
|
+
var PLAYLIST_ICONS = {
|
|
43
|
+
previous: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M6 6h2v12H6V6zm3.5 6L18 6v12l-8.5-6z"/></svg>',
|
|
44
|
+
next: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M16 6h2v12h-2V6zM6 6l8.5 6L6 18V6z"/></svg>',
|
|
45
|
+
list: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h11v2H3V6zm0 5h11v2H3v-2zm0 5h7v2H3v-2zm13-1.5V8l6 3.5-6 3.5z"/></svg>'
|
|
46
|
+
};
|
|
47
|
+
var PlaylistSkipButton = class {
|
|
48
|
+
constructor(plugin, direction) {
|
|
49
|
+
this.plugin = plugin;
|
|
50
|
+
this.direction = direction;
|
|
51
|
+
this.clickHandler = () => {
|
|
52
|
+
if (this.direction === "next") {
|
|
53
|
+
this.plugin.next();
|
|
54
|
+
} else {
|
|
55
|
+
this.plugin.previous();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const label = direction === "next" ? "Next item" : "Previous item";
|
|
59
|
+
this.el = document.createElement("button");
|
|
60
|
+
this.el.type = "button";
|
|
61
|
+
this.el.className = `sp-control sp-playlist-skip sp-playlist-skip--${direction}`;
|
|
62
|
+
this.el.setAttribute("aria-label", label);
|
|
63
|
+
this.el.setAttribute("title", label);
|
|
64
|
+
this.el.innerHTML = PLAYLIST_ICONS[direction];
|
|
65
|
+
this.el.addEventListener("click", this.clickHandler);
|
|
66
|
+
}
|
|
67
|
+
render() {
|
|
68
|
+
return this.el;
|
|
69
|
+
}
|
|
70
|
+
update() {
|
|
71
|
+
const state = this.plugin.getState();
|
|
72
|
+
if (state.tracks.length <= 1) {
|
|
73
|
+
this.el.style.display = "none";
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.el.style.display = "";
|
|
77
|
+
const enabled = this.direction === "next" ? state.hasNext : state.hasPrevious;
|
|
78
|
+
this.el.disabled = !enabled;
|
|
79
|
+
}
|
|
80
|
+
destroy() {
|
|
81
|
+
this.el.removeEventListener("click", this.clickHandler);
|
|
82
|
+
this.el.remove();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
var PlaylistPanel = class {
|
|
86
|
+
constructor(plugin, options) {
|
|
87
|
+
this.plugin = plugin;
|
|
88
|
+
this.options = options;
|
|
89
|
+
this.open = false;
|
|
90
|
+
this.renderedIds = "";
|
|
91
|
+
this.toggleHandler = (event) => {
|
|
92
|
+
event.stopPropagation();
|
|
93
|
+
this.setOpen(!this.open);
|
|
94
|
+
};
|
|
95
|
+
this.documentClickHandler = () => {
|
|
96
|
+
if (this.open) this.setOpen(false);
|
|
97
|
+
};
|
|
98
|
+
this.keydownHandler = (event) => {
|
|
99
|
+
if (event.key === "Escape" && this.open) {
|
|
100
|
+
this.setOpen(false);
|
|
101
|
+
this.button.focus();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
this.el = document.createElement("div");
|
|
105
|
+
this.el.className = "sp-playlist";
|
|
106
|
+
this.button = document.createElement("button");
|
|
107
|
+
this.button.type = "button";
|
|
108
|
+
this.button.className = "sp-control sp-playlist__button";
|
|
109
|
+
this.button.setAttribute("aria-label", "Playlist");
|
|
110
|
+
this.button.setAttribute("title", "Playlist");
|
|
111
|
+
this.button.setAttribute("aria-haspopup", "true");
|
|
112
|
+
this.button.setAttribute("aria-expanded", "false");
|
|
113
|
+
this.button.innerHTML = PLAYLIST_ICONS.list;
|
|
114
|
+
this.panel = document.createElement("div");
|
|
115
|
+
this.panel.className = "sp-playlist__panel";
|
|
116
|
+
this.panel.setAttribute("role", "menu");
|
|
117
|
+
this.panel.hidden = true;
|
|
118
|
+
this.el.appendChild(this.button);
|
|
119
|
+
this.el.appendChild(this.panel);
|
|
120
|
+
this.button.addEventListener("click", this.toggleHandler);
|
|
121
|
+
document.addEventListener("click", this.documentClickHandler);
|
|
122
|
+
document.addEventListener("keydown", this.keydownHandler);
|
|
123
|
+
}
|
|
124
|
+
render() {
|
|
125
|
+
return this.el;
|
|
126
|
+
}
|
|
127
|
+
update() {
|
|
128
|
+
const state = this.plugin.getState();
|
|
129
|
+
if (state.tracks.length <= 1) {
|
|
130
|
+
this.el.style.display = "none";
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this.el.style.display = "";
|
|
134
|
+
const signature = state.tracks.map((track) => track.id).join("|");
|
|
135
|
+
if (signature !== this.renderedIds) {
|
|
136
|
+
this.renderedIds = signature;
|
|
137
|
+
this.renderPanel(state.tracks, state.currentIndex);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
this.markActive(state.currentIndex);
|
|
141
|
+
}
|
|
142
|
+
destroy() {
|
|
143
|
+
this.button.removeEventListener("click", this.toggleHandler);
|
|
144
|
+
document.removeEventListener("click", this.documentClickHandler);
|
|
145
|
+
document.removeEventListener("keydown", this.keydownHandler);
|
|
146
|
+
this.el.remove();
|
|
147
|
+
}
|
|
148
|
+
setOpen(open) {
|
|
149
|
+
this.open = open;
|
|
150
|
+
this.panel.hidden = !open;
|
|
151
|
+
this.button.setAttribute("aria-expanded", String(open));
|
|
152
|
+
this.el.classList.toggle("sp-playlist--open", open);
|
|
153
|
+
}
|
|
154
|
+
markActive(currentIndex) {
|
|
155
|
+
const items = this.panel.querySelectorAll(".sp-playlist__item");
|
|
156
|
+
items.forEach((item, index) => {
|
|
157
|
+
item.classList.toggle("sp-playlist__item--active", index === currentIndex);
|
|
158
|
+
item.setAttribute("aria-current", index === currentIndex ? "true" : "false");
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
renderPanel(tracks, currentIndex) {
|
|
162
|
+
this.panel.textContent = "";
|
|
163
|
+
tracks.forEach((track, index) => {
|
|
164
|
+
const item = document.createElement("button");
|
|
165
|
+
item.type = "button";
|
|
166
|
+
item.className = "sp-playlist__item";
|
|
167
|
+
item.setAttribute("role", "menuitem");
|
|
168
|
+
item.setAttribute("aria-current", index === currentIndex ? "true" : "false");
|
|
169
|
+
if (index === currentIndex) {
|
|
170
|
+
item.classList.add("sp-playlist__item--active");
|
|
171
|
+
}
|
|
172
|
+
const position = document.createElement("span");
|
|
173
|
+
position.className = "sp-playlist__position";
|
|
174
|
+
position.textContent = String(index + 1);
|
|
175
|
+
const text = document.createElement("span");
|
|
176
|
+
text.className = "sp-playlist__text";
|
|
177
|
+
const title = document.createElement("span");
|
|
178
|
+
title.className = "sp-playlist__title";
|
|
179
|
+
title.textContent = track.title ?? `Item ${index + 1}`;
|
|
180
|
+
text.appendChild(title);
|
|
181
|
+
if (track.artist) {
|
|
182
|
+
const artist = document.createElement("span");
|
|
183
|
+
artist.className = "sp-playlist__artist";
|
|
184
|
+
artist.textContent = track.artist;
|
|
185
|
+
text.appendChild(artist);
|
|
186
|
+
}
|
|
187
|
+
item.appendChild(position);
|
|
188
|
+
item.appendChild(text);
|
|
189
|
+
item.addEventListener("click", (event) => {
|
|
190
|
+
event.stopPropagation();
|
|
191
|
+
this.options.onSelect(index);
|
|
192
|
+
this.setOpen(false);
|
|
193
|
+
});
|
|
194
|
+
this.panel.appendChild(item);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// src/styles.ts
|
|
200
|
+
var STYLE_ID = "sp-playlist-styles";
|
|
201
|
+
var styles = `
|
|
202
|
+
.sp-playlist-skip[disabled] {
|
|
203
|
+
opacity: 0.4;
|
|
204
|
+
cursor: default;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.sp-playlist {
|
|
208
|
+
position: relative;
|
|
209
|
+
display: inline-flex;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.sp-playlist__button svg,
|
|
213
|
+
.sp-playlist-skip svg {
|
|
214
|
+
width: 20px;
|
|
215
|
+
height: 20px;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.sp-playlist__panel {
|
|
219
|
+
position: absolute;
|
|
220
|
+
bottom: calc(100% + 8px);
|
|
221
|
+
right: 0;
|
|
222
|
+
z-index: 20;
|
|
223
|
+
display: flex;
|
|
224
|
+
flex-direction: column;
|
|
225
|
+
min-width: 240px;
|
|
226
|
+
max-width: 320px;
|
|
227
|
+
max-height: 300px;
|
|
228
|
+
overflow-y: auto;
|
|
229
|
+
padding: 4px;
|
|
230
|
+
border-radius: 8px;
|
|
231
|
+
background: rgba(20, 20, 20, 0.96);
|
|
232
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.sp-playlist__panel[hidden] {
|
|
236
|
+
display: none;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.sp-playlist__item {
|
|
240
|
+
display: flex;
|
|
241
|
+
gap: 10px;
|
|
242
|
+
align-items: flex-start;
|
|
243
|
+
width: 100%;
|
|
244
|
+
padding: 8px 10px;
|
|
245
|
+
border: 0;
|
|
246
|
+
border-radius: 6px;
|
|
247
|
+
background: transparent;
|
|
248
|
+
color: #fff;
|
|
249
|
+
font: inherit;
|
|
250
|
+
text-align: left;
|
|
251
|
+
cursor: pointer;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.sp-playlist__item:hover,
|
|
255
|
+
.sp-playlist__item:focus-visible {
|
|
256
|
+
background: rgba(255, 255, 255, 0.12);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.sp-playlist__item--active {
|
|
260
|
+
background: rgba(255, 255, 255, 0.08);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.sp-playlist__item--active .sp-playlist__title {
|
|
264
|
+
font-weight: 600;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.sp-playlist__position {
|
|
268
|
+
flex: 0 0 auto;
|
|
269
|
+
min-width: 18px;
|
|
270
|
+
color: rgba(255, 255, 255, 0.7);
|
|
271
|
+
font-variant-numeric: tabular-nums;
|
|
272
|
+
font-size: 12px;
|
|
273
|
+
line-height: 18px;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.sp-playlist__text {
|
|
277
|
+
display: flex;
|
|
278
|
+
flex-direction: column;
|
|
279
|
+
min-width: 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.sp-playlist__title {
|
|
283
|
+
font-size: 13px;
|
|
284
|
+
line-height: 18px;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.sp-playlist__artist {
|
|
288
|
+
color: rgba(255, 255, 255, 0.6);
|
|
289
|
+
font-size: 11px;
|
|
290
|
+
line-height: 16px;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
@media (max-width: 480px) {
|
|
294
|
+
.sp-playlist__panel {
|
|
295
|
+
min-width: 200px;
|
|
296
|
+
max-width: 76vw;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
`;
|
|
300
|
+
function injectStyles() {
|
|
301
|
+
if (typeof document === "undefined" || document.getElementById(STYLE_ID)) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
const el = document.createElement("style");
|
|
305
|
+
el.id = STYLE_ID;
|
|
306
|
+
el.textContent = styles;
|
|
307
|
+
document.head.appendChild(el);
|
|
308
|
+
return el;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/index.ts
|
|
27
312
|
var DEFAULT_CONFIG = {
|
|
28
313
|
autoAdvance: true,
|
|
29
314
|
preloadNext: true,
|
|
@@ -213,8 +498,42 @@ function createPlaylistPlugin(config) {
|
|
|
213
498
|
api?.emit("playlist:ended", void 0);
|
|
214
499
|
}
|
|
215
500
|
});
|
|
501
|
+
injectStyles();
|
|
502
|
+
void import("@scarlett-player/ui").then(({ registerControl }) => {
|
|
503
|
+
const self = plugin;
|
|
504
|
+
registerControl(
|
|
505
|
+
"playlist-previous",
|
|
506
|
+
() => new PlaylistSkipButton(self, "previous")
|
|
507
|
+
);
|
|
508
|
+
registerControl("playlist-next", () => new PlaylistSkipButton(self, "next"));
|
|
509
|
+
registerControl(
|
|
510
|
+
"playlist",
|
|
511
|
+
() => new PlaylistPanel(self, {
|
|
512
|
+
onSelect: (index) => self.play(index)
|
|
513
|
+
})
|
|
514
|
+
);
|
|
515
|
+
}).catch(() => {
|
|
516
|
+
api?.logger.debug("@scarlett-player/ui not present, playlist controls not registered");
|
|
517
|
+
});
|
|
518
|
+
const onKeyDown = (event) => {
|
|
519
|
+
if (!api || !api.container.contains(document.activeElement)) return;
|
|
520
|
+
const activeEl = document.activeElement;
|
|
521
|
+
if (activeEl instanceof HTMLInputElement || activeEl instanceof HTMLTextAreaElement || activeEl instanceof HTMLSelectElement || activeEl?.isContentEditable) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
525
|
+
if (event.key === "n" || event.key === "N") {
|
|
526
|
+
event.preventDefault();
|
|
527
|
+
plugin.next();
|
|
528
|
+
} else if (event.key === "p" || event.key === "P") {
|
|
529
|
+
event.preventDefault();
|
|
530
|
+
plugin.previous();
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
document.addEventListener("keydown", onKeyDown);
|
|
216
534
|
api.onDestroy(() => {
|
|
217
535
|
unsubEnded();
|
|
536
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
218
537
|
if (advanceTimeout) {
|
|
219
538
|
clearTimeout(advanceTimeout);
|
|
220
539
|
advanceTimeout = null;
|
|
@@ -409,5 +728,8 @@ function createPlaylistPlugin(config) {
|
|
|
409
728
|
var index_default = createPlaylistPlugin;
|
|
410
729
|
// Annotate the CommonJS export names for ESM import in node:
|
|
411
730
|
0 && (module.exports = {
|
|
731
|
+
PLAYLIST_ICONS,
|
|
732
|
+
PlaylistPanel,
|
|
733
|
+
PlaylistSkipButton,
|
|
412
734
|
createPlaylistPlugin
|
|
413
735
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -156,6 +156,70 @@ interface IPlaylistPlugin extends Plugin<PlaylistPluginConfig> {
|
|
|
156
156
|
getTrack(id: string): PlaylistTrack | null;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Playlist control-bar controls.
|
|
161
|
+
*
|
|
162
|
+
* Three controls, registered under `playlist-previous`, `playlist-next` and
|
|
163
|
+
* `playlist`. A host places them by listing those ids in its control layout.
|
|
164
|
+
*
|
|
165
|
+
* These exist for a concrete viewer problem: an event VOD that opens with a
|
|
166
|
+
* copyright card, a sponsor reel or a preshow the viewer has already seen.
|
|
167
|
+
* Without a skip control the only way past it is to guess with the scrubber.
|
|
168
|
+
*/
|
|
169
|
+
|
|
170
|
+
/** Matches the Control interface in @scarlett-player/ui without importing it. */
|
|
171
|
+
interface PlaylistControl {
|
|
172
|
+
render(): HTMLElement;
|
|
173
|
+
update(): void;
|
|
174
|
+
destroy(): void;
|
|
175
|
+
}
|
|
176
|
+
/** 24px icons on a 0 0 24 24 viewBox, matching @scarlett-player/ui. */
|
|
177
|
+
declare const PLAYLIST_ICONS: Record<string, string>;
|
|
178
|
+
/**
|
|
179
|
+
* Skip to the next or previous playlist item.
|
|
180
|
+
*
|
|
181
|
+
* Disabled rather than hidden at the ends of the queue: a control that vanishes
|
|
182
|
+
* mid-session moves everything next to it, and the viewer loses their place.
|
|
183
|
+
*/
|
|
184
|
+
declare class PlaylistSkipButton implements PlaylistControl {
|
|
185
|
+
private plugin;
|
|
186
|
+
private direction;
|
|
187
|
+
private el;
|
|
188
|
+
private readonly clickHandler;
|
|
189
|
+
constructor(plugin: IPlaylistPlugin, direction: 'next' | 'previous');
|
|
190
|
+
render(): HTMLElement;
|
|
191
|
+
update(): void;
|
|
192
|
+
destroy(): void;
|
|
193
|
+
}
|
|
194
|
+
/** Options for {@link PlaylistPanel}. */
|
|
195
|
+
interface PlaylistPanelOptions {
|
|
196
|
+
/** Called with the index the viewer picked. */
|
|
197
|
+
onSelect: (index: number) => void;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* A button that opens the queue as a list, so a viewer can see what else is
|
|
201
|
+
* there rather than stepping through blind.
|
|
202
|
+
*/
|
|
203
|
+
declare class PlaylistPanel implements PlaylistControl {
|
|
204
|
+
private plugin;
|
|
205
|
+
private options;
|
|
206
|
+
private el;
|
|
207
|
+
private button;
|
|
208
|
+
private panel;
|
|
209
|
+
private open;
|
|
210
|
+
private renderedIds;
|
|
211
|
+
private toggleHandler;
|
|
212
|
+
private documentClickHandler;
|
|
213
|
+
private keydownHandler;
|
|
214
|
+
constructor(plugin: IPlaylistPlugin, options: PlaylistPanelOptions);
|
|
215
|
+
render(): HTMLElement;
|
|
216
|
+
update(): void;
|
|
217
|
+
destroy(): void;
|
|
218
|
+
private setOpen;
|
|
219
|
+
private markActive;
|
|
220
|
+
private renderPanel;
|
|
221
|
+
}
|
|
222
|
+
|
|
159
223
|
/**
|
|
160
224
|
* Playlist Plugin for Scarlett Player
|
|
161
225
|
*
|
|
@@ -202,4 +266,4 @@ interface IPlaylistPlugin extends Plugin<PlaylistPluginConfig> {
|
|
|
202
266
|
*/
|
|
203
267
|
declare function createPlaylistPlugin(config?: Partial<PlaylistPluginConfig>): IPlaylistPlugin;
|
|
204
268
|
|
|
205
|
-
export { type IPlaylistPlugin, type PlaylistPluginConfig, type PlaylistState, type PlaylistTrack, type RepeatMode, createPlaylistPlugin, createPlaylistPlugin as default };
|
|
269
|
+
export { type IPlaylistPlugin, PLAYLIST_ICONS, type PlaylistControl, PlaylistPanel, type PlaylistPanelOptions, type PlaylistPluginConfig, PlaylistSkipButton, type PlaylistState, type PlaylistTrack, type RepeatMode, createPlaylistPlugin, createPlaylistPlugin as default };
|
package/dist/index.d.ts
CHANGED
|
@@ -156,6 +156,70 @@ interface IPlaylistPlugin extends Plugin<PlaylistPluginConfig> {
|
|
|
156
156
|
getTrack(id: string): PlaylistTrack | null;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Playlist control-bar controls.
|
|
161
|
+
*
|
|
162
|
+
* Three controls, registered under `playlist-previous`, `playlist-next` and
|
|
163
|
+
* `playlist`. A host places them by listing those ids in its control layout.
|
|
164
|
+
*
|
|
165
|
+
* These exist for a concrete viewer problem: an event VOD that opens with a
|
|
166
|
+
* copyright card, a sponsor reel or a preshow the viewer has already seen.
|
|
167
|
+
* Without a skip control the only way past it is to guess with the scrubber.
|
|
168
|
+
*/
|
|
169
|
+
|
|
170
|
+
/** Matches the Control interface in @scarlett-player/ui without importing it. */
|
|
171
|
+
interface PlaylistControl {
|
|
172
|
+
render(): HTMLElement;
|
|
173
|
+
update(): void;
|
|
174
|
+
destroy(): void;
|
|
175
|
+
}
|
|
176
|
+
/** 24px icons on a 0 0 24 24 viewBox, matching @scarlett-player/ui. */
|
|
177
|
+
declare const PLAYLIST_ICONS: Record<string, string>;
|
|
178
|
+
/**
|
|
179
|
+
* Skip to the next or previous playlist item.
|
|
180
|
+
*
|
|
181
|
+
* Disabled rather than hidden at the ends of the queue: a control that vanishes
|
|
182
|
+
* mid-session moves everything next to it, and the viewer loses their place.
|
|
183
|
+
*/
|
|
184
|
+
declare class PlaylistSkipButton implements PlaylistControl {
|
|
185
|
+
private plugin;
|
|
186
|
+
private direction;
|
|
187
|
+
private el;
|
|
188
|
+
private readonly clickHandler;
|
|
189
|
+
constructor(plugin: IPlaylistPlugin, direction: 'next' | 'previous');
|
|
190
|
+
render(): HTMLElement;
|
|
191
|
+
update(): void;
|
|
192
|
+
destroy(): void;
|
|
193
|
+
}
|
|
194
|
+
/** Options for {@link PlaylistPanel}. */
|
|
195
|
+
interface PlaylistPanelOptions {
|
|
196
|
+
/** Called with the index the viewer picked. */
|
|
197
|
+
onSelect: (index: number) => void;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* A button that opens the queue as a list, so a viewer can see what else is
|
|
201
|
+
* there rather than stepping through blind.
|
|
202
|
+
*/
|
|
203
|
+
declare class PlaylistPanel implements PlaylistControl {
|
|
204
|
+
private plugin;
|
|
205
|
+
private options;
|
|
206
|
+
private el;
|
|
207
|
+
private button;
|
|
208
|
+
private panel;
|
|
209
|
+
private open;
|
|
210
|
+
private renderedIds;
|
|
211
|
+
private toggleHandler;
|
|
212
|
+
private documentClickHandler;
|
|
213
|
+
private keydownHandler;
|
|
214
|
+
constructor(plugin: IPlaylistPlugin, options: PlaylistPanelOptions);
|
|
215
|
+
render(): HTMLElement;
|
|
216
|
+
update(): void;
|
|
217
|
+
destroy(): void;
|
|
218
|
+
private setOpen;
|
|
219
|
+
private markActive;
|
|
220
|
+
private renderPanel;
|
|
221
|
+
}
|
|
222
|
+
|
|
159
223
|
/**
|
|
160
224
|
* Playlist Plugin for Scarlett Player
|
|
161
225
|
*
|
|
@@ -202,4 +266,4 @@ interface IPlaylistPlugin extends Plugin<PlaylistPluginConfig> {
|
|
|
202
266
|
*/
|
|
203
267
|
declare function createPlaylistPlugin(config?: Partial<PlaylistPluginConfig>): IPlaylistPlugin;
|
|
204
268
|
|
|
205
|
-
export { type IPlaylistPlugin, type PlaylistPluginConfig, type PlaylistState, type PlaylistTrack, type RepeatMode, createPlaylistPlugin, createPlaylistPlugin as default };
|
|
269
|
+
export { type IPlaylistPlugin, PLAYLIST_ICONS, type PlaylistControl, PlaylistPanel, type PlaylistPanelOptions, type PlaylistPluginConfig, PlaylistSkipButton, type PlaylistState, type PlaylistTrack, type RepeatMode, createPlaylistPlugin, createPlaylistPlugin as default };
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,273 @@
|
|
|
1
|
+
// src/controls.ts
|
|
2
|
+
var PLAYLIST_ICONS = {
|
|
3
|
+
previous: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M6 6h2v12H6V6zm3.5 6L18 6v12l-8.5-6z"/></svg>',
|
|
4
|
+
next: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M16 6h2v12h-2V6zM6 6l8.5 6L6 18V6z"/></svg>',
|
|
5
|
+
list: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h11v2H3V6zm0 5h11v2H3v-2zm0 5h7v2H3v-2zm13-1.5V8l6 3.5-6 3.5z"/></svg>'
|
|
6
|
+
};
|
|
7
|
+
var PlaylistSkipButton = class {
|
|
8
|
+
constructor(plugin, direction) {
|
|
9
|
+
this.plugin = plugin;
|
|
10
|
+
this.direction = direction;
|
|
11
|
+
this.clickHandler = () => {
|
|
12
|
+
if (this.direction === "next") {
|
|
13
|
+
this.plugin.next();
|
|
14
|
+
} else {
|
|
15
|
+
this.plugin.previous();
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const label = direction === "next" ? "Next item" : "Previous item";
|
|
19
|
+
this.el = document.createElement("button");
|
|
20
|
+
this.el.type = "button";
|
|
21
|
+
this.el.className = `sp-control sp-playlist-skip sp-playlist-skip--${direction}`;
|
|
22
|
+
this.el.setAttribute("aria-label", label);
|
|
23
|
+
this.el.setAttribute("title", label);
|
|
24
|
+
this.el.innerHTML = PLAYLIST_ICONS[direction];
|
|
25
|
+
this.el.addEventListener("click", this.clickHandler);
|
|
26
|
+
}
|
|
27
|
+
render() {
|
|
28
|
+
return this.el;
|
|
29
|
+
}
|
|
30
|
+
update() {
|
|
31
|
+
const state = this.plugin.getState();
|
|
32
|
+
if (state.tracks.length <= 1) {
|
|
33
|
+
this.el.style.display = "none";
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
this.el.style.display = "";
|
|
37
|
+
const enabled = this.direction === "next" ? state.hasNext : state.hasPrevious;
|
|
38
|
+
this.el.disabled = !enabled;
|
|
39
|
+
}
|
|
40
|
+
destroy() {
|
|
41
|
+
this.el.removeEventListener("click", this.clickHandler);
|
|
42
|
+
this.el.remove();
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
var PlaylistPanel = class {
|
|
46
|
+
constructor(plugin, options) {
|
|
47
|
+
this.plugin = plugin;
|
|
48
|
+
this.options = options;
|
|
49
|
+
this.open = false;
|
|
50
|
+
this.renderedIds = "";
|
|
51
|
+
this.toggleHandler = (event) => {
|
|
52
|
+
event.stopPropagation();
|
|
53
|
+
this.setOpen(!this.open);
|
|
54
|
+
};
|
|
55
|
+
this.documentClickHandler = () => {
|
|
56
|
+
if (this.open) this.setOpen(false);
|
|
57
|
+
};
|
|
58
|
+
this.keydownHandler = (event) => {
|
|
59
|
+
if (event.key === "Escape" && this.open) {
|
|
60
|
+
this.setOpen(false);
|
|
61
|
+
this.button.focus();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
this.el = document.createElement("div");
|
|
65
|
+
this.el.className = "sp-playlist";
|
|
66
|
+
this.button = document.createElement("button");
|
|
67
|
+
this.button.type = "button";
|
|
68
|
+
this.button.className = "sp-control sp-playlist__button";
|
|
69
|
+
this.button.setAttribute("aria-label", "Playlist");
|
|
70
|
+
this.button.setAttribute("title", "Playlist");
|
|
71
|
+
this.button.setAttribute("aria-haspopup", "true");
|
|
72
|
+
this.button.setAttribute("aria-expanded", "false");
|
|
73
|
+
this.button.innerHTML = PLAYLIST_ICONS.list;
|
|
74
|
+
this.panel = document.createElement("div");
|
|
75
|
+
this.panel.className = "sp-playlist__panel";
|
|
76
|
+
this.panel.setAttribute("role", "menu");
|
|
77
|
+
this.panel.hidden = true;
|
|
78
|
+
this.el.appendChild(this.button);
|
|
79
|
+
this.el.appendChild(this.panel);
|
|
80
|
+
this.button.addEventListener("click", this.toggleHandler);
|
|
81
|
+
document.addEventListener("click", this.documentClickHandler);
|
|
82
|
+
document.addEventListener("keydown", this.keydownHandler);
|
|
83
|
+
}
|
|
84
|
+
render() {
|
|
85
|
+
return this.el;
|
|
86
|
+
}
|
|
87
|
+
update() {
|
|
88
|
+
const state = this.plugin.getState();
|
|
89
|
+
if (state.tracks.length <= 1) {
|
|
90
|
+
this.el.style.display = "none";
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
this.el.style.display = "";
|
|
94
|
+
const signature = state.tracks.map((track) => track.id).join("|");
|
|
95
|
+
if (signature !== this.renderedIds) {
|
|
96
|
+
this.renderedIds = signature;
|
|
97
|
+
this.renderPanel(state.tracks, state.currentIndex);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
this.markActive(state.currentIndex);
|
|
101
|
+
}
|
|
102
|
+
destroy() {
|
|
103
|
+
this.button.removeEventListener("click", this.toggleHandler);
|
|
104
|
+
document.removeEventListener("click", this.documentClickHandler);
|
|
105
|
+
document.removeEventListener("keydown", this.keydownHandler);
|
|
106
|
+
this.el.remove();
|
|
107
|
+
}
|
|
108
|
+
setOpen(open) {
|
|
109
|
+
this.open = open;
|
|
110
|
+
this.panel.hidden = !open;
|
|
111
|
+
this.button.setAttribute("aria-expanded", String(open));
|
|
112
|
+
this.el.classList.toggle("sp-playlist--open", open);
|
|
113
|
+
}
|
|
114
|
+
markActive(currentIndex) {
|
|
115
|
+
const items = this.panel.querySelectorAll(".sp-playlist__item");
|
|
116
|
+
items.forEach((item, index) => {
|
|
117
|
+
item.classList.toggle("sp-playlist__item--active", index === currentIndex);
|
|
118
|
+
item.setAttribute("aria-current", index === currentIndex ? "true" : "false");
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
renderPanel(tracks, currentIndex) {
|
|
122
|
+
this.panel.textContent = "";
|
|
123
|
+
tracks.forEach((track, index) => {
|
|
124
|
+
const item = document.createElement("button");
|
|
125
|
+
item.type = "button";
|
|
126
|
+
item.className = "sp-playlist__item";
|
|
127
|
+
item.setAttribute("role", "menuitem");
|
|
128
|
+
item.setAttribute("aria-current", index === currentIndex ? "true" : "false");
|
|
129
|
+
if (index === currentIndex) {
|
|
130
|
+
item.classList.add("sp-playlist__item--active");
|
|
131
|
+
}
|
|
132
|
+
const position = document.createElement("span");
|
|
133
|
+
position.className = "sp-playlist__position";
|
|
134
|
+
position.textContent = String(index + 1);
|
|
135
|
+
const text = document.createElement("span");
|
|
136
|
+
text.className = "sp-playlist__text";
|
|
137
|
+
const title = document.createElement("span");
|
|
138
|
+
title.className = "sp-playlist__title";
|
|
139
|
+
title.textContent = track.title ?? `Item ${index + 1}`;
|
|
140
|
+
text.appendChild(title);
|
|
141
|
+
if (track.artist) {
|
|
142
|
+
const artist = document.createElement("span");
|
|
143
|
+
artist.className = "sp-playlist__artist";
|
|
144
|
+
artist.textContent = track.artist;
|
|
145
|
+
text.appendChild(artist);
|
|
146
|
+
}
|
|
147
|
+
item.appendChild(position);
|
|
148
|
+
item.appendChild(text);
|
|
149
|
+
item.addEventListener("click", (event) => {
|
|
150
|
+
event.stopPropagation();
|
|
151
|
+
this.options.onSelect(index);
|
|
152
|
+
this.setOpen(false);
|
|
153
|
+
});
|
|
154
|
+
this.panel.appendChild(item);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// src/styles.ts
|
|
160
|
+
var STYLE_ID = "sp-playlist-styles";
|
|
161
|
+
var styles = `
|
|
162
|
+
.sp-playlist-skip[disabled] {
|
|
163
|
+
opacity: 0.4;
|
|
164
|
+
cursor: default;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.sp-playlist {
|
|
168
|
+
position: relative;
|
|
169
|
+
display: inline-flex;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.sp-playlist__button svg,
|
|
173
|
+
.sp-playlist-skip svg {
|
|
174
|
+
width: 20px;
|
|
175
|
+
height: 20px;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.sp-playlist__panel {
|
|
179
|
+
position: absolute;
|
|
180
|
+
bottom: calc(100% + 8px);
|
|
181
|
+
right: 0;
|
|
182
|
+
z-index: 20;
|
|
183
|
+
display: flex;
|
|
184
|
+
flex-direction: column;
|
|
185
|
+
min-width: 240px;
|
|
186
|
+
max-width: 320px;
|
|
187
|
+
max-height: 300px;
|
|
188
|
+
overflow-y: auto;
|
|
189
|
+
padding: 4px;
|
|
190
|
+
border-radius: 8px;
|
|
191
|
+
background: rgba(20, 20, 20, 0.96);
|
|
192
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.sp-playlist__panel[hidden] {
|
|
196
|
+
display: none;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.sp-playlist__item {
|
|
200
|
+
display: flex;
|
|
201
|
+
gap: 10px;
|
|
202
|
+
align-items: flex-start;
|
|
203
|
+
width: 100%;
|
|
204
|
+
padding: 8px 10px;
|
|
205
|
+
border: 0;
|
|
206
|
+
border-radius: 6px;
|
|
207
|
+
background: transparent;
|
|
208
|
+
color: #fff;
|
|
209
|
+
font: inherit;
|
|
210
|
+
text-align: left;
|
|
211
|
+
cursor: pointer;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.sp-playlist__item:hover,
|
|
215
|
+
.sp-playlist__item:focus-visible {
|
|
216
|
+
background: rgba(255, 255, 255, 0.12);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.sp-playlist__item--active {
|
|
220
|
+
background: rgba(255, 255, 255, 0.08);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.sp-playlist__item--active .sp-playlist__title {
|
|
224
|
+
font-weight: 600;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.sp-playlist__position {
|
|
228
|
+
flex: 0 0 auto;
|
|
229
|
+
min-width: 18px;
|
|
230
|
+
color: rgba(255, 255, 255, 0.7);
|
|
231
|
+
font-variant-numeric: tabular-nums;
|
|
232
|
+
font-size: 12px;
|
|
233
|
+
line-height: 18px;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.sp-playlist__text {
|
|
237
|
+
display: flex;
|
|
238
|
+
flex-direction: column;
|
|
239
|
+
min-width: 0;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.sp-playlist__title {
|
|
243
|
+
font-size: 13px;
|
|
244
|
+
line-height: 18px;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.sp-playlist__artist {
|
|
248
|
+
color: rgba(255, 255, 255, 0.6);
|
|
249
|
+
font-size: 11px;
|
|
250
|
+
line-height: 16px;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
@media (max-width: 480px) {
|
|
254
|
+
.sp-playlist__panel {
|
|
255
|
+
min-width: 200px;
|
|
256
|
+
max-width: 76vw;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
`;
|
|
260
|
+
function injectStyles() {
|
|
261
|
+
if (typeof document === "undefined" || document.getElementById(STYLE_ID)) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
const el = document.createElement("style");
|
|
265
|
+
el.id = STYLE_ID;
|
|
266
|
+
el.textContent = styles;
|
|
267
|
+
document.head.appendChild(el);
|
|
268
|
+
return el;
|
|
269
|
+
}
|
|
270
|
+
|
|
1
271
|
// src/index.ts
|
|
2
272
|
var DEFAULT_CONFIG = {
|
|
3
273
|
autoAdvance: true,
|
|
@@ -188,8 +458,42 @@ function createPlaylistPlugin(config) {
|
|
|
188
458
|
api?.emit("playlist:ended", void 0);
|
|
189
459
|
}
|
|
190
460
|
});
|
|
461
|
+
injectStyles();
|
|
462
|
+
void import("@scarlett-player/ui").then(({ registerControl }) => {
|
|
463
|
+
const self = plugin;
|
|
464
|
+
registerControl(
|
|
465
|
+
"playlist-previous",
|
|
466
|
+
() => new PlaylistSkipButton(self, "previous")
|
|
467
|
+
);
|
|
468
|
+
registerControl("playlist-next", () => new PlaylistSkipButton(self, "next"));
|
|
469
|
+
registerControl(
|
|
470
|
+
"playlist",
|
|
471
|
+
() => new PlaylistPanel(self, {
|
|
472
|
+
onSelect: (index) => self.play(index)
|
|
473
|
+
})
|
|
474
|
+
);
|
|
475
|
+
}).catch(() => {
|
|
476
|
+
api?.logger.debug("@scarlett-player/ui not present, playlist controls not registered");
|
|
477
|
+
});
|
|
478
|
+
const onKeyDown = (event) => {
|
|
479
|
+
if (!api || !api.container.contains(document.activeElement)) return;
|
|
480
|
+
const activeEl = document.activeElement;
|
|
481
|
+
if (activeEl instanceof HTMLInputElement || activeEl instanceof HTMLTextAreaElement || activeEl instanceof HTMLSelectElement || activeEl?.isContentEditable) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
485
|
+
if (event.key === "n" || event.key === "N") {
|
|
486
|
+
event.preventDefault();
|
|
487
|
+
plugin.next();
|
|
488
|
+
} else if (event.key === "p" || event.key === "P") {
|
|
489
|
+
event.preventDefault();
|
|
490
|
+
plugin.previous();
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
document.addEventListener("keydown", onKeyDown);
|
|
191
494
|
api.onDestroy(() => {
|
|
192
495
|
unsubEnded();
|
|
496
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
193
497
|
if (advanceTimeout) {
|
|
194
498
|
clearTimeout(advanceTimeout);
|
|
195
499
|
advanceTimeout = null;
|
|
@@ -383,6 +687,9 @@ function createPlaylistPlugin(config) {
|
|
|
383
687
|
}
|
|
384
688
|
var index_default = createPlaylistPlugin;
|
|
385
689
|
export {
|
|
690
|
+
PLAYLIST_ICONS,
|
|
691
|
+
PlaylistPanel,
|
|
692
|
+
PlaylistSkipButton,
|
|
386
693
|
createPlaylistPlugin,
|
|
387
694
|
index_default as default
|
|
388
695
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scarlett-player/playlist",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Playlist Plugin for Scarlett Player - Queue management, shuffle, repeat, and gapless playback",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -22,14 +22,16 @@
|
|
|
22
22
|
"dist"
|
|
23
23
|
],
|
|
24
24
|
"peerDependencies": {
|
|
25
|
-
"@scarlett-player/core": "^1.0.3"
|
|
25
|
+
"@scarlett-player/core": "^1.0.3",
|
|
26
|
+
"@scarlett-player/ui": "^1.5.0"
|
|
26
27
|
},
|
|
27
28
|
"devDependencies": {
|
|
28
29
|
"tsup": "^8.0.0",
|
|
29
30
|
"typescript": "^5.3.0",
|
|
30
31
|
"vitest": "^1.6.0",
|
|
31
32
|
"jsdom": "^24.0.0",
|
|
32
|
-
"@scarlett-player/core": "1.
|
|
33
|
+
"@scarlett-player/core": "1.6.0",
|
|
34
|
+
"@scarlett-player/ui": "1.6.0"
|
|
33
35
|
},
|
|
34
36
|
"keywords": [
|
|
35
37
|
"video",
|
|
@@ -53,6 +55,11 @@
|
|
|
53
55
|
"url": "https://github.com/Hackney-Enterprises-Inc/scarlett-player/issues"
|
|
54
56
|
},
|
|
55
57
|
"homepage": "https://scarlettplayer.com",
|
|
58
|
+
"peerDependenciesMeta": {
|
|
59
|
+
"@scarlett-player/ui": {
|
|
60
|
+
"optional": true
|
|
61
|
+
}
|
|
62
|
+
},
|
|
56
63
|
"scripts": {
|
|
57
64
|
"build": "tsup src/index.ts --format esm,cjs --dts",
|
|
58
65
|
"dev": "tsup src/index.ts --format esm,cjs --dts --watch",
|