@scarlett-player/playlist 1.8.0 → 1.9.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 ADDED
@@ -0,0 +1,126 @@
1
+ # @scarlett-player/playlist
2
+
3
+ Playlist plugin for [Scarlett Player](https://scarlettplayer.com). Queue management (add, insert, remove, move, clear), shuffle with a Fisher-Yates order, repeat modes, auto-advance when a track ends, optional localStorage persistence, and control-bar buttons for previous, next and a queue panel when `@scarlett-player/ui` is installed.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @scarlett-player/playlist @scarlett-player/core
9
+ ```
10
+
11
+ `@scarlett-player/core` is a required peer dependency. `@scarlett-player/ui` is an optional peer: the control-bar controls register only when it is present, and everything else works without it.
12
+
13
+ ## Usage
14
+
15
+ This matches the audio example in the root README.
16
+
17
+ ```ts
18
+ import { createPlayer } from '@scarlett-player/core';
19
+ import { createNativePlugin } from '@scarlett-player/native';
20
+ import { createAudioUIPlugin } from '@scarlett-player/audio-ui';
21
+ import { createPlaylistPlugin, type IPlaylistPlugin } from '@scarlett-player/playlist';
22
+ import { createMediaSessionPlugin } from '@scarlett-player/media-session';
23
+
24
+ const player = await createPlayer({
25
+ container: document.getElementById('audio-player'),
26
+ plugins: [
27
+ createNativePlugin(),
28
+ createAudioUIPlugin({ layout: 'full' }),
29
+ createPlaylistPlugin({
30
+ tracks: [
31
+ { id: '1', src: '/track1.mp3', title: 'Track 1', artist: 'Artist', artwork: '/art1.jpg' },
32
+ { id: '2', src: '/track2.mp3', title: 'Track 2', artist: 'Artist', artwork: '/art2.jpg' },
33
+ ],
34
+ }),
35
+ createMediaSessionPlugin(),
36
+ ],
37
+ });
38
+
39
+ // Start the first track through the playlist, not through `src` or
40
+ // player.load(). The playlist owns the current index: play() sets it, writes
41
+ // the track's title and artwork into state, and emits `media:load-request`,
42
+ // which the player loads and plays. Loading a source behind the playlist's
43
+ // back leaves its index pointing at nothing, so next/previous and
44
+ // auto-advance start from the wrong place.
45
+ const playlist = player.getPlugin<IPlaylistPlugin>('playlist');
46
+ playlist?.play(0);
47
+ ```
48
+
49
+ If you do pass `src` to `createPlayer()` for the first track, set `initialIndex: 0` so the playlist knows which track is already loaded.
50
+
51
+ ## Configuration
52
+
53
+ | Option | Type | Default | Description |
54
+ |---|---|---|---|
55
+ | `tracks` | `PlaylistTrack[]` | `[]` | Initial queue. Tracks without an `id` get a generated one |
56
+ | `autoAdvance` | `boolean` | `true` | Select the next track on `playback:ended` |
57
+ | `advanceDelay` | `number` | `0` | Milliseconds to wait before auto-advancing |
58
+ | `autoLoad` | `boolean` | `true` | Emit `media:load-request` with `autoplay: true` whenever the current track changes, so the player loads it. Set to `false` to load from `playlist:change` yourself |
59
+ | `shuffle` | `boolean` | `false` | Initial shuffle state |
60
+ | `repeat` | `'none' \| 'one' \| 'all'` | `'none'` | Initial repeat mode |
61
+ | `initialIndex` | `number` | `-1` | Index of the track already loaded by the player. `-1` means no track is active. Out-of-range values fall back to `-1` |
62
+ | `persist` | `boolean` | `false` | Save tracks, index, shuffle and repeat to localStorage and restore them on init |
63
+ | `persistKey` | `string` | `'scarlett-playlist'` | localStorage key used by `persist` |
64
+ | `preloadNext` | `boolean` | `true` | Accepted and stored, but no code path currently reads it |
65
+
66
+ A `PlaylistTrack` is `{ id, src, title?, artist?, album?, artwork?, duration?, type?, mimeType?, metadata? }` plus any extra properties you want to carry. `type` defaults to `'audio'` in player state when omitted.
67
+
68
+ ## Plugin API
69
+
70
+ Registered under the id `playlist`; retrieve it with `player.getPlugin<IPlaylistPlugin>('playlist')`.
71
+
72
+ | Method | Description |
73
+ |---|---|
74
+ | `add(track \| track[])` | Append to the queue |
75
+ | `insert(index, track)` | Insert at a position (clamped to the queue length) |
76
+ | `remove(idOrIndex)` | Remove by id or index. Removing the current track selects the track that slides into its slot |
77
+ | `move(fromIndex, toIndex)` | Reorder |
78
+ | `clear()` | Empty the queue and reset the index to `-1` |
79
+ | `play(idOrIndex?)` | Select a track. With no argument, resumes the current track or starts the first |
80
+ | `next()` / `previous()` | Step through the queue honouring shuffle and repeat. `previous()` restarts the current track when more than 3 seconds in |
81
+ | `toggleShuffle()` / `setShuffle(enabled)` | Shuffle control. The current track stays first in the new order |
82
+ | `cycleRepeat()` / `setRepeat(mode)` | Repeat control. `cycleRepeat()` goes none, all, one |
83
+ | `getState()` | `{ tracks, currentIndex, currentTrack, shuffle, repeat, shuffleOrder, hasNext, hasPrevious }` |
84
+ | `getTracks()` / `getCurrentTrack()` / `getTrack(id)` | Read the queue |
85
+
86
+ With the player focused, the `N` and `P` keys call `next()` and `previous()`. Keystrokes inside inputs or with a modifier held are left alone.
87
+
88
+ ## Events
89
+
90
+ All payload types live in `PlayerEventMap` in `@scarlett-player/core`.
91
+
92
+ | Event | Payload | When |
93
+ |---|---|---|
94
+ | `playlist:change` | `{ track, index }` | The current track changed. `track` is `null` after `clear()` |
95
+ | `playlist:add` | `{ track, index }` | `add()` (once per track) or `insert()` |
96
+ | `playlist:remove` | `{ track, index }` | `remove()` |
97
+ | `playlist:reorder` | `{ tracks }` | `move()` |
98
+ | `playlist:clear` | `void` | `clear()` |
99
+ | `playlist:shuffle` | `{ enabled }` | Shuffle changed |
100
+ | `playlist:repeat` | `{ mode }` | Repeat mode changed |
101
+ | `playlist:ended` | `void` | The last track ended with nothing to advance to |
102
+
103
+ On every track change the plugin also writes `title`, `poster` (from `artwork`) and `mediaType` into player state, clearing title and poster when the track has none so a previous track's values never leak.
104
+
105
+ ## Control-bar controls
106
+
107
+ When `@scarlett-player/ui` is installed, three controls are registered. Nothing is placed until you list them in the UI layout:
108
+
109
+ ```ts
110
+ uiPlugin({ controls: ['playlist-previous', 'play', 'playlist-next', 'progress', 'spacer', 'playlist', 'fullscreen'] })
111
+ ```
112
+
113
+ | Control id | Renders |
114
+ |---|---|
115
+ | `playlist-previous` / `playlist-next` | Skip buttons, disabled at the ends of the queue |
116
+ | `playlist` | A button that opens the queue as a list |
117
+
118
+ All three hide themselves when the queue has one track or fewer. The classes `PlaylistSkipButton`, `PlaylistPanel` and the `PLAYLIST_ICONS` map are exported for custom layouts.
119
+
120
+ ## Styling
121
+
122
+ A stylesheet is injected once per document (style element id `sp-playlist-styles`). Override these classes to restyle: `sp-playlist-skip` (with `--previous` / `--next` and the `[disabled]` state), `sp-playlist`, `sp-playlist--open`, `sp-playlist__button`, `sp-playlist__panel`, `sp-playlist__item`, `sp-playlist__item--active`, `sp-playlist__position`, `sp-playlist__text`, `sp-playlist__title` and `sp-playlist__artist`. No CSS custom properties are used.
123
+
124
+ ## License
125
+
126
+ MIT
package/dist/index.cjs CHANGED
@@ -37,6 +37,7 @@ __export(index_exports, {
37
37
  default: () => index_default
38
38
  });
39
39
  module.exports = __toCommonJS(index_exports);
40
+ var import_core2 = require("@scarlett-player/core");
40
41
 
41
42
  // src/controls.ts
42
43
  var PLAYLIST_ICONS = {
@@ -197,6 +198,7 @@ var PlaylistPanel = class {
197
198
  };
198
199
 
199
200
  // src/styles.ts
201
+ var import_core = require("@scarlett-player/core");
200
202
  var STYLE_ID = "sp-playlist-styles";
201
203
  var styles = `
202
204
  .sp-playlist-skip[disabled] {
@@ -298,18 +300,11 @@ var styles = `
298
300
  }
299
301
  `;
300
302
  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;
303
+ return (0, import_core.injectSharedStyles)(STYLE_ID, styles);
309
304
  }
310
305
 
311
306
  // src/version.ts
312
- var PKG_VERSION = true ? "1.7.1" : "0.0.0-dev";
307
+ var PKG_VERSION = true ? "1.9.0" : "0.0.0-dev";
313
308
 
314
309
  // src/index.ts
315
310
  var DEFAULT_CONFIG = {
@@ -336,6 +331,9 @@ function shuffleArray(array) {
336
331
  function createPlaylistPlugin(config) {
337
332
  const mergedConfig = { ...DEFAULT_CONFIG, ...config };
338
333
  let api = null;
334
+ let releaseStyles = null;
335
+ let releaseControls = null;
336
+ let lifecycle = 0;
339
337
  let tracks = mergedConfig.tracks || [];
340
338
  let currentIndex = mergedConfig.initialIndex ?? -1;
341
339
  if (currentIndex < -1 || currentIndex >= tracks.length) {
@@ -458,7 +456,7 @@ function createPlaylistPlugin(config) {
458
456
  }
459
457
  const track = tracks[index];
460
458
  currentIndex = index;
461
- api?.logger.info("Track changed", { index, title: track.title, src: track.src });
459
+ api?.logger.info("Track changed", { index, title: track.title, src: (0, import_core2.sanitizeUrl)(track.src) });
462
460
  api?.setState("title", track.title || "");
463
461
  api?.setState("poster", track.artwork || "");
464
462
  api?.setState("mediaType", track.type || "audio");
@@ -474,6 +472,7 @@ function createPlaylistPlugin(config) {
474
472
  type: "feature",
475
473
  description: "Playlist management with shuffle, repeat, and gapless playback",
476
474
  async init(pluginApi) {
475
+ const generation = ++lifecycle;
477
476
  api = pluginApi;
478
477
  api.logger.info("Playlist plugin initialized");
479
478
  loadPersistedPlaylist();
@@ -499,20 +498,31 @@ function createPlaylistPlugin(config) {
499
498
  api?.emit("playlist:ended", void 0);
500
499
  }
501
500
  });
502
- injectStyles();
503
- void import("@scarlett-player/ui").then(({ registerControl }) => {
501
+ releaseStyles = injectStyles();
502
+ const owner = api.container;
503
+ void import("@scarlett-player/ui").then(({ registerControl, unregisterControl }) => {
504
+ if (generation !== lifecycle) return;
504
505
  const self = plugin;
505
506
  registerControl(
506
507
  "playlist-previous",
507
- () => new PlaylistSkipButton(self, "previous")
508
+ () => new PlaylistSkipButton(self, "previous"),
509
+ { owner }
508
510
  );
509
- registerControl("playlist-next", () => new PlaylistSkipButton(self, "next"));
511
+ registerControl("playlist-next", () => new PlaylistSkipButton(self, "next"), {
512
+ owner
513
+ });
510
514
  registerControl(
511
515
  "playlist",
512
516
  () => new PlaylistPanel(self, {
513
517
  onSelect: (index) => self.play(index)
514
- })
518
+ }),
519
+ { owner }
515
520
  );
521
+ releaseControls = () => {
522
+ unregisterControl("playlist-previous", { owner });
523
+ unregisterControl("playlist-next", { owner });
524
+ unregisterControl("playlist", { owner });
525
+ };
516
526
  }).catch(() => {
517
527
  api?.logger.debug("@scarlett-player/ui not present, playlist controls not registered");
518
528
  });
@@ -543,8 +553,13 @@ function createPlaylistPlugin(config) {
543
553
  });
544
554
  },
545
555
  async destroy() {
556
+ lifecycle++;
546
557
  api?.logger.info("Playlist plugin destroying");
547
558
  persistPlaylist();
559
+ releaseStyles?.();
560
+ releaseStyles = null;
561
+ releaseControls?.();
562
+ releaseControls = null;
548
563
  api = null;
549
564
  },
550
565
  add(trackOrTracks) {
package/dist/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ // src/index.ts
2
+ import { sanitizeUrl } from "@scarlett-player/core";
3
+
1
4
  // src/controls.ts
2
5
  var PLAYLIST_ICONS = {
3
6
  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>',
@@ -157,6 +160,7 @@ var PlaylistPanel = class {
157
160
  };
158
161
 
159
162
  // src/styles.ts
163
+ import { injectSharedStyles } from "@scarlett-player/core";
160
164
  var STYLE_ID = "sp-playlist-styles";
161
165
  var styles = `
162
166
  .sp-playlist-skip[disabled] {
@@ -258,18 +262,11 @@ var styles = `
258
262
  }
259
263
  `;
260
264
  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;
265
+ return injectSharedStyles(STYLE_ID, styles);
269
266
  }
270
267
 
271
268
  // src/version.ts
272
- var PKG_VERSION = true ? "1.7.1" : "0.0.0-dev";
269
+ var PKG_VERSION = true ? "1.9.0" : "0.0.0-dev";
273
270
 
274
271
  // src/index.ts
275
272
  var DEFAULT_CONFIG = {
@@ -296,6 +293,9 @@ function shuffleArray(array) {
296
293
  function createPlaylistPlugin(config) {
297
294
  const mergedConfig = { ...DEFAULT_CONFIG, ...config };
298
295
  let api = null;
296
+ let releaseStyles = null;
297
+ let releaseControls = null;
298
+ let lifecycle = 0;
299
299
  let tracks = mergedConfig.tracks || [];
300
300
  let currentIndex = mergedConfig.initialIndex ?? -1;
301
301
  if (currentIndex < -1 || currentIndex >= tracks.length) {
@@ -418,7 +418,7 @@ function createPlaylistPlugin(config) {
418
418
  }
419
419
  const track = tracks[index];
420
420
  currentIndex = index;
421
- api?.logger.info("Track changed", { index, title: track.title, src: track.src });
421
+ api?.logger.info("Track changed", { index, title: track.title, src: sanitizeUrl(track.src) });
422
422
  api?.setState("title", track.title || "");
423
423
  api?.setState("poster", track.artwork || "");
424
424
  api?.setState("mediaType", track.type || "audio");
@@ -434,6 +434,7 @@ function createPlaylistPlugin(config) {
434
434
  type: "feature",
435
435
  description: "Playlist management with shuffle, repeat, and gapless playback",
436
436
  async init(pluginApi) {
437
+ const generation = ++lifecycle;
437
438
  api = pluginApi;
438
439
  api.logger.info("Playlist plugin initialized");
439
440
  loadPersistedPlaylist();
@@ -459,20 +460,31 @@ function createPlaylistPlugin(config) {
459
460
  api?.emit("playlist:ended", void 0);
460
461
  }
461
462
  });
462
- injectStyles();
463
- void import("@scarlett-player/ui").then(({ registerControl }) => {
463
+ releaseStyles = injectStyles();
464
+ const owner = api.container;
465
+ void import("@scarlett-player/ui").then(({ registerControl, unregisterControl }) => {
466
+ if (generation !== lifecycle) return;
464
467
  const self = plugin;
465
468
  registerControl(
466
469
  "playlist-previous",
467
- () => new PlaylistSkipButton(self, "previous")
470
+ () => new PlaylistSkipButton(self, "previous"),
471
+ { owner }
468
472
  );
469
- registerControl("playlist-next", () => new PlaylistSkipButton(self, "next"));
473
+ registerControl("playlist-next", () => new PlaylistSkipButton(self, "next"), {
474
+ owner
475
+ });
470
476
  registerControl(
471
477
  "playlist",
472
478
  () => new PlaylistPanel(self, {
473
479
  onSelect: (index) => self.play(index)
474
- })
480
+ }),
481
+ { owner }
475
482
  );
483
+ releaseControls = () => {
484
+ unregisterControl("playlist-previous", { owner });
485
+ unregisterControl("playlist-next", { owner });
486
+ unregisterControl("playlist", { owner });
487
+ };
476
488
  }).catch(() => {
477
489
  api?.logger.debug("@scarlett-player/ui not present, playlist controls not registered");
478
490
  });
@@ -503,8 +515,13 @@ function createPlaylistPlugin(config) {
503
515
  });
504
516
  },
505
517
  async destroy() {
518
+ lifecycle++;
506
519
  api?.logger.info("Playlist plugin destroying");
507
520
  persistPlaylist();
521
+ releaseStyles?.();
522
+ releaseStyles = null;
523
+ releaseControls?.();
524
+ releaseControls = null;
508
525
  api = null;
509
526
  },
510
527
  add(trackOrTracks) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scarlett-player/playlist",
3
- "version": "1.8.0",
3
+ "version": "1.9.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,16 +22,16 @@
22
22
  "dist"
23
23
  ],
24
24
  "peerDependencies": {
25
- "@scarlett-player/core": "^1.7.0",
26
- "@scarlett-player/ui": "^1.7.0"
25
+ "@scarlett-player/core": "^1.8.0",
26
+ "@scarlett-player/ui": "^1.8.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "tsup": "^8.0.0",
30
30
  "typescript": "^5.3.0",
31
31
  "vitest": "^1.6.0",
32
32
  "jsdom": "^24.0.0",
33
- "@scarlett-player/core": "1.8.0",
34
- "@scarlett-player/ui": "1.8.0"
33
+ "@scarlett-player/core": "1.9.0",
34
+ "@scarlett-player/ui": "1.9.0"
35
35
  },
36
36
  "keywords": [
37
37
  "video",
@@ -65,6 +65,6 @@
65
65
  "dev": "tsup --watch",
66
66
  "test": "vitest --run",
67
67
  "test:watch": "vitest",
68
- "typecheck": "tsc --noEmit"
68
+ "typecheck": "tsc --noEmit -p tsconfig.typecheck.json"
69
69
  }
70
70
  }