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.
@@ -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
- PLAY: {
71
- target: 'html5',
72
- actions: assign({ isPlaying: () => true }),
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: assign({
77
- isPlaying: () => true,
78
- webAudioLoadingState: () => 'LOADED' as WebAudioLoadingState,
79
- playbackType: () => 'WEBAUDIO' as PlaybackType,
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: assign({ webAudioLoadingState: () => 'LOADING' as WebAudioLoadingState }),
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: assign({
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: assign({ isPlaying: () => false }),
229
+ actions: ['clearIsPlaying', 'pauseHtml5', 'stopProgressLoop', 'reportProgress'],
101
230
  },
102
231
  PLAY: {
103
- actions: assign({ isPlaying: () => true }),
232
+ actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop'],
104
233
  },
105
234
  BUFFER_LOADING: {
106
- actions: assign({ webAudioLoadingState: () => 'LOADING' as WebAudioLoadingState }),
235
+ actions: 'setLoadingState',
107
236
  },
108
237
  PLAY_WEBAUDIO: {
109
238
  target: 'webaudio',
110
- actions: assign({
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: assign({
120
- webAudioLoadingState: () => 'LOADED' as WebAudioLoadingState,
121
- }),
244
+ actions: 'setLoadedState',
122
245
  },
123
246
  BUFFER_ERROR: {
124
- actions: assign({
125
- webAudioLoadingState: () => 'ERROR' as WebAudioLoadingState,
126
- }),
247
+ actions: 'setErrorState',
127
248
  },
128
249
  SEEK: {
129
- actions: assign({
130
- pausedAtTrackTime: ({ event }) => (event as { type: 'SEEK'; time: number }).time,
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: assign({ isPlaying: () => false }),
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: assign({
139
- resolvedUrl: ({ event }) => (event as { type: 'URL_RESOLVED'; url: string }).url,
140
- }),
273
+ actions: 'setResolvedUrl',
141
274
  },
142
275
  DEACTIVATE: {
143
- target: 'loading',
144
- actions: assign({ isPlaying: () => false }),
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: assign({ webAudioLoadingState: () => 'LOADING' as WebAudioLoadingState }),
288
+ actions: 'setLoadingState',
156
289
  },
157
290
  BUFFER_READY: {
158
291
  target: 'idle',
159
- actions: assign({
160
- webAudioLoadingState: () => 'LOADED' as WebAudioLoadingState,
161
- }),
292
+ actions: 'setLoadedState',
162
293
  },
163
294
  BUFFER_ERROR: {
164
295
  target: 'idle',
165
- actions: assign({
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: assign({
176
- isPlaying: () => true,
177
- webAudioLoadingState: () => 'LOADED' as WebAudioLoadingState,
178
- playbackType: () => 'WEBAUDIO' as PlaybackType,
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: assign({
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: assign({ isPlaying: () => false }),
196
- },
197
- PLAY: {
198
- actions: assign({ isPlaying: () => true }),
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: assign({
202
- isPlaying: () => true,
203
- playbackType: () => 'WEBAUDIO' as PlaybackType,
204
- }),
371
+ actions: 'setPlayingWebAudioType',
205
372
  },
206
373
  SEEK: {
207
- actions: assign({
208
- pausedAtTrackTime: ({ event }) => (event as { type: 'SEEK'; time: number }).time,
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: assign({ isPlaying: () => false }),
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: assign({ isPlaying: () => false }),
412
+ actions: [
413
+ 'clearPlayingAndSchedule',
414
+ 'stopSourceNode',
415
+ 'disconnectGain',
416
+ 'resetTiming',
417
+ 'resetHtml5Element',
418
+ 'stopProgressLoop',
419
+ ],
220
420
  },
221
421
  },
222
422
  },