gapless 4.0.5

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/src/Queue.ts ADDED
@@ -0,0 +1,442 @@
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
+ updateMediaSessionPositionState,
12
+ } from './utils/mediaSession';
13
+ import { createQueueMachine } from './machines/queue.machine';
14
+ import { Track } from './Track';
15
+ import type { TrackQueueRef } from './Track';
16
+ import { throttle } from './utils/throttle';
17
+ import type { GaplessOptions, AddTrackOptions, TrackInfo, TrackMetadata } from './types';
18
+
19
+ /** Maximum number of tracks to preload ahead of the current track. */
20
+ const PRELOAD_AHEAD = 2;
21
+
22
+ export class Queue implements TrackQueueRef {
23
+ private _tracks: Track[] = [];
24
+ private readonly _actor;
25
+
26
+ private readonly _onProgress?: (info: TrackInfo) => void;
27
+ private readonly _onEnded?: () => void;
28
+ private readonly _onPlayNextTrack?: (info: TrackInfo) => void;
29
+ private readonly _onPlayPreviousTrack?: (info: TrackInfo) => void;
30
+ private readonly _onStartNewTrack?: (info: TrackInfo) => void;
31
+ private readonly _onError?: (error: Error) => void;
32
+ private readonly _onPlayBlocked?: () => void;
33
+ private readonly _onDebug?: (msg: string) => void;
34
+
35
+ readonly webAudioIsDisabled: boolean;
36
+
37
+ private _volume: number;
38
+
39
+ /** Index of the next track with a pre-scheduled gapless start, or null. */
40
+ private _scheduledNextIndex: number | null = null;
41
+
42
+ private _throttledUpdatePositionState = throttle(
43
+ (duration: number, currentTime: number) =>
44
+ updateMediaSessionPositionState(duration, currentTime),
45
+ 1000,
46
+ );
47
+
48
+ constructor(options: GaplessOptions = {}) {
49
+ const {
50
+ tracks = [],
51
+ onProgress,
52
+ onEnded,
53
+ onPlayNextTrack,
54
+ onPlayPreviousTrack,
55
+ onStartNewTrack,
56
+ onError,
57
+ onPlayBlocked,
58
+ onDebug,
59
+ webAudioIsDisabled = false,
60
+ trackMetadata = [],
61
+ volume: initialVolume = 1,
62
+ } = options;
63
+
64
+ this._volume = Math.min(1, Math.max(0, initialVolume));
65
+ this.webAudioIsDisabled = webAudioIsDisabled;
66
+ this._onProgress = onProgress;
67
+ this._onEnded = onEnded;
68
+ this._onPlayNextTrack = onPlayNextTrack;
69
+ this._onPlayPreviousTrack = onPlayPreviousTrack;
70
+ this._onStartNewTrack = onStartNewTrack;
71
+ this._onError = onError;
72
+ this._onPlayBlocked = onPlayBlocked;
73
+ this._onDebug = onDebug;
74
+
75
+ this._tracks = tracks.map(
76
+ (url, i) =>
77
+ new Track({
78
+ trackUrl: url,
79
+ index: i,
80
+ queue: this,
81
+ metadata: trackMetadata[i],
82
+ })
83
+ );
84
+
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);
173
+
174
+ this._actor.subscribe((snapshot) => {
175
+ updateMediaSessionPlaybackState(snapshot.value === 'playing');
176
+ });
177
+
178
+ this._actor.start();
179
+
180
+ setupMediaSession({
181
+ onPlay: () => this.play(),
182
+ onPause: () => {
183
+ if (this._actor.getSnapshot().value === 'playing') this.pause();
184
+ },
185
+ onNext: () => this.next(),
186
+ onPrevious: () => this.previous(),
187
+ onSeek: (t) => this.seek(t),
188
+ });
189
+ }
190
+
191
+ // --------------------------------------------------------------------------
192
+ // Public API
193
+ // --------------------------------------------------------------------------
194
+
195
+ play(): void {
196
+ if (!this._currentTrack) return;
197
+ this._actor.send({ type: 'PLAY' });
198
+ }
199
+
200
+ pause(): void {
201
+ this._actor.send({ type: 'PAUSE' });
202
+ }
203
+
204
+ togglePlayPause(): void {
205
+ if (this._actor.getSnapshot().value === 'playing') {
206
+ this.pause();
207
+ } else {
208
+ this.play();
209
+ }
210
+ }
211
+
212
+ next(): void {
213
+ const snap = this._actor.getSnapshot();
214
+ const nextIndex = snap.context.currentTrackIndex + 1;
215
+ if (nextIndex >= this._tracks.length) return;
216
+
217
+ this._actor.send({ type: 'NEXT' });
218
+ }
219
+
220
+ previous(): void {
221
+ const ct = this._currentTrack;
222
+ if (ct && ct.currentTime > 8) {
223
+ ct.seek(0);
224
+ ct.play();
225
+ return;
226
+ }
227
+
228
+ this._actor.send({ type: 'PREVIOUS' });
229
+ }
230
+
231
+ gotoTrack(index: number, playImmediately = false): void {
232
+ if (index < 0 || index >= this._tracks.length) return;
233
+ this.onDebug(
234
+ `gotoTrack(${index}, playImmediately=${playImmediately}) queueState=${this._actor.getSnapshot().value} curIdx=${this._actor.getSnapshot().context.currentTrackIndex}`
235
+ );
236
+ this._actor.send({ type: 'GOTO', index, playImmediately });
237
+ }
238
+
239
+ seek(time: number): void {
240
+ this._actor.send({ type: 'SEEK', time });
241
+ }
242
+
243
+ setVolume(volume: number): void {
244
+ const clamped = Math.min(1, Math.max(0, volume));
245
+ this._volume = clamped;
246
+ for (const track of this._tracks) track.setVolume(clamped);
247
+ }
248
+
249
+ addTrack(url: string, options: AddTrackOptions = {}): void {
250
+ const index = this._tracks.length;
251
+ const metadata = options.metadata ?? ({} as TrackMetadata);
252
+ this._tracks.push(
253
+ new Track({
254
+ trackUrl: url,
255
+ index,
256
+ queue: this,
257
+ skipHEAD: options.skipHEAD,
258
+ metadata,
259
+ })
260
+ );
261
+ this._actor.send({ type: 'ADD_TRACK' });
262
+ }
263
+
264
+ removeTrack(index: number): void {
265
+ if (index < 0 || index >= this._tracks.length) return;
266
+ this._tracks[index].destroy();
267
+ this._tracks.splice(index, 1);
268
+ for (let i = index; i < this._tracks.length; i++) {
269
+ (this._tracks[i] as unknown as { index: number }).index = i;
270
+ }
271
+ if (this._scheduledNextIndex === index) {
272
+ this._scheduledNextIndex = null;
273
+ }
274
+ this._actor.send({ type: 'REMOVE_TRACK', index });
275
+ }
276
+
277
+ resumeAudioContext(): Promise<void> {
278
+ return _resumeAudioContext();
279
+ }
280
+
281
+ destroy(): void {
282
+ for (const track of this._tracks) track.destroy();
283
+ this._tracks = [];
284
+ this._actor.stop();
285
+ }
286
+
287
+ // --------------------------------------------------------------------------
288
+ // Getters
289
+ // --------------------------------------------------------------------------
290
+
291
+ get currentTrack(): TrackInfo | undefined {
292
+ return this._currentTrack?.toInfo();
293
+ }
294
+
295
+ get currentTrackIndex(): number {
296
+ return this._actor.getSnapshot().context.currentTrackIndex;
297
+ }
298
+
299
+ get tracks(): readonly TrackInfo[] {
300
+ return this._tracks.map((t) => t.toInfo());
301
+ }
302
+
303
+ get isPlaying(): boolean {
304
+ return this._actor.getSnapshot().value === 'playing';
305
+ }
306
+
307
+ get isPaused(): boolean {
308
+ return this._actor.getSnapshot().value === 'paused';
309
+ }
310
+
311
+ get volume(): number {
312
+ return this._volume;
313
+ }
314
+
315
+ /** Snapshot of the queue state machine (state name + context). For debugging. */
316
+ get queueSnapshot(): { state: string; context: { currentTrackIndex: number; trackCount: number } } {
317
+ const snap = this._actor.getSnapshot();
318
+ return { state: snap.value as string, context: snap.context };
319
+ }
320
+
321
+ // --------------------------------------------------------------------------
322
+ // TrackQueueRef — called by Track instances
323
+ // --------------------------------------------------------------------------
324
+
325
+ onTrackEnded(track: Track): void {
326
+ const snap = this._actor.getSnapshot();
327
+ this.onDebug(
328
+ `onTrackEnded track=${track.index} queueState=${snap.value} curIdx=${snap.context.currentTrackIndex}`
329
+ );
330
+ if (track.index !== snap.context.currentTrackIndex) return;
331
+
332
+ this._actor.send({ type: 'TRACK_ENDED' });
333
+ const newSnap = this._actor.getSnapshot();
334
+ this.onDebug(
335
+ `onTrackEnded after TRACK_ENDED → queueState=${newSnap.value} curIdx=${newSnap.context.currentTrackIndex}`
336
+ );
337
+ }
338
+
339
+ onTrackBufferReady(track: Track): void {
340
+ this._actor.send({ type: 'TRACK_LOADED', index: track.index });
341
+ }
342
+
343
+ onProgress(info: TrackInfo): void {
344
+ if (info.index !== this._actor.getSnapshot().context.currentTrackIndex) return;
345
+ if (!isNaN(info.duration)) {
346
+ this._throttledUpdatePositionState(info.duration, info.currentTime);
347
+ }
348
+ this._onProgress?.(info);
349
+ }
350
+
351
+ onError(error: Error): void {
352
+ this._onError?.(error);
353
+ }
354
+
355
+ onPlayBlocked(): void {
356
+ this._onPlayBlocked?.();
357
+ }
358
+
359
+ onDebug(msg: string): void {
360
+ this._onDebug?.(msg);
361
+ }
362
+
363
+ // --------------------------------------------------------------------------
364
+ // Private helpers
365
+ // --------------------------------------------------------------------------
366
+
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];
370
+ }
371
+
372
+ private get _currentTrack(): Track | undefined {
373
+ return this._tracks[this._actor.getSnapshot().context.currentTrackIndex];
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
+ 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}`);
397
+ }
398
+ this._scheduledNextIndex = null;
399
+ }
400
+
401
+ private _tryScheduleGapless(curIndex: number): void {
402
+ const ctx = getAudioContext();
403
+ if (!ctx || this.webAudioIsDisabled) return;
404
+
405
+ const nextIndex = curIndex + 1;
406
+ if (nextIndex >= this._tracks.length) return;
407
+
408
+ const current = this._tracks[curIndex];
409
+ const next = this._tracks[nextIndex];
410
+
411
+ if (
412
+ !current.isBufferLoaded ||
413
+ !next.isBufferLoaded ||
414
+ this._scheduledNextIndex === nextIndex ||
415
+ !current.isPlaying
416
+ )
417
+ return;
418
+
419
+ const endTime = this._computeTrackEndTime(current);
420
+ if (endTime === null) return;
421
+
422
+ if (endTime < ctx.currentTime + 0.01) return;
423
+
424
+ next.scheduleGaplessStart(endTime);
425
+ this._scheduledNextIndex = nextIndex;
426
+ }
427
+
428
+ private _computeTrackEndTime(track: Track): number | null {
429
+ const ctx = getAudioContext();
430
+ if (!ctx || !track.isBufferLoaded) return null;
431
+ const duration = track.duration;
432
+ if (isNaN(duration)) return null;
433
+
434
+ if (track.scheduledStartContextTime !== null) {
435
+ return track.scheduledStartContextTime + duration;
436
+ }
437
+
438
+ const remaining = duration - track.currentTime;
439
+ if (remaining <= 0) return null;
440
+ return ctx.currentTime + remaining;
441
+ }
442
+ }