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
|
@@ -7,13 +7,24 @@
|
|
|
7
7
|
// loading Track is preloaded (not yet playing). Decode in progress.
|
|
8
8
|
// webaudio AudioBufferSourceNode is the active output.
|
|
9
9
|
//
|
|
10
|
+
// Design invariant — "Web Audio always wins eventually":
|
|
11
|
+
// When a track's buffer finishes decoding (BUFFER_READY), webAudioLoadingState
|
|
12
|
+
// is set to 'LOADED' regardless of what state the machine is in (html5, loading,
|
|
13
|
+
// or idle). We intentionally do NOT switch mid-stream — the track stays in html5
|
|
14
|
+
// until the next play(). But every state handles BUFFER_READY, so the flag is
|
|
15
|
+
// never lost, and the next play() will see the buffer and use Web Audio.
|
|
16
|
+
//
|
|
17
|
+
// All DEACTIVATE transitions land in idle (not loading), so a deactivated track
|
|
18
|
+
// with a loaded buffer is always in idle+LOADED — ready for Web Audio on re-play.
|
|
19
|
+
//
|
|
10
20
|
// Bug fixes in this rewrite:
|
|
11
21
|
// #2: BUFFER_READY in html5 stays in html5 (no longer auto-transitions to webaudio)
|
|
12
22
|
// #3: DEACTIVATE from webaudio → idle (was staying in webaudio)
|
|
13
23
|
// #4: Removed dead error state (webAudioLoadingState: 'ERROR' is sufficient)
|
|
14
24
|
// ---------------------------------------------------------------------------
|
|
15
25
|
|
|
16
|
-
import { setup, assign } from 'xstate';
|
|
26
|
+
import { setup, assign, type AnyActorRef } from 'xstate';
|
|
27
|
+
import { fetchDecodeMachine } from './fetchDecode.machine';
|
|
17
28
|
import type { WebAudioLoadingState, PlaybackType } from '../types';
|
|
18
29
|
|
|
19
30
|
// ---- Context ---------------------------------------------------------------
|
|
@@ -24,9 +35,10 @@ export interface TrackContext {
|
|
|
24
35
|
skipHEAD: boolean;
|
|
25
36
|
playbackType: PlaybackType;
|
|
26
37
|
webAudioLoadingState: WebAudioLoadingState;
|
|
27
|
-
webAudioStartedAt: number;
|
|
28
|
-
pausedAtTrackTime: number;
|
|
29
38
|
isPlaying: boolean;
|
|
39
|
+
scheduledStartContextTime: number | null;
|
|
40
|
+
notifiedLookahead: boolean;
|
|
41
|
+
fetchDecodeRef: AnyActorRef | null;
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
// ---- Events ----------------------------------------------------------------
|
|
@@ -45,7 +57,11 @@ export type TrackEvent =
|
|
|
45
57
|
| { type: 'BUFFER_ERROR' }
|
|
46
58
|
| { type: 'HTML5_ENDED' }
|
|
47
59
|
| { type: 'WEBAUDIO_ENDED' }
|
|
48
|
-
| { type: 'URL_RESOLVED'; url: string }
|
|
60
|
+
| { type: 'URL_RESOLVED'; url: string }
|
|
61
|
+
| { type: 'START_FETCH' }
|
|
62
|
+
| { type: 'SCHEDULE_GAPLESS'; when: number }
|
|
63
|
+
| { type: 'CANCEL_GAPLESS' }
|
|
64
|
+
| { type: 'LOOKAHEAD_REACHED' };
|
|
49
65
|
|
|
50
66
|
// ---- Machine ---------------------------------------------------------------
|
|
51
67
|
|
|
@@ -55,38 +71,151 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
55
71
|
context: {} as TrackContext,
|
|
56
72
|
events: {} as TrackEvent,
|
|
57
73
|
},
|
|
74
|
+
actors: {
|
|
75
|
+
fetchDecode: fetchDecodeMachine,
|
|
76
|
+
},
|
|
77
|
+
guards: {
|
|
78
|
+
canPlayWebAudio: () => false,
|
|
79
|
+
canStartFetch: ({ context }) => context.webAudioLoadingState === 'NONE' && context.fetchDecodeRef === null,
|
|
80
|
+
},
|
|
81
|
+
actions: {
|
|
82
|
+
playHtml5: () => {},
|
|
83
|
+
startSourceNode: () => {},
|
|
84
|
+
startScheduledSourceNode: () => {},
|
|
85
|
+
startProgressLoop: () => {},
|
|
86
|
+
pauseHtml5: () => {},
|
|
87
|
+
freezePausedTime: () => {},
|
|
88
|
+
stopSourceNode: () => {},
|
|
89
|
+
disconnectGain: () => {},
|
|
90
|
+
stopProgressLoop: () => {},
|
|
91
|
+
reportProgress: () => {},
|
|
92
|
+
seekHtml5: () => {},
|
|
93
|
+
seekWebAudio: () => {},
|
|
94
|
+
resetHtml5Element: () => {},
|
|
95
|
+
resetTiming: () => {},
|
|
96
|
+
notifyTrackEnded: () => {},
|
|
97
|
+
setIsPlaying: assign({ isPlaying: () => true }),
|
|
98
|
+
clearIsPlaying: assign({ isPlaying: () => false }),
|
|
99
|
+
setLoadingState: assign({ webAudioLoadingState: () => 'LOADING' as WebAudioLoadingState }),
|
|
100
|
+
setLoadedState: assign({ webAudioLoadingState: () => 'LOADED' as WebAudioLoadingState }),
|
|
101
|
+
setErrorState: assign({ webAudioLoadingState: () => 'ERROR' as WebAudioLoadingState }),
|
|
102
|
+
clearScheduleAndLookahead: assign({ scheduledStartContextTime: () => null, notifiedLookahead: () => false }),
|
|
103
|
+
setPlayingWebAudio: assign({
|
|
104
|
+
isPlaying: () => true,
|
|
105
|
+
webAudioLoadingState: () => 'LOADED' as WebAudioLoadingState,
|
|
106
|
+
playbackType: () => 'WEBAUDIO' as PlaybackType,
|
|
107
|
+
}),
|
|
108
|
+
setScheduledGapless: assign({
|
|
109
|
+
isPlaying: () => true,
|
|
110
|
+
webAudioLoadingState: () => 'LOADED' as WebAudioLoadingState,
|
|
111
|
+
playbackType: () => 'WEBAUDIO' as PlaybackType,
|
|
112
|
+
scheduledStartContextTime: ({ event }) =>
|
|
113
|
+
(event as { type: 'SCHEDULE_GAPLESS'; when: number }).when,
|
|
114
|
+
}),
|
|
115
|
+
clearPlayingAndSchedule: assign({
|
|
116
|
+
isPlaying: () => false,
|
|
117
|
+
scheduledStartContextTime: () => null,
|
|
118
|
+
notifiedLookahead: () => false,
|
|
119
|
+
}),
|
|
120
|
+
setNotifiedLookahead: assign({ notifiedLookahead: () => true }),
|
|
121
|
+
setResolvedUrl: assign({
|
|
122
|
+
resolvedUrl: ({ event }) => (event as { type: 'URL_RESOLVED'; url: string }).url,
|
|
123
|
+
}),
|
|
124
|
+
clearScheduledStart: assign({ scheduledStartContextTime: () => null }),
|
|
125
|
+
setPlayingWebAudioType: assign({
|
|
126
|
+
isPlaying: () => true,
|
|
127
|
+
playbackType: () => 'WEBAUDIO' as PlaybackType,
|
|
128
|
+
}),
|
|
129
|
+
},
|
|
58
130
|
}).createMachine({
|
|
59
131
|
id: 'track',
|
|
60
132
|
initial: 'idle',
|
|
61
133
|
context: initialContext,
|
|
62
134
|
|
|
135
|
+
on: {
|
|
136
|
+
START_FETCH: {
|
|
137
|
+
guard: 'canStartFetch',
|
|
138
|
+
actions: assign({
|
|
139
|
+
webAudioLoadingState: () => 'LOADING' as WebAudioLoadingState,
|
|
140
|
+
fetchDecodeRef: ({ context, spawn }) =>
|
|
141
|
+
spawn('fetchDecode', {
|
|
142
|
+
id: 'fetchDecode',
|
|
143
|
+
input: {
|
|
144
|
+
trackUrl: context.trackUrl,
|
|
145
|
+
resolvedUrl: context.resolvedUrl,
|
|
146
|
+
skipHEAD: context.skipHEAD,
|
|
147
|
+
},
|
|
148
|
+
}),
|
|
149
|
+
}),
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
|
|
63
153
|
states: {
|
|
64
154
|
// -----------------------------------------------------------------
|
|
65
155
|
// idle: constructed but not started
|
|
66
156
|
// -----------------------------------------------------------------
|
|
67
157
|
idle: {
|
|
68
158
|
on: {
|
|
69
|
-
HTML5_ENDED: {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
159
|
+
HTML5_ENDED: {
|
|
160
|
+
actions: ['notifyTrackEnded'],
|
|
161
|
+
},
|
|
162
|
+
DEACTIVATE: {
|
|
163
|
+
actions: [
|
|
164
|
+
'resetHtml5Element',
|
|
165
|
+
'resetTiming',
|
|
166
|
+
'stopProgressLoop',
|
|
167
|
+
'clearScheduleAndLookahead',
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
ACTIVATE: {
|
|
171
|
+
actions: [
|
|
172
|
+
'resetTiming',
|
|
173
|
+
'resetHtml5Element',
|
|
174
|
+
'clearScheduleAndLookahead',
|
|
175
|
+
],
|
|
73
176
|
},
|
|
177
|
+
PLAY: [
|
|
178
|
+
{
|
|
179
|
+
guard: 'canPlayWebAudio',
|
|
180
|
+
target: 'webaudio',
|
|
181
|
+
actions: [
|
|
182
|
+
'setPlayingWebAudio',
|
|
183
|
+
'startSourceNode',
|
|
184
|
+
'startProgressLoop',
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
target: 'html5',
|
|
189
|
+
actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop'],
|
|
190
|
+
},
|
|
191
|
+
],
|
|
74
192
|
PLAY_WEBAUDIO: {
|
|
75
193
|
target: 'webaudio',
|
|
76
|
-
actions:
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
194
|
+
actions: [
|
|
195
|
+
'setPlayingWebAudio',
|
|
196
|
+
'startSourceNode',
|
|
197
|
+
'startProgressLoop',
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
SCHEDULE_GAPLESS: {
|
|
201
|
+
target: 'webaudio',
|
|
202
|
+
actions: [
|
|
203
|
+
'setScheduledGapless',
|
|
204
|
+
'startScheduledSourceNode',
|
|
205
|
+
],
|
|
81
206
|
},
|
|
82
207
|
PRELOAD: { target: 'loading' },
|
|
83
208
|
BUFFER_LOADING: {
|
|
84
|
-
actions:
|
|
209
|
+
actions: 'setLoadingState',
|
|
210
|
+
},
|
|
211
|
+
BUFFER_READY: {
|
|
212
|
+
actions: 'setLoadedState',
|
|
213
|
+
},
|
|
214
|
+
BUFFER_ERROR: {
|
|
215
|
+
actions: 'setErrorState',
|
|
85
216
|
},
|
|
86
217
|
URL_RESOLVED: {
|
|
87
|
-
actions:
|
|
88
|
-
resolvedUrl: ({ event }) => (event as { type: 'URL_RESOLVED'; url: string }).url,
|
|
89
|
-
}),
|
|
218
|
+
actions: 'setResolvedUrl',
|
|
90
219
|
},
|
|
91
220
|
},
|
|
92
221
|
},
|
|
@@ -97,51 +226,55 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
97
226
|
html5: {
|
|
98
227
|
on: {
|
|
99
228
|
PAUSE: {
|
|
100
|
-
actions:
|
|
229
|
+
actions: ['clearIsPlaying', 'pauseHtml5', 'stopProgressLoop', 'reportProgress'],
|
|
101
230
|
},
|
|
102
231
|
PLAY: {
|
|
103
|
-
actions:
|
|
232
|
+
actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop'],
|
|
104
233
|
},
|
|
105
234
|
BUFFER_LOADING: {
|
|
106
|
-
actions:
|
|
235
|
+
actions: 'setLoadingState',
|
|
107
236
|
},
|
|
108
237
|
PLAY_WEBAUDIO: {
|
|
109
238
|
target: 'webaudio',
|
|
110
|
-
actions:
|
|
111
|
-
isPlaying: () => true,
|
|
112
|
-
webAudioLoadingState: () => 'LOADED' as WebAudioLoadingState,
|
|
113
|
-
playbackType: () => 'WEBAUDIO' as PlaybackType,
|
|
114
|
-
}),
|
|
239
|
+
actions: 'setPlayingWebAudio',
|
|
115
240
|
},
|
|
116
241
|
// Bug #2 fix: BUFFER_READY in html5 stays in html5, only updates loading state.
|
|
117
242
|
// The actual switchover to webaudio only happens via explicit PLAY_WEBAUDIO.
|
|
118
243
|
BUFFER_READY: {
|
|
119
|
-
actions:
|
|
120
|
-
webAudioLoadingState: () => 'LOADED' as WebAudioLoadingState,
|
|
121
|
-
}),
|
|
244
|
+
actions: 'setLoadedState',
|
|
122
245
|
},
|
|
123
246
|
BUFFER_ERROR: {
|
|
124
|
-
actions:
|
|
125
|
-
webAudioLoadingState: () => 'ERROR' as WebAudioLoadingState,
|
|
126
|
-
}),
|
|
247
|
+
actions: 'setErrorState',
|
|
127
248
|
},
|
|
128
249
|
SEEK: {
|
|
129
|
-
actions:
|
|
130
|
-
|
|
131
|
-
|
|
250
|
+
actions: [
|
|
251
|
+
'seekHtml5',
|
|
252
|
+
'reportProgress',
|
|
253
|
+
],
|
|
254
|
+
},
|
|
255
|
+
LOOKAHEAD_REACHED: {
|
|
256
|
+
actions: 'setNotifiedLookahead',
|
|
132
257
|
},
|
|
133
258
|
HTML5_ENDED: {
|
|
134
259
|
target: 'idle',
|
|
135
|
-
actions:
|
|
260
|
+
actions: ['clearIsPlaying', 'stopProgressLoop', 'notifyTrackEnded'],
|
|
261
|
+
},
|
|
262
|
+
ACTIVATE: {
|
|
263
|
+
target: 'idle',
|
|
264
|
+
actions: [
|
|
265
|
+
'clearPlayingAndSchedule',
|
|
266
|
+
'pauseHtml5',
|
|
267
|
+
'stopProgressLoop',
|
|
268
|
+
'resetTiming',
|
|
269
|
+
'resetHtml5Element',
|
|
270
|
+
],
|
|
136
271
|
},
|
|
137
272
|
URL_RESOLVED: {
|
|
138
|
-
actions:
|
|
139
|
-
resolvedUrl: ({ event }) => (event as { type: 'URL_RESOLVED'; url: string }).url,
|
|
140
|
-
}),
|
|
273
|
+
actions: 'setResolvedUrl',
|
|
141
274
|
},
|
|
142
275
|
DEACTIVATE: {
|
|
143
|
-
target: '
|
|
144
|
-
actions:
|
|
276
|
+
target: 'idle',
|
|
277
|
+
actions: ['clearIsPlaying', 'pauseHtml5', 'resetHtml5Element', 'resetTiming', 'stopProgressLoop'],
|
|
145
278
|
},
|
|
146
279
|
},
|
|
147
280
|
},
|
|
@@ -152,36 +285,60 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
152
285
|
loading: {
|
|
153
286
|
on: {
|
|
154
287
|
BUFFER_LOADING: {
|
|
155
|
-
actions:
|
|
288
|
+
actions: 'setLoadingState',
|
|
156
289
|
},
|
|
157
290
|
BUFFER_READY: {
|
|
158
291
|
target: 'idle',
|
|
159
|
-
actions:
|
|
160
|
-
webAudioLoadingState: () => 'LOADED' as WebAudioLoadingState,
|
|
161
|
-
}),
|
|
292
|
+
actions: 'setLoadedState',
|
|
162
293
|
},
|
|
163
294
|
BUFFER_ERROR: {
|
|
164
295
|
target: 'idle',
|
|
165
|
-
actions:
|
|
166
|
-
webAudioLoadingState: () => 'ERROR' as WebAudioLoadingState,
|
|
167
|
-
}),
|
|
168
|
-
},
|
|
169
|
-
PLAY: {
|
|
170
|
-
target: 'html5',
|
|
171
|
-
actions: assign({ isPlaying: () => true }),
|
|
296
|
+
actions: 'setErrorState',
|
|
172
297
|
},
|
|
298
|
+
PLAY: [
|
|
299
|
+
{
|
|
300
|
+
guard: 'canPlayWebAudio',
|
|
301
|
+
target: 'webaudio',
|
|
302
|
+
actions: [
|
|
303
|
+
'setPlayingWebAudio',
|
|
304
|
+
'startSourceNode',
|
|
305
|
+
'startProgressLoop',
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
target: 'html5',
|
|
310
|
+
actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop'],
|
|
311
|
+
},
|
|
312
|
+
],
|
|
173
313
|
PLAY_WEBAUDIO: {
|
|
174
314
|
target: 'webaudio',
|
|
175
|
-
actions:
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
315
|
+
actions: [
|
|
316
|
+
'setPlayingWebAudio',
|
|
317
|
+
'startSourceNode',
|
|
318
|
+
'startProgressLoop',
|
|
319
|
+
],
|
|
320
|
+
},
|
|
321
|
+
SCHEDULE_GAPLESS: {
|
|
322
|
+
target: 'webaudio',
|
|
323
|
+
actions: [
|
|
324
|
+
'setScheduledGapless',
|
|
325
|
+
'startScheduledSourceNode',
|
|
326
|
+
],
|
|
327
|
+
},
|
|
328
|
+
ACTIVATE: {
|
|
329
|
+
target: 'idle',
|
|
330
|
+
actions: [
|
|
331
|
+
'clearPlayingAndSchedule',
|
|
332
|
+
'resetTiming',
|
|
333
|
+
'resetHtml5Element',
|
|
334
|
+
],
|
|
335
|
+
},
|
|
336
|
+
DEACTIVATE: {
|
|
337
|
+
target: 'idle',
|
|
338
|
+
actions: ['clearIsPlaying', 'resetTiming'],
|
|
180
339
|
},
|
|
181
340
|
URL_RESOLVED: {
|
|
182
|
-
actions:
|
|
183
|
-
resolvedUrl: ({ event }) => (event as { type: 'URL_RESOLVED'; url: string }).url,
|
|
184
|
-
}),
|
|
341
|
+
actions: 'setResolvedUrl',
|
|
185
342
|
},
|
|
186
343
|
},
|
|
187
344
|
},
|
|
@@ -192,31 +349,74 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
192
349
|
webaudio: {
|
|
193
350
|
on: {
|
|
194
351
|
PAUSE: {
|
|
195
|
-
actions:
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
352
|
+
actions: [
|
|
353
|
+
'clearIsPlaying',
|
|
354
|
+
'freezePausedTime',
|
|
355
|
+
'stopSourceNode',
|
|
356
|
+
'disconnectGain',
|
|
357
|
+
'stopProgressLoop',
|
|
358
|
+
'reportProgress',
|
|
359
|
+
],
|
|
199
360
|
},
|
|
361
|
+
PLAY: [
|
|
362
|
+
{
|
|
363
|
+
guard: 'canPlayWebAudio',
|
|
364
|
+
actions: ['setIsPlaying', 'startSourceNode', 'startProgressLoop'],
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
actions: 'setIsPlaying',
|
|
368
|
+
},
|
|
369
|
+
],
|
|
200
370
|
PLAY_WEBAUDIO: {
|
|
201
|
-
actions:
|
|
202
|
-
isPlaying: () => true,
|
|
203
|
-
playbackType: () => 'WEBAUDIO' as PlaybackType,
|
|
204
|
-
}),
|
|
371
|
+
actions: 'setPlayingWebAudioType',
|
|
205
372
|
},
|
|
206
373
|
SEEK: {
|
|
207
|
-
actions:
|
|
208
|
-
|
|
209
|
-
|
|
374
|
+
actions: [
|
|
375
|
+
'clearScheduledStart',
|
|
376
|
+
'seekWebAudio',
|
|
377
|
+
'reportProgress',
|
|
378
|
+
],
|
|
210
379
|
},
|
|
211
380
|
SET_VOLUME: {},
|
|
381
|
+
CANCEL_GAPLESS: {
|
|
382
|
+
target: 'idle',
|
|
383
|
+
actions: [
|
|
384
|
+
'clearPlayingAndSchedule',
|
|
385
|
+
'stopSourceNode',
|
|
386
|
+
'disconnectGain',
|
|
387
|
+
'stopProgressLoop',
|
|
388
|
+
'resetTiming',
|
|
389
|
+
],
|
|
390
|
+
},
|
|
391
|
+
LOOKAHEAD_REACHED: {
|
|
392
|
+
actions: 'setNotifiedLookahead',
|
|
393
|
+
},
|
|
212
394
|
WEBAUDIO_ENDED: {
|
|
213
395
|
target: 'idle',
|
|
214
|
-
actions:
|
|
396
|
+
actions: ['clearIsPlaying', 'stopProgressLoop', 'notifyTrackEnded'],
|
|
397
|
+
},
|
|
398
|
+
ACTIVATE: {
|
|
399
|
+
target: 'idle',
|
|
400
|
+
actions: [
|
|
401
|
+
'clearPlayingAndSchedule',
|
|
402
|
+
'stopSourceNode',
|
|
403
|
+
'disconnectGain',
|
|
404
|
+
'stopProgressLoop',
|
|
405
|
+
'resetTiming',
|
|
406
|
+
'resetHtml5Element',
|
|
407
|
+
],
|
|
215
408
|
},
|
|
216
409
|
// Bug #3 fix: DEACTIVATE from webaudio → idle (was staying in webaudio)
|
|
217
410
|
DEACTIVATE: {
|
|
218
411
|
target: 'idle',
|
|
219
|
-
actions:
|
|
412
|
+
actions: [
|
|
413
|
+
'clearPlayingAndSchedule',
|
|
414
|
+
'stopSourceNode',
|
|
415
|
+
'disconnectGain',
|
|
416
|
+
'resetTiming',
|
|
417
|
+
'resetHtml5Element',
|
|
418
|
+
'stopProgressLoop',
|
|
419
|
+
],
|
|
220
420
|
},
|
|
221
421
|
},
|
|
222
422
|
},
|