gapless 4.0.11 → 4.1.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 +58 -2
- package/dist/index.d.ts +38 -8
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/Queue.ts +45 -14
- package/src/Track.ts +42 -12
- package/src/machines/track.machine.ts +37 -9
- package/src/types.ts +24 -3
package/src/Queue.ts
CHANGED
|
@@ -14,10 +14,7 @@ import { createQueueMachine } from './machines/queue.machine';
|
|
|
14
14
|
import { Track } from './Track';
|
|
15
15
|
import type { TrackQueueRef } from './Track';
|
|
16
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;
|
|
17
|
+
import type { GaplessOptions, AddTrackOptions, TrackInfo, TrackMetadata, PlaybackMethod } from './types';
|
|
21
18
|
|
|
22
19
|
export class Queue implements TrackQueueRef {
|
|
23
20
|
private _tracks: Track[] = [];
|
|
@@ -32,16 +29,18 @@ export class Queue implements TrackQueueRef {
|
|
|
32
29
|
private readonly _onPlayBlocked?: () => void;
|
|
33
30
|
private readonly _onDebug?: (msg: string) => void;
|
|
34
31
|
|
|
35
|
-
readonly
|
|
32
|
+
readonly playbackMethod: PlaybackMethod;
|
|
36
33
|
|
|
37
34
|
private _volume: number;
|
|
35
|
+
private _preloadNumTracks: number;
|
|
36
|
+
private _playbackRate: number;
|
|
38
37
|
|
|
39
38
|
/** Index of the next track with a pre-scheduled gapless start, or null. */
|
|
40
39
|
private _scheduledNextIndex: number | null = null;
|
|
41
40
|
|
|
42
41
|
private _throttledUpdatePositionState = throttle(
|
|
43
|
-
(duration: number, currentTime: number) =>
|
|
44
|
-
updateMediaSessionPositionState(duration, currentTime),
|
|
42
|
+
(duration: number, currentTime: number, playbackRate: number) =>
|
|
43
|
+
updateMediaSessionPositionState(duration, currentTime, playbackRate),
|
|
45
44
|
1000,
|
|
46
45
|
);
|
|
47
46
|
|
|
@@ -56,13 +55,17 @@ export class Queue implements TrackQueueRef {
|
|
|
56
55
|
onError,
|
|
57
56
|
onPlayBlocked,
|
|
58
57
|
onDebug,
|
|
59
|
-
|
|
58
|
+
playbackMethod = 'HYBRID',
|
|
60
59
|
trackMetadata = [],
|
|
61
60
|
volume: initialVolume = 1,
|
|
61
|
+
preloadNumTracks = 2,
|
|
62
|
+
playbackRate: initialPlaybackRate = 1,
|
|
62
63
|
} = options;
|
|
63
64
|
|
|
64
65
|
this._volume = Math.min(1, Math.max(0, initialVolume));
|
|
65
|
-
this.
|
|
66
|
+
this._preloadNumTracks = Math.max(0, preloadNumTracks);
|
|
67
|
+
this._playbackRate = Math.min(4, Math.max(0.25, initialPlaybackRate));
|
|
68
|
+
this.playbackMethod = playbackMethod;
|
|
66
69
|
this._onProgress = onProgress;
|
|
67
70
|
this._onEnded = onEnded;
|
|
68
71
|
this._onPlayNextTrack = onPlayNextTrack;
|
|
@@ -246,6 +249,17 @@ export class Queue implements TrackQueueRef {
|
|
|
246
249
|
for (const track of this._tracks) track.setVolume(clamped);
|
|
247
250
|
}
|
|
248
251
|
|
|
252
|
+
setPlaybackRate(rate: number): void {
|
|
253
|
+
const clamped = Math.min(4, Math.max(0.25, rate));
|
|
254
|
+
this._playbackRate = clamped;
|
|
255
|
+
this._currentTrack?.setPlaybackRate(clamped);
|
|
256
|
+
this._cancelScheduledGapless();
|
|
257
|
+
const snap = this._actor.getSnapshot();
|
|
258
|
+
if (snap.value === 'playing') {
|
|
259
|
+
this._tryScheduleGapless(snap.context.currentTrackIndex);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
249
263
|
addTrack(url: string, options: AddTrackOptions = {}): void {
|
|
250
264
|
const index = this._tracks.length;
|
|
251
265
|
const metadata = options.metadata ?? ({} as TrackMetadata);
|
|
@@ -314,6 +328,22 @@ export class Queue implements TrackQueueRef {
|
|
|
314
328
|
return this._volume;
|
|
315
329
|
}
|
|
316
330
|
|
|
331
|
+
get preloadNumTracks(): number {
|
|
332
|
+
return this._preloadNumTracks;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
set preloadNumTracks(value: number) {
|
|
336
|
+
this._preloadNumTracks = Math.max(0, value);
|
|
337
|
+
const snap = this._actor.getSnapshot();
|
|
338
|
+
if (snap.value === 'playing') {
|
|
339
|
+
this._preloadAhead(snap.context.currentTrackIndex);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
get playbackRate(): number {
|
|
344
|
+
return this._playbackRate;
|
|
345
|
+
}
|
|
346
|
+
|
|
317
347
|
/** Snapshot of the queue state machine (state name + context). For debugging. */
|
|
318
348
|
get queueSnapshot(): { state: string; context: { currentTrackIndex: number; trackCount: number } } {
|
|
319
349
|
const snap = this._actor.getSnapshot();
|
|
@@ -347,7 +377,7 @@ export class Queue implements TrackQueueRef {
|
|
|
347
377
|
onProgress(info: TrackInfo): void {
|
|
348
378
|
if (info.index !== this._actor.getSnapshot().context.currentTrackIndex) return;
|
|
349
379
|
if (!isNaN(info.duration)) {
|
|
350
|
-
this._throttledUpdatePositionState(info.duration, info.currentTime);
|
|
380
|
+
this._throttledUpdatePositionState(info.duration, info.currentTime, this._playbackRate);
|
|
351
381
|
}
|
|
352
382
|
this._onProgress?.(info);
|
|
353
383
|
}
|
|
@@ -357,6 +387,7 @@ export class Queue implements TrackQueueRef {
|
|
|
357
387
|
}
|
|
358
388
|
|
|
359
389
|
onPlayBlocked(): void {
|
|
390
|
+
this._actor.send({ type: 'PAUSE' });
|
|
360
391
|
this._onPlayBlocked?.();
|
|
361
392
|
}
|
|
362
393
|
|
|
@@ -392,7 +423,7 @@ export class Queue implements TrackQueueRef {
|
|
|
392
423
|
return;
|
|
393
424
|
}
|
|
394
425
|
}
|
|
395
|
-
const limit = fromIndex +
|
|
426
|
+
const limit = fromIndex + this._preloadNumTracks + 1;
|
|
396
427
|
this.onDebug(`_preloadAhead(${fromIndex}) limit=${limit} trackCount=${this._tracks.length}`);
|
|
397
428
|
for (let i = fromIndex + 1; i < this._tracks.length && i < limit; i++) {
|
|
398
429
|
const t = this._tracks[i];
|
|
@@ -418,7 +449,7 @@ export class Queue implements TrackQueueRef {
|
|
|
418
449
|
|
|
419
450
|
private _tryScheduleGapless(curIndex: number): void {
|
|
420
451
|
const ctx = getAudioContext();
|
|
421
|
-
if (!ctx || this.
|
|
452
|
+
if (!ctx || this.playbackMethod === 'HTML5_ONLY') return;
|
|
422
453
|
|
|
423
454
|
const nextIndex = curIndex + 1;
|
|
424
455
|
if (nextIndex >= this._tracks.length) return;
|
|
@@ -450,10 +481,10 @@ export class Queue implements TrackQueueRef {
|
|
|
450
481
|
if (isNaN(duration)) return null;
|
|
451
482
|
|
|
452
483
|
if (track.scheduledStartContextTime !== null) {
|
|
453
|
-
return track.scheduledStartContextTime + duration;
|
|
484
|
+
return track.scheduledStartContextTime + duration / this._playbackRate;
|
|
454
485
|
}
|
|
455
486
|
|
|
456
|
-
const remaining = duration - track.currentTime;
|
|
487
|
+
const remaining = (duration - track.currentTime) / this._playbackRate;
|
|
457
488
|
if (remaining <= 0) return null;
|
|
458
489
|
return ctx.currentTime + remaining;
|
|
459
490
|
}
|
package/src/Track.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { getAudioContext, resumeAudioContext } from './utils/audioContext';
|
|
|
7
7
|
import { createTrackMachine } from './machines/track.machine';
|
|
8
8
|
import { fetchDecodeMachine } from './machines/fetchDecode.machine';
|
|
9
9
|
import type { TrackContext } from './machines/track.machine';
|
|
10
|
-
import type { TrackInfo, TrackMetadata, WebAudioLoadingState, PlaybackType } from './types';
|
|
10
|
+
import type { TrackInfo, TrackMetadata, WebAudioLoadingState, PlaybackType, PlaybackMethod } from './types';
|
|
11
11
|
|
|
12
12
|
export interface TrackQueueRef {
|
|
13
13
|
onTrackEnded(track: Track): void;
|
|
@@ -18,7 +18,8 @@ export interface TrackQueueRef {
|
|
|
18
18
|
onPlayBlocked(): void;
|
|
19
19
|
onDebug(msg: string): void;
|
|
20
20
|
readonly volume: number;
|
|
21
|
-
readonly
|
|
21
|
+
readonly playbackMethod: PlaybackMethod;
|
|
22
|
+
readonly playbackRate: number;
|
|
22
23
|
readonly currentTrackIndex: number;
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -42,10 +43,10 @@ export class Track {
|
|
|
42
43
|
readonly audio: HTMLAudioElement;
|
|
43
44
|
|
|
44
45
|
// ---- Web Audio nodes -----------------------------------------------------
|
|
45
|
-
private readonly
|
|
46
|
+
private readonly _playbackMethod: PlaybackMethod;
|
|
46
47
|
|
|
47
48
|
private get ctx(): AudioContext | null {
|
|
48
|
-
if (this.
|
|
49
|
+
if (this._playbackMethod === 'HTML5_ONLY') return null;
|
|
49
50
|
const context = getAudioContext();
|
|
50
51
|
if (context && !this.gainNode) {
|
|
51
52
|
this.gainNode = context.createGain();
|
|
@@ -57,8 +58,10 @@ export class Track {
|
|
|
57
58
|
private gainNode: GainNode | null = null;
|
|
58
59
|
private sourceNode: AudioBufferSourceNode | null = null;
|
|
59
60
|
audioBuffer: AudioBuffer | null = null;
|
|
60
|
-
/** AudioContext.currentTime
|
|
61
|
-
private
|
|
61
|
+
/** AudioContext.currentTime at the start of the current playback segment. */
|
|
62
|
+
private _waRefCtxTime = 0;
|
|
63
|
+
/** Track position (seconds) at the start of the current playback segment. */
|
|
64
|
+
private _waRefTrackTime = 0;
|
|
62
65
|
/** Track-time (seconds) frozen at the moment of the most recent pause. */
|
|
63
66
|
private pausedAtTrackTime = 0;
|
|
64
67
|
// ---- FSM -----------------------------------------------------------------
|
|
@@ -104,7 +107,7 @@ export class Track {
|
|
|
104
107
|
this._actor.send({ type: 'HTML5_ENDED' });
|
|
105
108
|
};
|
|
106
109
|
|
|
107
|
-
this.
|
|
110
|
+
this._playbackMethod = opts.queue.playbackMethod;
|
|
108
111
|
|
|
109
112
|
const initialContext: TrackContext = {
|
|
110
113
|
trackUrl: this._trackUrl,
|
|
@@ -116,10 +119,12 @@ export class Track {
|
|
|
116
119
|
scheduledStartContextTime: null,
|
|
117
120
|
notifiedLookahead: false,
|
|
118
121
|
fetchStarted: false,
|
|
122
|
+
pendingPlay: false,
|
|
119
123
|
};
|
|
120
124
|
const machine = createTrackMachine(initialContext).provide({
|
|
121
125
|
guards: {
|
|
122
126
|
canPlayWebAudio: () => !!(this.ctx && this.audioBuffer && this.gainNode),
|
|
127
|
+
isWebAudioOnly: () => this._playbackMethod === 'WEBAUDIO_ONLY',
|
|
123
128
|
},
|
|
124
129
|
actors: {
|
|
125
130
|
fetchDecode: fetchDecodeMachine.provide({
|
|
@@ -150,6 +155,10 @@ export class Track {
|
|
|
150
155
|
}),
|
|
151
156
|
},
|
|
152
157
|
actions: {
|
|
158
|
+
triggerFetchForPendingPlay: () => {
|
|
159
|
+
this.preload();
|
|
160
|
+
resumeAudioContext();
|
|
161
|
+
},
|
|
153
162
|
playHtml5: () => this._playHtml5(),
|
|
154
163
|
startSourceNode: () => {
|
|
155
164
|
this._startSourceNode(this.pausedAtTrackTime);
|
|
@@ -160,11 +169,13 @@ export class Track {
|
|
|
160
169
|
this._stopSourceNode();
|
|
161
170
|
this.sourceNode = this.ctx.createBufferSource();
|
|
162
171
|
this.sourceNode.buffer = this.audioBuffer;
|
|
172
|
+
this.sourceNode.playbackRate.value = this.queueRef.playbackRate;
|
|
163
173
|
this.sourceNode.connect(this.gainNode);
|
|
164
174
|
this.gainNode.connect(this.ctx.destination);
|
|
165
175
|
this.sourceNode.onended = this._handleWebAudioEnded;
|
|
166
176
|
this.sourceNode.start(when, 0);
|
|
167
|
-
this.
|
|
177
|
+
this._waRefCtxTime = when;
|
|
178
|
+
this._waRefTrackTime = 0;
|
|
168
179
|
this.queueRef.onDebug(
|
|
169
180
|
`startScheduledSourceNode track=${this.index} when=${when.toFixed(3)} ctxNow=${this.ctx.currentTime.toFixed(3)} delta=${(when - this.ctx.currentTime).toFixed(3)}s`
|
|
170
181
|
);
|
|
@@ -172,7 +183,8 @@ export class Track {
|
|
|
172
183
|
startProgressLoop: () => this.startProgressLoop(),
|
|
173
184
|
pauseHtml5: () => this.audio.pause(),
|
|
174
185
|
freezePausedTime: () => {
|
|
175
|
-
|
|
186
|
+
const t = this.currentTime;
|
|
187
|
+
this.pausedAtTrackTime = isFinite(t) ? t : 0;
|
|
176
188
|
},
|
|
177
189
|
stopSourceNode: () => this._stopSourceNode(),
|
|
178
190
|
disconnectGain: () => this._disconnectGain(),
|
|
@@ -186,7 +198,8 @@ export class Track {
|
|
|
186
198
|
this.audio.currentTime = 0;
|
|
187
199
|
},
|
|
188
200
|
resetTiming: () => {
|
|
189
|
-
this.
|
|
201
|
+
this._waRefCtxTime = 0;
|
|
202
|
+
this._waRefTrackTime = 0;
|
|
190
203
|
this.pausedAtTrackTime = 0;
|
|
191
204
|
},
|
|
192
205
|
notifyTrackEnded: () => {
|
|
@@ -214,6 +227,7 @@ export class Track {
|
|
|
214
227
|
}
|
|
215
228
|
|
|
216
229
|
seek(time: number): void {
|
|
230
|
+
if (!isFinite(time)) return;
|
|
217
231
|
const clamped = Math.max(0, isNaN(this.duration) ? time : Math.min(time, this.duration));
|
|
218
232
|
this.pausedAtTrackTime = clamped;
|
|
219
233
|
this._actor.send({ type: 'SEEK', time: clamped });
|
|
@@ -226,6 +240,17 @@ export class Track {
|
|
|
226
240
|
this._actor.send({ type: 'SET_VOLUME', volume: vol });
|
|
227
241
|
}
|
|
228
242
|
|
|
243
|
+
setPlaybackRate(rate: number): void {
|
|
244
|
+
// Freeze current track position at the old rate before switching
|
|
245
|
+
if (this.ctx && this.sourceNode && this._actor.getSnapshot().context.isPlaying) {
|
|
246
|
+
const oldRate = this.sourceNode.playbackRate.value;
|
|
247
|
+
this._waRefTrackTime = this._waRefTrackTime + (this.ctx.currentTime - this._waRefCtxTime) * oldRate;
|
|
248
|
+
this._waRefCtxTime = this.ctx.currentTime;
|
|
249
|
+
}
|
|
250
|
+
this.audio.playbackRate = rate;
|
|
251
|
+
if (this.sourceNode) this.sourceNode.playbackRate.value = rate;
|
|
252
|
+
}
|
|
253
|
+
|
|
229
254
|
preload(): void {
|
|
230
255
|
this.queueRef.onDebug(
|
|
231
256
|
`preload() track=${this.index} state=${this._actor.getSnapshot().value} hasBuffer=${!!this.audioBuffer} hasCtx=${!!this.ctx}`
|
|
@@ -294,7 +319,7 @@ export class Track {
|
|
|
294
319
|
if (snap.value === 'webaudio') {
|
|
295
320
|
if (!snap.context.isPlaying) return this.pausedAtTrackTime;
|
|
296
321
|
if (!this.ctx) return 0;
|
|
297
|
-
return Math.max(0, this.ctx.currentTime - this.
|
|
322
|
+
return Math.max(0, this._waRefTrackTime + (this.ctx.currentTime - this._waRefCtxTime) * this.queueRef.playbackRate);
|
|
298
323
|
}
|
|
299
324
|
return this.audio.currentTime;
|
|
300
325
|
}
|
|
@@ -354,6 +379,7 @@ export class Track {
|
|
|
354
379
|
playbackType: this.playbackType,
|
|
355
380
|
webAudioLoadingState: this.webAudioLoadingState,
|
|
356
381
|
metadata: this.metadata,
|
|
382
|
+
playbackRate: this.queueRef.playbackRate,
|
|
357
383
|
machineState: this.machineState,
|
|
358
384
|
};
|
|
359
385
|
}
|
|
@@ -364,6 +390,7 @@ export class Track {
|
|
|
364
390
|
|
|
365
391
|
private _playHtml5(): void {
|
|
366
392
|
if (this.audio.preload !== 'auto') this.audio.preload = 'auto';
|
|
393
|
+
this.audio.playbackRate = this.queueRef.playbackRate;
|
|
367
394
|
const promise = this.audio.play();
|
|
368
395
|
if (promise) {
|
|
369
396
|
promise.catch((err: unknown) => {
|
|
@@ -380,6 +407,7 @@ export class Track {
|
|
|
380
407
|
|
|
381
408
|
private _seekHtml5(): void {
|
|
382
409
|
const clamped = this.pausedAtTrackTime;
|
|
410
|
+
if (!isFinite(clamped)) return;
|
|
383
411
|
if (this.audio.preload !== 'auto') this.audio.preload = 'auto';
|
|
384
412
|
if (this.audio.readyState >= HTMLMediaElement.HAVE_METADATA) {
|
|
385
413
|
this.audio.currentTime = clamped;
|
|
@@ -411,11 +439,13 @@ export class Track {
|
|
|
411
439
|
|
|
412
440
|
this.sourceNode = this.ctx.createBufferSource();
|
|
413
441
|
this.sourceNode.buffer = this.audioBuffer;
|
|
442
|
+
this.sourceNode.playbackRate.value = this.queueRef.playbackRate;
|
|
414
443
|
this.sourceNode.connect(this.gainNode);
|
|
415
444
|
this.gainNode.connect(this.ctx.destination);
|
|
416
445
|
this.sourceNode.onended = this._handleWebAudioEnded;
|
|
417
446
|
|
|
418
|
-
this.
|
|
447
|
+
this._waRefCtxTime = this.ctx.currentTime;
|
|
448
|
+
this._waRefTrackTime = offset;
|
|
419
449
|
this.sourceNode.start(0, offset);
|
|
420
450
|
}
|
|
421
451
|
|
|
@@ -39,6 +39,8 @@ export interface TrackContext {
|
|
|
39
39
|
scheduledStartContextTime: number | null;
|
|
40
40
|
notifiedLookahead: boolean;
|
|
41
41
|
fetchStarted: boolean;
|
|
42
|
+
/** True when PLAY was received in webAudioOnly mode before buffer is ready. */
|
|
43
|
+
pendingPlay: boolean;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
// ---- Events ----------------------------------------------------------------
|
|
@@ -76,6 +78,7 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
76
78
|
},
|
|
77
79
|
guards: {
|
|
78
80
|
canPlayWebAudio: () => false,
|
|
81
|
+
isWebAudioOnly: () => false,
|
|
79
82
|
canStartFetch: ({ context }) => context.webAudioLoadingState === 'NONE' && !context.fetchStarted,
|
|
80
83
|
},
|
|
81
84
|
actions: {
|
|
@@ -94,6 +97,9 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
94
97
|
resetHtml5Element: () => {},
|
|
95
98
|
resetTiming: () => {},
|
|
96
99
|
notifyTrackEnded: () => {},
|
|
100
|
+
triggerFetchForPendingPlay: () => {},
|
|
101
|
+
setPendingPlay: assign({ pendingPlay: () => true }),
|
|
102
|
+
clearPendingPlay: assign({ pendingPlay: () => false }),
|
|
97
103
|
setIsPlaying: assign({ isPlaying: () => true }),
|
|
98
104
|
clearIsPlaying: assign({ isPlaying: () => false }),
|
|
99
105
|
setLoadingState: assign({ webAudioLoadingState: () => 'LOADING' as WebAudioLoadingState }),
|
|
@@ -186,6 +192,10 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
186
192
|
'startProgressLoop',
|
|
187
193
|
],
|
|
188
194
|
},
|
|
195
|
+
{
|
|
196
|
+
guard: 'isWebAudioOnly',
|
|
197
|
+
actions: ['setPendingPlay', 'triggerFetchForPendingPlay'],
|
|
198
|
+
},
|
|
189
199
|
{
|
|
190
200
|
target: 'html5',
|
|
191
201
|
actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop'],
|
|
@@ -210,11 +220,18 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
210
220
|
BUFFER_LOADING: {
|
|
211
221
|
actions: 'setLoadingState',
|
|
212
222
|
},
|
|
213
|
-
BUFFER_READY:
|
|
214
|
-
|
|
215
|
-
|
|
223
|
+
BUFFER_READY: [
|
|
224
|
+
{
|
|
225
|
+
guard: ({ context }: { context: TrackContext }) => context.pendingPlay,
|
|
226
|
+
target: 'webaudio',
|
|
227
|
+
actions: ['clearPendingPlay', 'setPlayingWebAudio', 'startSourceNode', 'startProgressLoop'],
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
actions: 'setLoadedState',
|
|
231
|
+
},
|
|
232
|
+
],
|
|
216
233
|
BUFFER_ERROR: {
|
|
217
|
-
actions: 'setErrorState',
|
|
234
|
+
actions: ['setErrorState', 'clearPendingPlay'],
|
|
218
235
|
},
|
|
219
236
|
URL_RESOLVED: {
|
|
220
237
|
actions: 'setResolvedUrl',
|
|
@@ -289,13 +306,20 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
289
306
|
BUFFER_LOADING: {
|
|
290
307
|
actions: 'setLoadingState',
|
|
291
308
|
},
|
|
292
|
-
BUFFER_READY:
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
309
|
+
BUFFER_READY: [
|
|
310
|
+
{
|
|
311
|
+
guard: ({ context }: { context: TrackContext }) => context.pendingPlay,
|
|
312
|
+
target: 'webaudio',
|
|
313
|
+
actions: ['clearPendingPlay', 'setPlayingWebAudio', 'startSourceNode', 'startProgressLoop'],
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
target: 'idle',
|
|
317
|
+
actions: 'setLoadedState',
|
|
318
|
+
},
|
|
319
|
+
],
|
|
296
320
|
BUFFER_ERROR: {
|
|
297
321
|
target: 'idle',
|
|
298
|
-
actions: 'setErrorState',
|
|
322
|
+
actions: ['setErrorState', 'clearPendingPlay'],
|
|
299
323
|
},
|
|
300
324
|
PLAY: [
|
|
301
325
|
{
|
|
@@ -307,6 +331,10 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
307
331
|
'startProgressLoop',
|
|
308
332
|
],
|
|
309
333
|
},
|
|
334
|
+
{
|
|
335
|
+
guard: 'isWebAudioOnly',
|
|
336
|
+
actions: ['setPendingPlay', 'triggerFetchForPendingPlay'],
|
|
337
|
+
},
|
|
310
338
|
{
|
|
311
339
|
target: 'html5',
|
|
312
340
|
actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop'],
|
package/src/types.ts
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
export type PlaybackType = 'HTML5' | 'WEBAUDIO';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Controls how audio is rendered.
|
|
9
|
+
* - `'HYBRID'` (default): Starts with HTML5 audio, then switches to Web Audio after decode. Best for remote files.
|
|
10
|
+
* - `'HTML5_ONLY'`: Uses HTML5 audio exclusively. Gapless playback is not available.
|
|
11
|
+
* - `'WEBAUDIO_ONLY'`: Uses Web Audio API exclusively. Audio must fully buffer before playing — only use for very small or local files.
|
|
12
|
+
*/
|
|
13
|
+
export type PlaybackMethod = 'HYBRID' | 'HTML5_ONLY' | 'WEBAUDIO_ONLY';
|
|
14
|
+
|
|
7
15
|
export type WebAudioLoadingState = 'NONE' | 'LOADING' | 'LOADED' | 'ERROR';
|
|
8
16
|
|
|
9
17
|
/** Metadata attached to a track (arbitrary user data). */
|
|
@@ -36,14 +44,25 @@ export interface GaplessOptions {
|
|
|
36
44
|
/** Called when autoplay is blocked by the browser. */
|
|
37
45
|
onPlayBlocked?: () => void;
|
|
38
46
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
47
|
+
* Controls how audio is rendered.
|
|
48
|
+
* - `'HYBRID'` (default): Starts with HTML5 audio, switches to Web Audio after decode. Best for remote files.
|
|
49
|
+
* - `'HTML5_ONLY'`: HTML5 audio only. Gapless playback is not available.
|
|
50
|
+
* - `'WEBAUDIO_ONLY'`: Web Audio API only. Audio must fully buffer before playing.
|
|
41
51
|
*/
|
|
42
|
-
|
|
52
|
+
playbackMethod?: PlaybackMethod;
|
|
43
53
|
/** Per-track metadata (aligned to the tracks array by index). */
|
|
44
54
|
trackMetadata?: TrackMetadata[];
|
|
45
55
|
/** Initial volume, 0.0–1.0. Defaults to 1. */
|
|
46
56
|
volume?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Number of tracks to preload ahead of the current track.
|
|
59
|
+
* Defaults to 2. Set to 0 to disable preloading.
|
|
60
|
+
*/
|
|
61
|
+
preloadNumTracks?: number;
|
|
62
|
+
/**
|
|
63
|
+
* Initial playback rate, 0.25–4.0. Defaults to 1.
|
|
64
|
+
*/
|
|
65
|
+
playbackRate?: number;
|
|
47
66
|
}
|
|
48
67
|
|
|
49
68
|
/** Options for dynamically adding a track. */
|
|
@@ -82,6 +101,8 @@ export interface TrackInfo {
|
|
|
82
101
|
webAudioLoadingState: WebAudioLoadingState;
|
|
83
102
|
/** Arbitrary metadata supplied when the track was added. */
|
|
84
103
|
metadata?: TrackMetadata;
|
|
104
|
+
/** Current playback rate. */
|
|
105
|
+
playbackRate: number;
|
|
85
106
|
/** Current xstate machine state for this track (e.g. 'idle', 'html5', 'webaudio'). */
|
|
86
107
|
machineState: string;
|
|
87
108
|
}
|