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.
@@ -0,0 +1,130 @@
1
+ // ---------------------------------------------------------------------------
2
+ // FetchDecodeMachine — child actor for the fetch + decode pipeline
3
+ //
4
+ // States: resolvingUrl → fetching → decoding → done | error
5
+ //
6
+ // Spawned by TrackMachine via START_FETCH. Sends BUFFER_READY, BUFFER_ERROR,
7
+ // and URL_RESOLVED back to the parent — the same events TrackMachine already
8
+ // handles. Promise implementations are no-op defaults, provided by Track.ts
9
+ // via .provide().
10
+ //
11
+ // xstate v5 automatically passes an AbortSignal to fromPromise actors, so
12
+ // when the parent stops (destroy), in-flight fetches are aborted for free.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import { setup, assign, sendParent, fromPromise } from 'xstate';
16
+
17
+ // ---- Context ---------------------------------------------------------------
18
+
19
+ export interface FetchDecodeContext {
20
+ trackUrl: string;
21
+ resolvedUrl: string;
22
+ skipHEAD: boolean;
23
+ }
24
+
25
+ // ---- Machine ---------------------------------------------------------------
26
+
27
+ export const fetchDecodeMachine = setup({
28
+ types: {
29
+ context: {} as FetchDecodeContext,
30
+ input: {} as FetchDecodeContext,
31
+ },
32
+ actors: {
33
+ resolveUrl: fromPromise<string | null, { trackUrl: string }>(async () => null),
34
+ fetchAudio: fromPromise<void, { resolvedUrl: string }>(async () => {}),
35
+ decodeAudio: fromPromise<void, void>(async () => {}),
36
+ },
37
+ guards: {
38
+ shouldSkipHEAD: ({ context }) => context.skipHEAD,
39
+ },
40
+ }).createMachine({
41
+ id: 'fetchDecode',
42
+ initial: 'resolvingUrl',
43
+ // Note: xstate v5 internally calls assign() when initialising a child
44
+ // actor with a context function, which produces a false-positive
45
+ // "Custom actions should not call assign()" warning when this machine
46
+ // is spawned from a parent. This is harmless and cannot be avoided.
47
+ context: ({ input }) => ({
48
+ trackUrl: input.trackUrl,
49
+ resolvedUrl: input.resolvedUrl,
50
+ skipHEAD: input.skipHEAD,
51
+ }),
52
+
53
+ states: {
54
+ // -----------------------------------------------------------------
55
+ // resolvingUrl: HEAD request to resolve redirects
56
+ // -----------------------------------------------------------------
57
+ resolvingUrl: {
58
+ always: {
59
+ guard: 'shouldSkipHEAD',
60
+ target: 'fetching',
61
+ },
62
+ invoke: {
63
+ id: 'resolveUrl',
64
+ src: 'resolveUrl',
65
+ input: ({ context }) => ({ trackUrl: context.trackUrl }),
66
+ onDone: {
67
+ target: 'fetching',
68
+ actions: [
69
+ assign({
70
+ resolvedUrl: ({ event, context }) => event.output ?? context.resolvedUrl,
71
+ skipHEAD: () => true,
72
+ }),
73
+ sendParent(({ event, context }) => {
74
+ const url = event.output;
75
+ return { type: 'URL_RESOLVED' as const, url: url ?? context.resolvedUrl };
76
+ }),
77
+ ],
78
+ },
79
+ onError: {
80
+ // HEAD failed — non-fatal, fall back to original URL
81
+ target: 'fetching',
82
+ actions: assign({ skipHEAD: () => true }),
83
+ },
84
+ },
85
+ },
86
+
87
+ // -----------------------------------------------------------------
88
+ // fetching: GET the audio data
89
+ // -----------------------------------------------------------------
90
+ fetching: {
91
+ invoke: {
92
+ id: 'fetchAudio',
93
+ src: 'fetchAudio',
94
+ input: ({ context }) => ({ resolvedUrl: context.resolvedUrl }),
95
+ onDone: 'decoding',
96
+ onError: {
97
+ target: 'error',
98
+ actions: sendParent({ type: 'BUFFER_ERROR' }),
99
+ },
100
+ },
101
+ },
102
+
103
+ // -----------------------------------------------------------------
104
+ // decoding: decodeAudioData on the ArrayBuffer
105
+ // -----------------------------------------------------------------
106
+ decoding: {
107
+ invoke: {
108
+ id: 'decodeAudio',
109
+ src: 'decodeAudio',
110
+ input: () => {},
111
+ onDone: {
112
+ target: 'done',
113
+ actions: sendParent({ type: 'BUFFER_READY' }),
114
+ },
115
+ onError: {
116
+ target: 'error',
117
+ actions: sendParent({ type: 'BUFFER_ERROR' }),
118
+ },
119
+ },
120
+ },
121
+
122
+ // -----------------------------------------------------------------
123
+ // Terminal states
124
+ // -----------------------------------------------------------------
125
+ done: { type: 'final' },
126
+ error: { type: 'final' },
127
+ },
128
+ });
129
+
130
+ export type FetchDecodeMachine = typeof fetchDecodeMachine;
@@ -0,0 +1,387 @@
1
+ // ---------------------------------------------------------------------------
2
+ // QueueMachine — top-level queue state (xstate v5)
3
+ //
4
+ // States:
5
+ // idle No tracks, or not yet started.
6
+ // playing A track is actively playing.
7
+ // paused Explicitly paused by the user.
8
+ // ended The last track in the queue has finished.
9
+ //
10
+ // Root-level `on:` eliminates the handler duplication that plagued v2.
11
+ //
12
+ // Named actions (no-op defaults here, real implementations via .provide()):
13
+ // Actions before assign() see the OLD context (e.g. deactivateCurrent
14
+ // sees the old currentTrackIndex). Actions after assign() see the NEW
15
+ // context (e.g. activateAndPlayCurrent sees the incremented index).
16
+ // Implementations MUST use the ({ context }) parameter, NOT getSnapshot().
17
+ // ---------------------------------------------------------------------------
18
+
19
+ import { setup, assign } from 'xstate';
20
+
21
+ // ---- Context ---------------------------------------------------------------
22
+
23
+ export interface QueueContext {
24
+ currentTrackIndex: number;
25
+ trackCount: number;
26
+ }
27
+
28
+ // ---- Events ----------------------------------------------------------------
29
+
30
+ export type QueueEvent =
31
+ | { type: 'PLAY' }
32
+ | { type: 'PAUSE' }
33
+ | { type: 'TOGGLE' }
34
+ | { type: 'NEXT' }
35
+ | { type: 'PREVIOUS' }
36
+ | { type: 'GOTO'; index: number; playImmediately?: boolean }
37
+ | { type: 'SEEK'; time: number }
38
+ | { type: 'SET_VOLUME'; volume: number }
39
+ | { type: 'ADD_TRACK' }
40
+ | { type: 'REMOVE_TRACK'; index: number }
41
+ | { type: 'TRACK_ENDED' }
42
+ | { type: 'TRACK_LOADED'; index: number };
43
+
44
+ // ---- Machine ---------------------------------------------------------------
45
+
46
+ export function createQueueMachine(initialContext: QueueContext) {
47
+ return setup({
48
+ types: {
49
+ context: {} as QueueContext,
50
+ events: {} as QueueEvent,
51
+ },
52
+ guards: {
53
+ hasNextTrack: ({ context }) => context.currentTrackIndex + 1 < context.trackCount,
54
+ playImmediately: ({ event }) =>
55
+ !!(event as { type: 'GOTO'; playImmediately?: boolean }).playImmediately,
56
+ },
57
+ actions: {
58
+ // --- named assign actions (avoids mixing inline assign with custom actions) ---
59
+ incrementTrackCount: assign({
60
+ trackCount: ({ context }) => context.trackCount + 1,
61
+ }),
62
+ decrementTrackCount: assign({
63
+ trackCount: ({ context }) => Math.max(0, context.trackCount - 1),
64
+ currentTrackIndex: ({ context, event }) => {
65
+ const e = event as { type: 'REMOVE_TRACK'; index: number };
66
+ if (e.index < context.currentTrackIndex) {
67
+ return Math.max(0, context.currentTrackIndex - 1);
68
+ }
69
+ return context.currentTrackIndex;
70
+ },
71
+ }),
72
+ gotoTrackIndex: assign({
73
+ currentTrackIndex: ({ event }) =>
74
+ (event as { type: 'GOTO'; index: number }).index,
75
+ }),
76
+ advanceToNextTrack: assign({
77
+ currentTrackIndex: ({ context }) => {
78
+ const next = context.currentTrackIndex + 1;
79
+ return next < context.trackCount ? next : context.currentTrackIndex;
80
+ },
81
+ }),
82
+ goToPreviousTrack: assign({
83
+ currentTrackIndex: ({ context }) => Math.max(0, context.currentTrackIndex - 1),
84
+ }),
85
+ advanceOnTrackEnd: assign({
86
+ currentTrackIndex: ({ context }) => context.currentTrackIndex + 1,
87
+ }),
88
+ resetToFirstTrack: assign({
89
+ currentTrackIndex: () => 0,
90
+ }),
91
+ // --- side-effect actions (no-op defaults, provided by Queue.ts) ---
92
+ deactivateCurrent: () => {},
93
+ deactivateEndedTrack: () => {},
94
+ activateAndPlayCurrent: () => {},
95
+ playOrContinueGapless: () => {},
96
+ cancelAllGapless: () => {},
97
+ notifyStartNewTrack: () => {},
98
+ notifyPlayNextTrack: () => {},
99
+ notifyPlayPreviousTrack: () => {},
100
+ notifyEnded: () => {},
101
+ updateMediaSessionMetadata: () => {},
102
+ preloadAhead: () => {},
103
+ playCurrent: () => {},
104
+ pauseCurrent: () => {},
105
+ seekCurrent: () => {},
106
+ seekCurrentToZero: () => {},
107
+ scheduleGapless: () => {},
108
+ cancelScheduledGapless: () => {},
109
+ cancelAndRescheduleGapless: () => {},
110
+ },
111
+ }).createMachine({
112
+ id: 'queue',
113
+ initial: 'idle',
114
+ context: initialContext,
115
+
116
+ // Global handlers — shared across all states
117
+ on: {
118
+ ADD_TRACK: {
119
+ actions: 'incrementTrackCount',
120
+ },
121
+ REMOVE_TRACK: {
122
+ actions: 'decrementTrackCount',
123
+ },
124
+ },
125
+
126
+ states: {
127
+ // -----------------------------------------------------------------
128
+ // idle
129
+ // -----------------------------------------------------------------
130
+ idle: {
131
+ on: {
132
+ PLAY: {
133
+ target: 'playing',
134
+ actions: ['playCurrent', 'updateMediaSessionMetadata', 'preloadAhead', 'scheduleGapless'],
135
+ },
136
+ GOTO: [
137
+ {
138
+ guard: 'playImmediately',
139
+ target: 'playing',
140
+ actions: [
141
+ 'deactivateCurrent',
142
+ 'cancelAllGapless',
143
+ 'gotoTrackIndex',
144
+ 'activateAndPlayCurrent',
145
+ 'notifyStartNewTrack',
146
+ 'updateMediaSessionMetadata',
147
+ 'preloadAhead',
148
+ ],
149
+ },
150
+ {
151
+ target: 'paused',
152
+ actions: [
153
+ 'deactivateCurrent',
154
+ 'cancelAllGapless',
155
+ 'gotoTrackIndex',
156
+ 'seekCurrentToZero',
157
+ 'preloadAhead',
158
+ ],
159
+ },
160
+ ],
161
+ TRACK_LOADED: {
162
+ actions: ['preloadAhead'],
163
+ },
164
+ },
165
+ },
166
+
167
+ // -----------------------------------------------------------------
168
+ // playing
169
+ // -----------------------------------------------------------------
170
+ playing: {
171
+ on: {
172
+ PAUSE: {
173
+ target: 'paused',
174
+ actions: ['cancelScheduledGapless', 'pauseCurrent'],
175
+ },
176
+ TOGGLE: {
177
+ target: 'paused',
178
+ actions: ['cancelScheduledGapless', 'pauseCurrent'],
179
+ },
180
+ NEXT: {
181
+ actions: [
182
+ 'deactivateCurrent',
183
+ 'cancelAllGapless',
184
+ 'advanceToNextTrack',
185
+ 'activateAndPlayCurrent',
186
+ 'notifyStartNewTrack',
187
+ 'notifyPlayNextTrack',
188
+ 'updateMediaSessionMetadata',
189
+ 'preloadAhead',
190
+ ],
191
+ },
192
+ PREVIOUS: {
193
+ actions: [
194
+ 'deactivateCurrent',
195
+ 'cancelAllGapless',
196
+ 'goToPreviousTrack',
197
+ 'activateAndPlayCurrent',
198
+ 'notifyStartNewTrack',
199
+ 'notifyPlayPreviousTrack',
200
+ 'updateMediaSessionMetadata',
201
+ 'preloadAhead',
202
+ ],
203
+ },
204
+ GOTO: [
205
+ {
206
+ guard: 'playImmediately',
207
+ actions: [
208
+ 'deactivateCurrent',
209
+ 'cancelAllGapless',
210
+ 'gotoTrackIndex',
211
+ 'activateAndPlayCurrent',
212
+ 'notifyStartNewTrack',
213
+ 'updateMediaSessionMetadata',
214
+ 'preloadAhead',
215
+ ],
216
+ },
217
+ {
218
+ actions: [
219
+ 'deactivateCurrent',
220
+ 'cancelAllGapless',
221
+ 'gotoTrackIndex',
222
+ 'seekCurrentToZero',
223
+ 'preloadAhead',
224
+ ],
225
+ },
226
+ ],
227
+ SEEK: {
228
+ actions: ['seekCurrent', 'cancelAndRescheduleGapless'],
229
+ },
230
+ TRACK_ENDED: [
231
+ {
232
+ guard: 'hasNextTrack',
233
+ target: 'playing',
234
+ actions: [
235
+ 'deactivateEndedTrack',
236
+ 'advanceOnTrackEnd',
237
+ 'playOrContinueGapless',
238
+ 'notifyStartNewTrack',
239
+ 'notifyPlayNextTrack',
240
+ 'updateMediaSessionMetadata',
241
+ 'preloadAhead',
242
+ ],
243
+ },
244
+ {
245
+ target: 'ended',
246
+ actions: ['deactivateEndedTrack', 'notifyEnded'],
247
+ },
248
+ ],
249
+ TRACK_LOADED: {
250
+ actions: ['scheduleGapless', 'preloadAhead'],
251
+ },
252
+ },
253
+ },
254
+
255
+ // -----------------------------------------------------------------
256
+ // paused
257
+ // -----------------------------------------------------------------
258
+ paused: {
259
+ on: {
260
+ PLAY: {
261
+ target: 'playing',
262
+ actions: ['playCurrent', 'updateMediaSessionMetadata', 'preloadAhead', 'scheduleGapless'],
263
+ },
264
+ TOGGLE: {
265
+ target: 'playing',
266
+ actions: ['playCurrent', 'updateMediaSessionMetadata', 'preloadAhead', 'scheduleGapless'],
267
+ },
268
+ NEXT: {
269
+ actions: [
270
+ 'deactivateCurrent',
271
+ 'cancelAllGapless',
272
+ 'advanceToNextTrack',
273
+ 'activateAndPlayCurrent',
274
+ 'notifyStartNewTrack',
275
+ 'notifyPlayNextTrack',
276
+ 'updateMediaSessionMetadata',
277
+ 'preloadAhead',
278
+ ],
279
+ },
280
+ PREVIOUS: {
281
+ actions: [
282
+ 'deactivateCurrent',
283
+ 'cancelAllGapless',
284
+ 'goToPreviousTrack',
285
+ 'activateAndPlayCurrent',
286
+ 'notifyStartNewTrack',
287
+ 'notifyPlayPreviousTrack',
288
+ 'updateMediaSessionMetadata',
289
+ 'preloadAhead',
290
+ ],
291
+ },
292
+ GOTO: [
293
+ {
294
+ guard: 'playImmediately',
295
+ target: 'playing',
296
+ actions: [
297
+ 'deactivateCurrent',
298
+ 'cancelAllGapless',
299
+ 'gotoTrackIndex',
300
+ 'activateAndPlayCurrent',
301
+ 'notifyStartNewTrack',
302
+ 'updateMediaSessionMetadata',
303
+ 'preloadAhead',
304
+ ],
305
+ },
306
+ {
307
+ actions: [
308
+ 'deactivateCurrent',
309
+ 'cancelAllGapless',
310
+ 'gotoTrackIndex',
311
+ 'seekCurrentToZero',
312
+ 'preloadAhead',
313
+ ],
314
+ },
315
+ ],
316
+ SEEK: {
317
+ actions: ['seekCurrent'],
318
+ },
319
+ TRACK_ENDED: [
320
+ {
321
+ guard: 'hasNextTrack',
322
+ target: 'paused',
323
+ actions: [
324
+ 'deactivateEndedTrack',
325
+ 'advanceOnTrackEnd',
326
+ 'notifyStartNewTrack',
327
+ 'updateMediaSessionMetadata',
328
+ ],
329
+ },
330
+ {
331
+ target: 'ended',
332
+ actions: ['deactivateEndedTrack', 'notifyEnded'],
333
+ },
334
+ ],
335
+ TRACK_LOADED: {
336
+ actions: ['preloadAhead'],
337
+ },
338
+ },
339
+ },
340
+
341
+ // -----------------------------------------------------------------
342
+ // ended
343
+ // -----------------------------------------------------------------
344
+ ended: {
345
+ on: {
346
+ PLAY: {
347
+ target: 'playing',
348
+ actions: [
349
+ 'resetToFirstTrack',
350
+ 'playCurrent',
351
+ 'updateMediaSessionMetadata',
352
+ 'preloadAhead',
353
+ 'scheduleGapless',
354
+ ],
355
+ },
356
+ GOTO: [
357
+ {
358
+ guard: 'playImmediately',
359
+ target: 'playing',
360
+ actions: [
361
+ 'deactivateCurrent',
362
+ 'cancelAllGapless',
363
+ 'gotoTrackIndex',
364
+ 'activateAndPlayCurrent',
365
+ 'notifyStartNewTrack',
366
+ 'updateMediaSessionMetadata',
367
+ 'preloadAhead',
368
+ ],
369
+ },
370
+ {
371
+ target: 'paused',
372
+ actions: [
373
+ 'deactivateCurrent',
374
+ 'cancelAllGapless',
375
+ 'gotoTrackIndex',
376
+ 'seekCurrentToZero',
377
+ 'preloadAhead',
378
+ ],
379
+ },
380
+ ],
381
+ },
382
+ },
383
+ },
384
+ });
385
+ }
386
+
387
+ export type QueueMachine = ReturnType<typeof createQueueMachine>;