gapless.js 4.0.1 → 4.0.3

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,6 +1,6 @@
1
1
  {
2
2
  "name": "gapless.js",
3
- "version": "4.0.1",
3
+ "version": "4.0.3",
4
4
  "description": "Gapless audio playback javascript plugin",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
package/src/Queue.ts CHANGED
@@ -36,8 +36,8 @@ export class Queue implements TrackQueueRef {
36
36
 
37
37
  private _volume: number;
38
38
 
39
- /** Track indices for which a gapless start has been pre-scheduled. */
40
- private _scheduledIndices = new Set<number>();
39
+ /** Index of the next track with a pre-scheduled gapless start, or null. */
40
+ private _scheduledNextIndex: number | null = null;
41
41
 
42
42
  private _throttledUpdatePositionState = throttle(
43
43
  (duration: number, currentTime: number) =>
@@ -82,12 +82,94 @@ export class Queue implements TrackQueueRef {
82
82
  })
83
83
  );
84
84
 
85
- this._actor = createActor(
86
- createQueueMachine({
87
- currentTrackIndex: 0,
88
- trackCount: this._tracks.length,
89
- })
90
- );
85
+ // -----------------------------------------------------------------------
86
+ // Wire up the queue machine with real action implementations.
87
+ //
88
+ // IMPORTANT: actions receive ({ context }) which reflects the in-progress
89
+ // context (updated by prior assign() calls within the same transition).
90
+ // Do NOT use this._actor.getSnapshot() inside actions — that returns the
91
+ // pre-transition snapshot and won't reflect intermediate assign() updates.
92
+ // -----------------------------------------------------------------------
93
+ const machine = createQueueMachine({
94
+ currentTrackIndex: 0,
95
+ trackCount: this._tracks.length
96
+ }).provide({
97
+ actions: {
98
+ deactivateCurrent: ({ context }) => {
99
+ this._trackAt(context.currentTrackIndex)?.deactivate();
100
+ },
101
+ deactivateEndedTrack: ({ context }) => {
102
+ this._trackAt(context.currentTrackIndex)?.deactivate();
103
+ },
104
+ activateAndPlayCurrent: ({ context }) => {
105
+ const track = this._trackAt(context.currentTrackIndex);
106
+ if (!track) return;
107
+ track.activate();
108
+ if (this._scheduledNextIndex !== track.index) {
109
+ track.play();
110
+ }
111
+ },
112
+ playOrContinueGapless: ({ context }) => {
113
+ const cur = this._trackAt(context.currentTrackIndex);
114
+ if (!cur) return;
115
+ if (this._scheduledNextIndex !== cur.index) {
116
+ cur.play();
117
+ } else {
118
+ this._scheduledNextIndex = null;
119
+ this.onDebug(
120
+ `onTrackEnded: gapless track ${cur.index} — sourceNode=${cur.hasSourceNode} isPlaying=${cur.isPlaying} machineState=${cur.machineState}`
121
+ );
122
+ cur.startProgressLoop();
123
+ }
124
+ },
125
+ cancelAllGapless: () => this._cancelScheduledGapless(),
126
+ notifyStartNewTrack: ({ context }) => {
127
+ const cur = this._trackAt(context.currentTrackIndex);
128
+ if (cur) this._onStartNewTrack?.(cur.toInfo());
129
+ },
130
+ notifyPlayNextTrack: ({ context }) => {
131
+ const cur = this._trackAt(context.currentTrackIndex);
132
+ if (cur) this._onPlayNextTrack?.(cur.toInfo());
133
+ },
134
+ notifyPlayPreviousTrack: ({ context }) => {
135
+ const cur = this._trackAt(context.currentTrackIndex);
136
+ if (cur) this._onPlayPreviousTrack?.(cur.toInfo());
137
+ },
138
+ notifyEnded: () => this._onEnded?.(),
139
+ updateMediaSessionMetadata: ({ context }) => {
140
+ const cur = this._trackAt(context.currentTrackIndex);
141
+ if (cur) updateMediaSessionMetadata(cur.metadata);
142
+ },
143
+ preloadAhead: ({ context }) => {
144
+ this._preloadAhead(context.currentTrackIndex);
145
+ },
146
+ playCurrent: ({ context }) => {
147
+ this._trackAt(context.currentTrackIndex)?.play();
148
+ },
149
+ pauseCurrent: ({ context }) => {
150
+ this._trackAt(context.currentTrackIndex)?.pause();
151
+ },
152
+ seekCurrent: ({ context, event }) => {
153
+ const e = event as { type: 'SEEK'; time: number };
154
+ this._trackAt(context.currentTrackIndex)?.seek(e.time);
155
+ },
156
+ seekCurrentToZero: ({ context }) => {
157
+ this._trackAt(context.currentTrackIndex)?.seek(0);
158
+ },
159
+ scheduleGapless: ({ context }) => {
160
+ this._tryScheduleGapless(context.currentTrackIndex);
161
+ },
162
+ cancelScheduledGapless: () => {
163
+ this._cancelScheduledGapless();
164
+ },
165
+ cancelAndRescheduleGapless: ({ context }) => {
166
+ this._cancelScheduledGapless();
167
+ this._tryScheduleGapless(context.currentTrackIndex);
168
+ },
169
+ },
170
+ });
171
+
172
+ this._actor = createActor(machine);
91
173
 
92
174
  this._actor.subscribe((snapshot) => {
93
175
  updateMediaSessionPlaybackState(snapshot.value === 'playing');
@@ -111,19 +193,12 @@ export class Queue implements TrackQueueRef {
111
193
  // --------------------------------------------------------------------------
112
194
 
113
195
  play(): void {
114
- const ct = this._currentTrack;
115
- if (!ct) return;
116
- ct.play();
196
+ if (!this._currentTrack) return;
117
197
  this._actor.send({ type: 'PLAY' });
118
- updateMediaSessionMetadata(ct.metadata);
119
- this._preloadAhead(ct.index);
120
- this._tryScheduleGapless(ct);
121
198
  }
122
199
 
123
200
  pause(): void {
124
201
  this._actor.send({ type: 'PAUSE' });
125
- this._cancelScheduledGapless();
126
- this._currentTrack?.pause();
127
202
  }
128
203
 
129
204
  togglePlayPause(): void {
@@ -139,18 +214,7 @@ export class Queue implements TrackQueueRef {
139
214
  const nextIndex = snap.context.currentTrackIndex + 1;
140
215
  if (nextIndex >= this._tracks.length) return;
141
216
 
142
- this._deactivateCurrent();
143
- this._cancelAllScheduledGapless();
144
217
  this._actor.send({ type: 'NEXT' });
145
- this._activateCurrent(true);
146
-
147
- const cur = this._currentTrack;
148
- if (cur) {
149
- this._onStartNewTrack?.(cur.toInfo());
150
- this._onPlayNextTrack?.(cur.toInfo());
151
- updateMediaSessionMetadata(cur.metadata);
152
- }
153
- this._preloadAhead(this._actor.getSnapshot().context.currentTrackIndex);
154
218
  }
155
219
 
156
220
  previous(): void {
@@ -161,52 +225,19 @@ export class Queue implements TrackQueueRef {
161
225
  return;
162
226
  }
163
227
 
164
- this._deactivateCurrent();
165
- this._cancelAllScheduledGapless();
166
228
  this._actor.send({ type: 'PREVIOUS' });
167
- this._activateCurrent(true);
168
-
169
- const cur = this._currentTrack;
170
- if (cur) {
171
- this._onStartNewTrack?.(cur.toInfo());
172
- this._onPlayPreviousTrack?.(cur.toInfo());
173
- updateMediaSessionMetadata(cur.metadata);
174
- }
175
229
  }
176
230
 
177
231
  gotoTrack(index: number, playImmediately = false): void {
178
232
  if (index < 0 || index >= this._tracks.length) return;
179
- const prevSnap = this._actor.getSnapshot();
180
233
  this.onDebug(
181
- `gotoTrack(${index}, playImmediately=${playImmediately}) queueState=${prevSnap.value} curIdx=${prevSnap.context.currentTrackIndex}`
234
+ `gotoTrack(${index}, playImmediately=${playImmediately}) queueState=${this._actor.getSnapshot().value} curIdx=${this._actor.getSnapshot().context.currentTrackIndex}`
182
235
  );
183
- this._deactivateCurrent();
184
- this._cancelAllScheduledGapless();
185
236
  this._actor.send({ type: 'GOTO', index, playImmediately });
186
- const afterSnap = this._actor.getSnapshot();
187
- this.onDebug(
188
- `gotoTrack after GOTO → queueState=${afterSnap.value} curIdx=${afterSnap.context.currentTrackIndex}`
189
- );
190
-
191
- if (playImmediately) {
192
- this._activateCurrent(true);
193
- const cur = this._currentTrack;
194
- if (cur) {
195
- this.onDebug(
196
- `gotoTrack activateCurrent done, track=${cur.index} trackState=${cur.playbackType} isPlaying=${cur.isPlaying}`
197
- );
198
- this._onStartNewTrack?.(cur.toInfo());
199
- updateMediaSessionMetadata(cur.metadata);
200
- }
201
- this._preloadAhead(index);
202
- } else {
203
- this._currentTrack?.seek(0);
204
- }
205
237
  }
206
238
 
207
239
  seek(time: number): void {
208
- this._currentTrack?.seek(time);
209
- this._cancelAndRescheduleGapless();
240
+ this._actor.send({ type: 'SEEK', time });
210
241
  }
211
242
 
212
243
  setVolume(volume: number): void {
@@ -237,7 +268,9 @@ export class Queue implements TrackQueueRef {
237
268
  for (let i = index; i < this._tracks.length; i++) {
238
269
  (this._tracks[i] as unknown as { index: number }).index = i;
239
270
  }
240
- this._scheduledIndices.delete(index);
271
+ if (this._scheduledNextIndex === index) {
272
+ this._scheduledNextIndex = null;
273
+ }
241
274
  this._actor.send({ type: 'REMOVE_TRACK', index });
242
275
  }
243
276
 
@@ -296,51 +329,15 @@ export class Queue implements TrackQueueRef {
296
329
  );
297
330
  if (track.index !== snap.context.currentTrackIndex) return;
298
331
 
299
- // Deactivate the finished track so its currentTime resets to 0
300
- track.deactivate();
301
-
302
332
  this._actor.send({ type: 'TRACK_ENDED' });
303
333
  const newSnap = this._actor.getSnapshot();
304
334
  this.onDebug(
305
335
  `onTrackEnded after TRACK_ENDED → queueState=${newSnap.value} curIdx=${newSnap.context.currentTrackIndex}`
306
336
  );
307
-
308
- if (newSnap.value === 'ended') {
309
- this._onEnded?.();
310
- return;
311
- }
312
-
313
- if (newSnap.value === 'playing') {
314
- const cur = this._currentTrack;
315
- if (cur) {
316
- if (!this._scheduledIndices.has(cur.index)) {
317
- cur.play();
318
- } else {
319
- this.onDebug(
320
- `onTrackEnded: gapless track ${cur.index} — sourceNode=${cur.hasSourceNode} isPlaying=${cur.isPlaying} machineState=${cur.machineState}`
321
- );
322
- cur.startProgressLoop();
323
- }
324
- this._onStartNewTrack?.(cur.toInfo());
325
- this._onPlayNextTrack?.(cur.toInfo());
326
- updateMediaSessionMetadata(cur.metadata);
327
- this._preloadAhead(cur.index);
328
- }
329
- }
330
-
331
- if (newSnap.value === 'paused') {
332
- const cur = this._currentTrack;
333
- if (cur) {
334
- this._onStartNewTrack?.(cur.toInfo());
335
- updateMediaSessionMetadata(cur.metadata);
336
- }
337
- }
338
337
  }
339
338
 
340
339
  onTrackBufferReady(track: Track): void {
341
340
  this._actor.send({ type: 'TRACK_LOADED', index: track.index });
342
- this._tryScheduleGapless(track);
343
- this._preloadAhead(this._actor.getSnapshot().context.currentTrackIndex);
344
341
  }
345
342
 
346
343
  onProgress(info: TrackInfo): void {
@@ -367,21 +364,13 @@ export class Queue implements TrackQueueRef {
367
364
  // Private helpers
368
365
  // --------------------------------------------------------------------------
369
366
 
370
- private get _currentTrack(): Track | undefined {
371
- return this._tracks[this._actor.getSnapshot().context.currentTrackIndex];
367
+ /** Look up a track by index — safe for use inside machine actions. */
368
+ private _trackAt(index: number): Track | undefined {
369
+ return this._tracks[index];
372
370
  }
373
371
 
374
- private _deactivateCurrent(): void {
375
- this._currentTrack?.deactivate();
376
- }
377
-
378
- private _activateCurrent(startPlaying: boolean): void {
379
- const track = this._currentTrack;
380
- if (!track) return;
381
- track.activate();
382
- if (startPlaying && !this._scheduledIndices.has(track.index)) {
383
- track.play();
384
- }
372
+ private get _currentTrack(): Track | undefined {
373
+ return this._tracks[this._actor.getSnapshot().context.currentTrackIndex];
385
374
  }
386
375
 
387
376
  private _preloadAhead(fromIndex: number): void {
@@ -400,36 +389,19 @@ export class Queue implements TrackQueueRef {
400
389
  }
401
390
 
402
391
  private _cancelScheduledGapless(): void {
403
- const curIndex = this._actor.getSnapshot().context.currentTrackIndex;
404
- const nextIndex = curIndex + 1;
405
- if (nextIndex < this._tracks.length && this._scheduledIndices.has(nextIndex)) {
406
- this._tracks[nextIndex].cancelGaplessStart();
407
- this._scheduledIndices.delete(nextIndex);
408
- this.onDebug(`_cancelScheduledGapless: cancelled track ${nextIndex}`);
409
- }
410
- }
411
-
412
- private _cancelAllScheduledGapless(): void {
413
- for (const idx of this._scheduledIndices) {
414
- this._tracks[idx]?.cancelGaplessStart();
392
+ if (this._scheduledNextIndex === null) return;
393
+ const track = this._trackAt(this._scheduledNextIndex);
394
+ if (track) {
395
+ track.cancelGaplessStart();
396
+ this.onDebug(`_cancelScheduledGapless: cancelled track ${this._scheduledNextIndex}`);
415
397
  }
416
- this._scheduledIndices.clear();
398
+ this._scheduledNextIndex = null;
417
399
  }
418
400
 
419
- private _cancelAndRescheduleGapless(): void {
420
- this._cancelScheduledGapless();
421
- const current = this._currentTrack;
422
- if (current) {
423
- this._tryScheduleGapless(current);
424
- }
425
- }
426
-
427
- private _tryScheduleGapless(_fromTrack: Track): void {
401
+ private _tryScheduleGapless(curIndex: number): void {
428
402
  const ctx = getAudioContext();
429
403
  if (!ctx || this.webAudioIsDisabled) return;
430
404
 
431
- const snap = this._actor.getSnapshot();
432
- const curIndex = snap.context.currentTrackIndex;
433
405
  const nextIndex = curIndex + 1;
434
406
  if (nextIndex >= this._tracks.length) return;
435
407
 
@@ -439,7 +411,7 @@ export class Queue implements TrackQueueRef {
439
411
  if (
440
412
  !current.isBufferLoaded ||
441
413
  !next.isBufferLoaded ||
442
- this._scheduledIndices.has(nextIndex) ||
414
+ this._scheduledNextIndex === nextIndex ||
443
415
  !current.isPlaying
444
416
  )
445
417
  return;
@@ -450,7 +422,7 @@ export class Queue implements TrackQueueRef {
450
422
  if (endTime < ctx.currentTime + 0.01) return;
451
423
 
452
424
  next.scheduleGaplessStart(endTime);
453
- this._scheduledIndices.add(nextIndex);
425
+ this._scheduledNextIndex = nextIndex;
454
426
  }
455
427
 
456
428
  private _computeTrackEndTime(track: Track): number | null {