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/dist/index.d.ts +9 -13
- package/dist/index.mjs +3 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/Queue.ts +110 -138
- package/src/Track.ts +134 -217
- package/src/machines/fetchDecode.machine.ts +130 -0
- package/src/machines/queue.machine.ts +237 -68
- package/src/machines/track.machine.ts +272 -72
package/package.json
CHANGED
package/src/Queue.ts
CHANGED
|
@@ -36,8 +36,8 @@ export class Queue implements TrackQueueRef {
|
|
|
36
36
|
|
|
37
37
|
private _volume: number;
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
private
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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=${
|
|
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.
|
|
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.
|
|
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
|
-
|
|
371
|
-
|
|
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
|
|
375
|
-
this.
|
|
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
|
-
|
|
404
|
-
const
|
|
405
|
-
if (
|
|
406
|
-
|
|
407
|
-
this.
|
|
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.
|
|
398
|
+
this._scheduledNextIndex = null;
|
|
417
399
|
}
|
|
418
400
|
|
|
419
|
-
private
|
|
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.
|
|
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.
|
|
425
|
+
this._scheduledNextIndex = nextIndex;
|
|
454
426
|
}
|
|
455
427
|
|
|
456
428
|
private _computeTrackEndTime(track: Track): number | null {
|