gapless.js 2.2.3 → 4.0.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/package.json CHANGED
@@ -1,50 +1,53 @@
1
1
  {
2
2
  "name": "gapless.js",
3
- "version": "2.2.3",
3
+ "version": "4.0.0",
4
4
  "description": "Gapless audio playback javascript plugin",
5
- "main": "index.js",
6
- "types": "index.d.ts",
7
- "scripts": {
8
- "clean": "rimraf index.d.ts index.js",
9
- "build": "tsc",
10
- "prelint": "npm run clean",
11
- "lint": "eslint --fix \"*.{js,ts}\"",
12
- "prepublishOnly": "npm run lint && npm run build && pinst --disable",
13
- "_postinstall": "husky install",
14
- "postpublish": "pinst --enable"
5
+ "type": "module",
6
+ "main": "dist/index.mjs",
7
+ "module": "dist/index.mjs",
8
+ "types": "dist/index.d.ts",
9
+ "files": [
10
+ "dist",
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "exports": {
15
+ "./package.json": "./package.json",
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.mjs"
19
+ }
15
20
  },
16
- "lint-staged": {
17
- "*.js": [
18
- "eslint --fix"
19
- ],
20
- "*.ts": [
21
- "eslint --fix"
22
- ]
21
+ "sideEffects": false,
22
+ "private": false,
23
+ "scripts": {
24
+ "test": "vitest run",
25
+ "build": "tsup",
26
+ "build:demo": "pnpm --dir demo build",
27
+ "dev": "pnpm --dir demo dev",
28
+ "types": "tsc --noEmit",
29
+ "test:watch": "vitest",
30
+ "test:coverage": "vitest run --coverage"
23
31
  },
24
32
  "repository": {
25
33
  "type": "git",
26
- "url": "git+https://github.com/jgeurts/gapless.js.git"
34
+ "url": "git+https://github.com/RelistenNet/gapless.js.git"
27
35
  },
28
- "author": "Daniel Saewitz, Jim Geurts",
36
+ "author": "Daniel Saewitz",
29
37
  "license": "MIT",
30
38
  "devDependencies": {
31
- "@typescript-eslint/eslint-plugin": "^4.8.2",
32
- "@typescript-eslint/parser": "^4.8.2",
33
- "eslint": "^7.13.0",
34
- "eslint-config-airbnb-base": "^14.2.1",
35
- "eslint-config-airbnb-typescript": "^12.0.0",
36
- "eslint-config-prettier": "^6.15.0",
37
- "eslint-plugin-import": "^2.22.1",
38
- "eslint-plugin-jsdoc": "^30.7.8",
39
- "eslint-plugin-mocha": "^8.0.0",
40
- "eslint-plugin-prettier": "^3.1.4",
41
- "eslint-plugin-promise": "^4.2.1",
42
- "eslint-plugin-security": "^1.4.0",
43
- "husky": "^5.0.4",
44
- "lint-staged": "^10.5.2",
45
- "pinst": "^2.1.1",
46
- "prettier": "^2.2.1",
47
- "rimraf": "^3.0.2",
48
- "typescript": "^4.1.2"
39
+ "@playwright/test": "^1.58.2",
40
+ "@switz/eslint-config": "^12.5.2",
41
+ "@vitest/coverage-v8": "^4.0.18",
42
+ "eslint": "^9.25.1",
43
+ "happy-dom": "^20.7.0",
44
+ "playwright": "^1.58.2",
45
+ "tsup": "^8.5.1",
46
+ "typescript": "^5.9.3",
47
+ "vitest": "^4.0.18"
48
+ },
49
+ "packageManager": "pnpm@10.30.2",
50
+ "dependencies": {
51
+ "xstate": "^5.28.0"
49
52
  }
50
53
  }
package/src/Queue.ts ADDED
@@ -0,0 +1,459 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Queue — public API class; orchestrates tracks via QueueMachine
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { createActor } from 'xstate';
6
+ import { getAudioContext, resumeAudioContext as _resumeAudioContext } from './utils/audioContext';
7
+ import {
8
+ setupMediaSession,
9
+ updateMediaSessionMetadata,
10
+ updateMediaSessionPlaybackState,
11
+ } from './utils/mediaSession';
12
+ import { createQueueMachine } from './machines/queue.machine';
13
+ import { Track } from './Track';
14
+ import type { TrackQueueRef } from './Track';
15
+ import type { GaplessOptions, AddTrackOptions, TrackInfo, TrackMetadata } from './types';
16
+
17
+ /** Maximum number of tracks to preload ahead of the current track. */
18
+ const PRELOAD_AHEAD = 2;
19
+
20
+ export class Queue implements TrackQueueRef {
21
+ private _tracks: Track[] = [];
22
+ private readonly _actor;
23
+
24
+ private readonly _onProgress?: (info: TrackInfo) => void;
25
+ private readonly _onEnded?: () => void;
26
+ private readonly _onPlayNextTrack?: (info: TrackInfo) => void;
27
+ private readonly _onPlayPreviousTrack?: (info: TrackInfo) => void;
28
+ private readonly _onStartNewTrack?: (info: TrackInfo) => void;
29
+ private readonly _onError?: (error: Error) => void;
30
+ private readonly _onPlayBlocked?: () => void;
31
+ private readonly _onDebug?: (msg: string) => void;
32
+
33
+ readonly webAudioIsDisabled: boolean;
34
+
35
+ private _volume: number;
36
+
37
+ /** Track indices for which a gapless start has been pre-scheduled. */
38
+ private _scheduledIndices = new Set<number>();
39
+
40
+ constructor(options: GaplessOptions = {}) {
41
+ const {
42
+ tracks = [],
43
+ onProgress,
44
+ onEnded,
45
+ onPlayNextTrack,
46
+ onPlayPreviousTrack,
47
+ onStartNewTrack,
48
+ onError,
49
+ onPlayBlocked,
50
+ onDebug,
51
+ webAudioIsDisabled = false,
52
+ trackMetadata = [],
53
+ volume: initialVolume = 1,
54
+ } = options;
55
+
56
+ this._volume = Math.min(1, Math.max(0, initialVolume));
57
+ this.webAudioIsDisabled = webAudioIsDisabled;
58
+ this._onProgress = onProgress;
59
+ this._onEnded = onEnded;
60
+ this._onPlayNextTrack = onPlayNextTrack;
61
+ this._onPlayPreviousTrack = onPlayPreviousTrack;
62
+ this._onStartNewTrack = onStartNewTrack;
63
+ this._onError = onError;
64
+ this._onPlayBlocked = onPlayBlocked;
65
+ this._onDebug = onDebug;
66
+
67
+ this._tracks = tracks.map(
68
+ (url, i) =>
69
+ new Track({
70
+ trackUrl: url,
71
+ index: i,
72
+ queue: this,
73
+ metadata: trackMetadata[i],
74
+ })
75
+ );
76
+
77
+ this._actor = createActor(
78
+ createQueueMachine({
79
+ currentTrackIndex: 0,
80
+ trackCount: this._tracks.length,
81
+ })
82
+ );
83
+
84
+ this._actor.subscribe((snapshot) => {
85
+ updateMediaSessionPlaybackState(snapshot.value === 'playing');
86
+ });
87
+
88
+ this._actor.start();
89
+
90
+ setupMediaSession({
91
+ onPlay: () => this.play(),
92
+ onPause: () => {
93
+ if (this._actor.getSnapshot().value === 'playing') this.pause();
94
+ },
95
+ onNext: () => this.next(),
96
+ onPrevious: () => this.previous(),
97
+ onSeek: (t) => this.seek(t),
98
+ });
99
+ }
100
+
101
+ // --------------------------------------------------------------------------
102
+ // Public API
103
+ // --------------------------------------------------------------------------
104
+
105
+ play(): void {
106
+ const ct = this._currentTrack;
107
+ if (!ct) return;
108
+ ct.play();
109
+ this._actor.send({ type: 'PLAY' });
110
+ updateMediaSessionMetadata(ct.metadata);
111
+ this._preloadAhead(ct.index);
112
+ this._tryScheduleGapless(ct);
113
+ }
114
+
115
+ pause(): void {
116
+ this._actor.send({ type: 'PAUSE' });
117
+ this._cancelScheduledGapless();
118
+ this._currentTrack?.pause();
119
+ }
120
+
121
+ togglePlayPause(): void {
122
+ if (this._actor.getSnapshot().value === 'playing') {
123
+ this.pause();
124
+ } else {
125
+ this.play();
126
+ }
127
+ }
128
+
129
+ next(): void {
130
+ const snap = this._actor.getSnapshot();
131
+ const nextIndex = snap.context.currentTrackIndex + 1;
132
+ if (nextIndex >= this._tracks.length) return;
133
+
134
+ this._deactivateCurrent();
135
+ this._cancelAllScheduledGapless();
136
+ this._actor.send({ type: 'NEXT' });
137
+ this._activateCurrent(true);
138
+
139
+ const cur = this._currentTrack;
140
+ if (cur) {
141
+ this._onStartNewTrack?.(cur.toInfo());
142
+ this._onPlayNextTrack?.(cur.toInfo());
143
+ updateMediaSessionMetadata(cur.metadata);
144
+ }
145
+ this._preloadAhead(this._actor.getSnapshot().context.currentTrackIndex);
146
+ }
147
+
148
+ previous(): void {
149
+ const ct = this._currentTrack;
150
+ if (ct && ct.currentTime > 8) {
151
+ ct.seek(0);
152
+ ct.play();
153
+ return;
154
+ }
155
+
156
+ this._deactivateCurrent();
157
+ this._cancelAllScheduledGapless();
158
+ this._actor.send({ type: 'PREVIOUS' });
159
+ this._activateCurrent(true);
160
+
161
+ const cur = this._currentTrack;
162
+ if (cur) {
163
+ this._onStartNewTrack?.(cur.toInfo());
164
+ this._onPlayPreviousTrack?.(cur.toInfo());
165
+ updateMediaSessionMetadata(cur.metadata);
166
+ }
167
+ }
168
+
169
+ gotoTrack(index: number, playImmediately = false): void {
170
+ if (index < 0 || index >= this._tracks.length) return;
171
+ const prevSnap = this._actor.getSnapshot();
172
+ this.onDebug(
173
+ `gotoTrack(${index}, playImmediately=${playImmediately}) queueState=${prevSnap.value} curIdx=${prevSnap.context.currentTrackIndex}`
174
+ );
175
+ this._deactivateCurrent();
176
+ this._cancelAllScheduledGapless();
177
+ this._actor.send({ type: 'GOTO', index, playImmediately });
178
+ const afterSnap = this._actor.getSnapshot();
179
+ this.onDebug(
180
+ `gotoTrack after GOTO → queueState=${afterSnap.value} curIdx=${afterSnap.context.currentTrackIndex}`
181
+ );
182
+
183
+ if (playImmediately) {
184
+ this._activateCurrent(true);
185
+ const cur = this._currentTrack;
186
+ if (cur) {
187
+ this.onDebug(
188
+ `gotoTrack activateCurrent done, track=${cur.index} trackState=${cur.playbackType} isPlaying=${cur.isPlaying}`
189
+ );
190
+ this._onStartNewTrack?.(cur.toInfo());
191
+ updateMediaSessionMetadata(cur.metadata);
192
+ }
193
+ this._preloadAhead(index);
194
+ } else {
195
+ this._currentTrack?.seek(0);
196
+ }
197
+ }
198
+
199
+ seek(time: number): void {
200
+ this._currentTrack?.seek(time);
201
+ this._cancelAndRescheduleGapless();
202
+ }
203
+
204
+ setVolume(volume: number): void {
205
+ const clamped = Math.min(1, Math.max(0, volume));
206
+ this._volume = clamped;
207
+ for (const track of this._tracks) track.setVolume(clamped);
208
+ }
209
+
210
+ addTrack(url: string, options: AddTrackOptions = {}): void {
211
+ const index = this._tracks.length;
212
+ const metadata = options.metadata ?? ({} as TrackMetadata);
213
+ this._tracks.push(
214
+ new Track({
215
+ trackUrl: url,
216
+ index,
217
+ queue: this,
218
+ skipHEAD: options.skipHEAD,
219
+ metadata,
220
+ })
221
+ );
222
+ this._actor.send({ type: 'ADD_TRACK' });
223
+ }
224
+
225
+ removeTrack(index: number): void {
226
+ if (index < 0 || index >= this._tracks.length) return;
227
+ this._tracks[index].destroy();
228
+ this._tracks.splice(index, 1);
229
+ for (let i = index; i < this._tracks.length; i++) {
230
+ (this._tracks[i] as unknown as { index: number }).index = i;
231
+ }
232
+ this._scheduledIndices.delete(index);
233
+ this._actor.send({ type: 'REMOVE_TRACK', index });
234
+ }
235
+
236
+ resumeAudioContext(): Promise<void> {
237
+ return _resumeAudioContext();
238
+ }
239
+
240
+ destroy(): void {
241
+ for (const track of this._tracks) track.destroy();
242
+ this._tracks = [];
243
+ this._actor.stop();
244
+ }
245
+
246
+ // --------------------------------------------------------------------------
247
+ // Getters
248
+ // --------------------------------------------------------------------------
249
+
250
+ get currentTrack(): TrackInfo | undefined {
251
+ return this._currentTrack?.toInfo();
252
+ }
253
+
254
+ get currentTrackIndex(): number {
255
+ return this._actor.getSnapshot().context.currentTrackIndex;
256
+ }
257
+
258
+ get tracks(): readonly TrackInfo[] {
259
+ return this._tracks.map((t) => t.toInfo());
260
+ }
261
+
262
+ get isPlaying(): boolean {
263
+ return this._actor.getSnapshot().value === 'playing';
264
+ }
265
+
266
+ get isPaused(): boolean {
267
+ return this._actor.getSnapshot().value === 'paused';
268
+ }
269
+
270
+ get volume(): number {
271
+ return this._volume;
272
+ }
273
+
274
+ /** Snapshot of the queue state machine (state name + context). For debugging. */
275
+ get queueSnapshot(): { state: string; context: { currentTrackIndex: number; trackCount: number } } {
276
+ const snap = this._actor.getSnapshot();
277
+ return { state: snap.value as string, context: snap.context };
278
+ }
279
+
280
+ // --------------------------------------------------------------------------
281
+ // TrackQueueRef — called by Track instances
282
+ // --------------------------------------------------------------------------
283
+
284
+ onTrackEnded(track: Track): void {
285
+ const snap = this._actor.getSnapshot();
286
+ this.onDebug(
287
+ `onTrackEnded track=${track.index} queueState=${snap.value} curIdx=${snap.context.currentTrackIndex}`
288
+ );
289
+ if (track.index !== snap.context.currentTrackIndex) return;
290
+
291
+ // Deactivate the finished track so its currentTime resets to 0
292
+ track.deactivate();
293
+
294
+ this._actor.send({ type: 'TRACK_ENDED' });
295
+ const newSnap = this._actor.getSnapshot();
296
+ this.onDebug(
297
+ `onTrackEnded after TRACK_ENDED → queueState=${newSnap.value} curIdx=${newSnap.context.currentTrackIndex}`
298
+ );
299
+
300
+ if (newSnap.value === 'ended') {
301
+ this._onEnded?.();
302
+ return;
303
+ }
304
+
305
+ if (newSnap.value === 'playing') {
306
+ const cur = this._currentTrack;
307
+ if (cur) {
308
+ if (!this._scheduledIndices.has(cur.index)) {
309
+ cur.play();
310
+ } else {
311
+ this.onDebug(
312
+ `onTrackEnded: gapless track ${cur.index} — sourceNode=${cur.hasSourceNode} isPlaying=${cur.isPlaying} machineState=${cur.machineState}`
313
+ );
314
+ cur.startProgressLoop();
315
+ }
316
+ this._onStartNewTrack?.(cur.toInfo());
317
+ this._onPlayNextTrack?.(cur.toInfo());
318
+ updateMediaSessionMetadata(cur.metadata);
319
+ this._preloadAhead(cur.index);
320
+ }
321
+ }
322
+
323
+ if (newSnap.value === 'paused') {
324
+ const cur = this._currentTrack;
325
+ if (cur) {
326
+ this._onStartNewTrack?.(cur.toInfo());
327
+ updateMediaSessionMetadata(cur.metadata);
328
+ }
329
+ }
330
+ }
331
+
332
+ onTrackBufferReady(track: Track): void {
333
+ this._actor.send({ type: 'TRACK_LOADED', index: track.index });
334
+ this._tryScheduleGapless(track);
335
+ this._preloadAhead(this._actor.getSnapshot().context.currentTrackIndex);
336
+ }
337
+
338
+ onProgress(info: TrackInfo): void {
339
+ if (info.index !== this._actor.getSnapshot().context.currentTrackIndex) return;
340
+ this._onProgress?.(info);
341
+ }
342
+
343
+ onError(error: Error): void {
344
+ this._onError?.(error);
345
+ }
346
+
347
+ onPlayBlocked(): void {
348
+ this._onPlayBlocked?.();
349
+ }
350
+
351
+ onDebug(msg: string): void {
352
+ this._onDebug?.(msg);
353
+ }
354
+
355
+ // --------------------------------------------------------------------------
356
+ // Private helpers
357
+ // --------------------------------------------------------------------------
358
+
359
+ private get _currentTrack(): Track | undefined {
360
+ return this._tracks[this._actor.getSnapshot().context.currentTrackIndex];
361
+ }
362
+
363
+ private _deactivateCurrent(): void {
364
+ this._currentTrack?.deactivate();
365
+ }
366
+
367
+ private _activateCurrent(startPlaying: boolean): void {
368
+ const track = this._currentTrack;
369
+ if (!track) return;
370
+ track.activate();
371
+ if (startPlaying && !this._scheduledIndices.has(track.index)) {
372
+ track.play();
373
+ }
374
+ }
375
+
376
+ private _preloadAhead(fromIndex: number): void {
377
+ const limit = fromIndex + PRELOAD_AHEAD + 1;
378
+ this.onDebug(`_preloadAhead(${fromIndex}) limit=${limit} trackCount=${this._tracks.length}`);
379
+ for (let i = fromIndex + 1; i < this._tracks.length && i < limit; i++) {
380
+ const t = this._tracks[i];
381
+ if (!t.isBufferLoaded) {
382
+ this.onDebug(`_preloadAhead: starting preload for track ${i}`);
383
+ t.preload();
384
+ break;
385
+ } else {
386
+ this.onDebug(`_preloadAhead: track ${i} already loaded`);
387
+ }
388
+ }
389
+ }
390
+
391
+ private _cancelScheduledGapless(): void {
392
+ const curIndex = this._actor.getSnapshot().context.currentTrackIndex;
393
+ const nextIndex = curIndex + 1;
394
+ if (nextIndex < this._tracks.length && this._scheduledIndices.has(nextIndex)) {
395
+ this._tracks[nextIndex].cancelGaplessStart();
396
+ this._scheduledIndices.delete(nextIndex);
397
+ this.onDebug(`_cancelScheduledGapless: cancelled track ${nextIndex}`);
398
+ }
399
+ }
400
+
401
+ private _cancelAllScheduledGapless(): void {
402
+ for (const idx of this._scheduledIndices) {
403
+ this._tracks[idx]?.cancelGaplessStart();
404
+ }
405
+ this._scheduledIndices.clear();
406
+ }
407
+
408
+ private _cancelAndRescheduleGapless(): void {
409
+ this._cancelScheduledGapless();
410
+ const current = this._currentTrack;
411
+ if (current) {
412
+ this._tryScheduleGapless(current);
413
+ }
414
+ }
415
+
416
+ private _tryScheduleGapless(_fromTrack: Track): void {
417
+ const ctx = getAudioContext();
418
+ if (!ctx || this.webAudioIsDisabled) return;
419
+
420
+ const snap = this._actor.getSnapshot();
421
+ const curIndex = snap.context.currentTrackIndex;
422
+ const nextIndex = curIndex + 1;
423
+ if (nextIndex >= this._tracks.length) return;
424
+
425
+ const current = this._tracks[curIndex];
426
+ const next = this._tracks[nextIndex];
427
+
428
+ if (
429
+ !current.isBufferLoaded ||
430
+ !next.isBufferLoaded ||
431
+ this._scheduledIndices.has(nextIndex) ||
432
+ !current.isPlaying
433
+ )
434
+ return;
435
+
436
+ const endTime = this._computeTrackEndTime(current);
437
+ if (endTime === null) return;
438
+
439
+ if (endTime < ctx.currentTime + 0.01) return;
440
+
441
+ next.scheduleGaplessStart(endTime);
442
+ this._scheduledIndices.add(nextIndex);
443
+ }
444
+
445
+ private _computeTrackEndTime(track: Track): number | null {
446
+ const ctx = getAudioContext();
447
+ if (!ctx || !track.isBufferLoaded) return null;
448
+ const duration = track.duration;
449
+ if (isNaN(duration)) return null;
450
+
451
+ if (track.scheduledStartContextTime !== null) {
452
+ return track.scheduledStartContextTime + duration;
453
+ }
454
+
455
+ const remaining = duration - track.currentTime;
456
+ if (remaining <= 0) return null;
457
+ return ctx.currentTime + remaining;
458
+ }
459
+ }