gapless 4.0.12 → 4.1.1
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 +64 -3
- package/dist/index.d.ts +38 -8
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/Queue.ts +56 -13
- package/src/Track.ts +38 -11
- package/src/machines/track.machine.ts +38 -9
- package/src/types.ts +24 -3
package/src/Queue.ts
CHANGED
|
@@ -14,10 +14,9 @@ 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';
|
|
17
|
+
import type { GaplessOptions, AddTrackOptions, TrackInfo, TrackMetadata, PlaybackMethod } from './types';
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
const PRELOAD_AHEAD = 2;
|
|
19
|
+
const MAX_SCHEDULE_LOOKAHEAD = 5;
|
|
21
20
|
|
|
22
21
|
export class Queue implements TrackQueueRef {
|
|
23
22
|
private _tracks: Track[] = [];
|
|
@@ -32,16 +31,18 @@ export class Queue implements TrackQueueRef {
|
|
|
32
31
|
private readonly _onPlayBlocked?: () => void;
|
|
33
32
|
private readonly _onDebug?: (msg: string) => void;
|
|
34
33
|
|
|
35
|
-
readonly
|
|
34
|
+
readonly playbackMethod: PlaybackMethod;
|
|
36
35
|
|
|
37
36
|
private _volume: number;
|
|
37
|
+
private _preloadNumTracks: number;
|
|
38
|
+
private _playbackRate: number;
|
|
38
39
|
|
|
39
40
|
/** Index of the next track with a pre-scheduled gapless start, or null. */
|
|
40
41
|
private _scheduledNextIndex: number | null = null;
|
|
41
42
|
|
|
42
43
|
private _throttledUpdatePositionState = throttle(
|
|
43
|
-
(duration: number, currentTime: number) =>
|
|
44
|
-
updateMediaSessionPositionState(duration, currentTime),
|
|
44
|
+
(duration: number, currentTime: number, playbackRate: number) =>
|
|
45
|
+
updateMediaSessionPositionState(duration, currentTime, playbackRate),
|
|
45
46
|
1000,
|
|
46
47
|
);
|
|
47
48
|
|
|
@@ -56,13 +57,17 @@ export class Queue implements TrackQueueRef {
|
|
|
56
57
|
onError,
|
|
57
58
|
onPlayBlocked,
|
|
58
59
|
onDebug,
|
|
59
|
-
|
|
60
|
+
playbackMethod = 'HYBRID',
|
|
60
61
|
trackMetadata = [],
|
|
61
62
|
volume: initialVolume = 1,
|
|
63
|
+
preloadNumTracks = 2,
|
|
64
|
+
playbackRate: initialPlaybackRate = 1,
|
|
62
65
|
} = options;
|
|
63
66
|
|
|
64
67
|
this._volume = Math.min(1, Math.max(0, initialVolume));
|
|
65
|
-
this.
|
|
68
|
+
this._preloadNumTracks = Math.max(0, preloadNumTracks);
|
|
69
|
+
this._playbackRate = Math.min(4, Math.max(0.25, initialPlaybackRate));
|
|
70
|
+
this.playbackMethod = playbackMethod;
|
|
66
71
|
this._onProgress = onProgress;
|
|
67
72
|
this._onEnded = onEnded;
|
|
68
73
|
this._onPlayNextTrack = onPlayNextTrack;
|
|
@@ -246,6 +251,17 @@ export class Queue implements TrackQueueRef {
|
|
|
246
251
|
for (const track of this._tracks) track.setVolume(clamped);
|
|
247
252
|
}
|
|
248
253
|
|
|
254
|
+
setPlaybackRate(rate: number): void {
|
|
255
|
+
const clamped = Math.min(4, Math.max(0.25, rate));
|
|
256
|
+
this._playbackRate = clamped;
|
|
257
|
+
this._currentTrack?.setPlaybackRate(clamped);
|
|
258
|
+
this._cancelScheduledGapless();
|
|
259
|
+
const snap = this._actor.getSnapshot();
|
|
260
|
+
if (snap.value === 'playing') {
|
|
261
|
+
this._tryScheduleGapless(snap.context.currentTrackIndex);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
249
265
|
addTrack(url: string, options: AddTrackOptions = {}): void {
|
|
250
266
|
const index = this._tracks.length;
|
|
251
267
|
const metadata = options.metadata ?? ({} as TrackMetadata);
|
|
@@ -314,6 +330,22 @@ export class Queue implements TrackQueueRef {
|
|
|
314
330
|
return this._volume;
|
|
315
331
|
}
|
|
316
332
|
|
|
333
|
+
get preloadNumTracks(): number {
|
|
334
|
+
return this._preloadNumTracks;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
set preloadNumTracks(value: number) {
|
|
338
|
+
this._preloadNumTracks = Math.max(0, value);
|
|
339
|
+
const snap = this._actor.getSnapshot();
|
|
340
|
+
if (snap.value === 'playing') {
|
|
341
|
+
this._preloadAhead(snap.context.currentTrackIndex);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
get playbackRate(): number {
|
|
346
|
+
return this._playbackRate;
|
|
347
|
+
}
|
|
348
|
+
|
|
317
349
|
/** Snapshot of the queue state machine (state name + context). For debugging. */
|
|
318
350
|
get queueSnapshot(): { state: string; context: { currentTrackIndex: number; trackCount: number } } {
|
|
319
351
|
const snap = this._actor.getSnapshot();
|
|
@@ -347,7 +379,7 @@ export class Queue implements TrackQueueRef {
|
|
|
347
379
|
onProgress(info: TrackInfo): void {
|
|
348
380
|
if (info.index !== this._actor.getSnapshot().context.currentTrackIndex) return;
|
|
349
381
|
if (!isNaN(info.duration)) {
|
|
350
|
-
this._throttledUpdatePositionState(info.duration, info.currentTime);
|
|
382
|
+
this._throttledUpdatePositionState(info.duration, info.currentTime, this._playbackRate);
|
|
351
383
|
}
|
|
352
384
|
this._onProgress?.(info);
|
|
353
385
|
}
|
|
@@ -393,7 +425,7 @@ export class Queue implements TrackQueueRef {
|
|
|
393
425
|
return;
|
|
394
426
|
}
|
|
395
427
|
}
|
|
396
|
-
const limit = fromIndex +
|
|
428
|
+
const limit = fromIndex + this._preloadNumTracks + 1;
|
|
397
429
|
this.onDebug(`_preloadAhead(${fromIndex}) limit=${limit} trackCount=${this._tracks.length}`);
|
|
398
430
|
for (let i = fromIndex + 1; i < this._tracks.length && i < limit; i++) {
|
|
399
431
|
const t = this._tracks[i];
|
|
@@ -419,7 +451,7 @@ export class Queue implements TrackQueueRef {
|
|
|
419
451
|
|
|
420
452
|
private _tryScheduleGapless(curIndex: number): void {
|
|
421
453
|
const ctx = getAudioContext();
|
|
422
|
-
if (!ctx || this.
|
|
454
|
+
if (!ctx || this.playbackMethod === 'HTML5_ONLY') return;
|
|
423
455
|
|
|
424
456
|
const nextIndex = curIndex + 1;
|
|
425
457
|
if (nextIndex >= this._tracks.length) return;
|
|
@@ -440,7 +472,18 @@ export class Queue implements TrackQueueRef {
|
|
|
440
472
|
|
|
441
473
|
if (endTime < ctx.currentTime + 0.01) return;
|
|
442
474
|
|
|
475
|
+
const timeUntilEnd = endTime - ctx.currentTime;
|
|
476
|
+
if (current.playbackType === 'HTML5' && timeUntilEnd > MAX_SCHEDULE_LOOKAHEAD) {
|
|
477
|
+
this.onDebug(
|
|
478
|
+
`_tryScheduleGapless: deferring — HTML5 track ${curIndex} has ${timeUntilEnd.toFixed(1)}s remaining (max lookahead=${MAX_SCHEDULE_LOOKAHEAD}s)`
|
|
479
|
+
);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
443
483
|
next.scheduleGaplessStart(endTime);
|
|
484
|
+
this.onDebug(
|
|
485
|
+
`_tryScheduleGapless: scheduled track ${nextIndex} at endTime=${endTime.toFixed(3)} (in ${(endTime - ctx.currentTime).toFixed(1)}s) curPlaybackType=${current.playbackType}`
|
|
486
|
+
);
|
|
444
487
|
this._scheduledNextIndex = nextIndex;
|
|
445
488
|
}
|
|
446
489
|
|
|
@@ -451,10 +494,10 @@ export class Queue implements TrackQueueRef {
|
|
|
451
494
|
if (isNaN(duration)) return null;
|
|
452
495
|
|
|
453
496
|
if (track.scheduledStartContextTime !== null) {
|
|
454
|
-
return track.scheduledStartContextTime + duration;
|
|
497
|
+
return track.scheduledStartContextTime + duration / this._playbackRate;
|
|
455
498
|
}
|
|
456
499
|
|
|
457
|
-
const remaining = duration - track.currentTime;
|
|
500
|
+
const remaining = (duration - track.currentTime) / this._playbackRate;
|
|
458
501
|
if (remaining <= 0) return null;
|
|
459
502
|
return ctx.currentTime + remaining;
|
|
460
503
|
}
|
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
|
);
|
|
@@ -187,7 +198,8 @@ export class Track {
|
|
|
187
198
|
this.audio.currentTime = 0;
|
|
188
199
|
},
|
|
189
200
|
resetTiming: () => {
|
|
190
|
-
this.
|
|
201
|
+
this._waRefCtxTime = 0;
|
|
202
|
+
this._waRefTrackTime = 0;
|
|
191
203
|
this.pausedAtTrackTime = 0;
|
|
192
204
|
},
|
|
193
205
|
notifyTrackEnded: () => {
|
|
@@ -228,6 +240,17 @@ export class Track {
|
|
|
228
240
|
this._actor.send({ type: 'SET_VOLUME', volume: vol });
|
|
229
241
|
}
|
|
230
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
|
+
|
|
231
254
|
preload(): void {
|
|
232
255
|
this.queueRef.onDebug(
|
|
233
256
|
`preload() track=${this.index} state=${this._actor.getSnapshot().value} hasBuffer=${!!this.audioBuffer} hasCtx=${!!this.ctx}`
|
|
@@ -296,7 +319,7 @@ export class Track {
|
|
|
296
319
|
if (snap.value === 'webaudio') {
|
|
297
320
|
if (!snap.context.isPlaying) return this.pausedAtTrackTime;
|
|
298
321
|
if (!this.ctx) return 0;
|
|
299
|
-
return Math.max(0, this.ctx.currentTime - this.
|
|
322
|
+
return Math.max(0, this._waRefTrackTime + (this.ctx.currentTime - this._waRefCtxTime) * this.queueRef.playbackRate);
|
|
300
323
|
}
|
|
301
324
|
return this.audio.currentTime;
|
|
302
325
|
}
|
|
@@ -356,6 +379,7 @@ export class Track {
|
|
|
356
379
|
playbackType: this.playbackType,
|
|
357
380
|
webAudioLoadingState: this.webAudioLoadingState,
|
|
358
381
|
metadata: this.metadata,
|
|
382
|
+
playbackRate: this.queueRef.playbackRate,
|
|
359
383
|
machineState: this.machineState,
|
|
360
384
|
};
|
|
361
385
|
}
|
|
@@ -366,6 +390,7 @@ export class Track {
|
|
|
366
390
|
|
|
367
391
|
private _playHtml5(): void {
|
|
368
392
|
if (this.audio.preload !== 'auto') this.audio.preload = 'auto';
|
|
393
|
+
this.audio.playbackRate = this.queueRef.playbackRate;
|
|
369
394
|
const promise = this.audio.play();
|
|
370
395
|
if (promise) {
|
|
371
396
|
promise.catch((err: unknown) => {
|
|
@@ -414,11 +439,13 @@ export class Track {
|
|
|
414
439
|
|
|
415
440
|
this.sourceNode = this.ctx.createBufferSource();
|
|
416
441
|
this.sourceNode.buffer = this.audioBuffer;
|
|
442
|
+
this.sourceNode.playbackRate.value = this.queueRef.playbackRate;
|
|
417
443
|
this.sourceNode.connect(this.gainNode);
|
|
418
444
|
this.gainNode.connect(this.ctx.destination);
|
|
419
445
|
this.sourceNode.onended = this._handleWebAudioEnded;
|
|
420
446
|
|
|
421
|
-
this.
|
|
447
|
+
this._waRefCtxTime = this.ctx.currentTime;
|
|
448
|
+
this._waRefTrackTime = offset;
|
|
422
449
|
this.sourceNode.start(0, offset);
|
|
423
450
|
}
|
|
424
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 }),
|
|
@@ -163,6 +169,7 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
163
169
|
},
|
|
164
170
|
DEACTIVATE: {
|
|
165
171
|
actions: [
|
|
172
|
+
'pauseHtml5',
|
|
166
173
|
'resetHtml5Element',
|
|
167
174
|
'resetTiming',
|
|
168
175
|
'stopProgressLoop',
|
|
@@ -186,6 +193,10 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
186
193
|
'startProgressLoop',
|
|
187
194
|
],
|
|
188
195
|
},
|
|
196
|
+
{
|
|
197
|
+
guard: 'isWebAudioOnly',
|
|
198
|
+
actions: ['setPendingPlay', 'triggerFetchForPendingPlay'],
|
|
199
|
+
},
|
|
189
200
|
{
|
|
190
201
|
target: 'html5',
|
|
191
202
|
actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop'],
|
|
@@ -210,11 +221,18 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
210
221
|
BUFFER_LOADING: {
|
|
211
222
|
actions: 'setLoadingState',
|
|
212
223
|
},
|
|
213
|
-
BUFFER_READY:
|
|
214
|
-
|
|
215
|
-
|
|
224
|
+
BUFFER_READY: [
|
|
225
|
+
{
|
|
226
|
+
guard: ({ context }: { context: TrackContext }) => context.pendingPlay,
|
|
227
|
+
target: 'webaudio',
|
|
228
|
+
actions: ['clearPendingPlay', 'setPlayingWebAudio', 'startSourceNode', 'startProgressLoop'],
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
actions: 'setLoadedState',
|
|
232
|
+
},
|
|
233
|
+
],
|
|
216
234
|
BUFFER_ERROR: {
|
|
217
|
-
actions: 'setErrorState',
|
|
235
|
+
actions: ['setErrorState', 'clearPendingPlay'],
|
|
218
236
|
},
|
|
219
237
|
URL_RESOLVED: {
|
|
220
238
|
actions: 'setResolvedUrl',
|
|
@@ -289,13 +307,20 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
289
307
|
BUFFER_LOADING: {
|
|
290
308
|
actions: 'setLoadingState',
|
|
291
309
|
},
|
|
292
|
-
BUFFER_READY:
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
310
|
+
BUFFER_READY: [
|
|
311
|
+
{
|
|
312
|
+
guard: ({ context }: { context: TrackContext }) => context.pendingPlay,
|
|
313
|
+
target: 'webaudio',
|
|
314
|
+
actions: ['clearPendingPlay', 'setPlayingWebAudio', 'startSourceNode', 'startProgressLoop'],
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
target: 'idle',
|
|
318
|
+
actions: 'setLoadedState',
|
|
319
|
+
},
|
|
320
|
+
],
|
|
296
321
|
BUFFER_ERROR: {
|
|
297
322
|
target: 'idle',
|
|
298
|
-
actions: 'setErrorState',
|
|
323
|
+
actions: ['setErrorState', 'clearPendingPlay'],
|
|
299
324
|
},
|
|
300
325
|
PLAY: [
|
|
301
326
|
{
|
|
@@ -307,6 +332,10 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
307
332
|
'startProgressLoop',
|
|
308
333
|
],
|
|
309
334
|
},
|
|
335
|
+
{
|
|
336
|
+
guard: 'isWebAudioOnly',
|
|
337
|
+
actions: ['setPendingPlay', 'triggerFetchForPendingPlay'],
|
|
338
|
+
},
|
|
310
339
|
{
|
|
311
340
|
target: 'html5',
|
|
312
341
|
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
|
}
|