@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 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.5.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.5.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",