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.
package/src/gapless.js ADDED
@@ -0,0 +1,636 @@
1
+ (function (factory) {
2
+ // Establish the root object, `window` (`self`) in the browser, or `global` on the server.
3
+ // We use `self` instead of `window` for `WebWorker` support.
4
+ const root =
5
+ (typeof self == 'object' && self.self === self && self) ||
6
+ (typeof global == 'object' && global.global === global && global);
7
+
8
+ // Node.js, CommonJS, or ES6
9
+ if (typeof module === 'object' && typeof module.exports === 'object') {
10
+ module.exports = factory(root, exports);
11
+ // Finally, as a browser global.
12
+ } else {
13
+ root.Gapless = factory(root, {});
14
+ }
15
+ })((root, Gapless) => {
16
+ const PRELOAD_NUM_TRACKS = 2;
17
+
18
+ const isBrowser = typeof window !== 'undefined';
19
+ const audioContext =
20
+ isBrowser && (window.AudioContext || window.webkitAudioContext)
21
+ ? new (window.AudioContext || window.webkitAudioContext)()
22
+ : null;
23
+
24
+ const GaplessPlaybackType = {
25
+ HTML5: 'HTML5',
26
+ WEBAUDIO: 'WEBAUDIO',
27
+ };
28
+
29
+ const GaplessPlaybackLoadingState = {
30
+ NONE: 'NONE',
31
+ LOADING: 'LOADING',
32
+ LOADED: 'LOADED',
33
+ };
34
+
35
+ class Queue {
36
+ constructor(props = {}) {
37
+ const {
38
+ tracks = [],
39
+ onProgress,
40
+ onEnded,
41
+ onPlayNextTrack,
42
+ onPlayPreviousTrack,
43
+ onStartNewTrack,
44
+ webAudioIsDisabled = false,
45
+ onError,
46
+ onPlayBlocked,
47
+ } = props;
48
+
49
+ this.props = {
50
+ onProgress,
51
+ onEnded,
52
+ onPlayNextTrack,
53
+ onPlayPreviousTrack,
54
+ onStartNewTrack,
55
+ onError,
56
+ onPlayBlocked,
57
+ };
58
+
59
+ this.state = {
60
+ volume: 1,
61
+ currentTrackIdx: 0,
62
+ webAudioIsDisabled,
63
+ };
64
+
65
+ this.Track = Track;
66
+
67
+ this.tracks = tracks.map(
68
+ (trackUrl, idx) =>
69
+ new Track({
70
+ trackUrl,
71
+ idx,
72
+ queue: this,
73
+ })
74
+ );
75
+
76
+ // if the browser doesn't support web audio
77
+ // disable it!
78
+ if (!audioContext) {
79
+ this.disableWebAudio();
80
+ }
81
+ }
82
+
83
+ addTrack({ trackUrl, skipHEAD, metadata = {} }) {
84
+ this.tracks.push(
85
+ new Track({
86
+ trackUrl,
87
+ skipHEAD,
88
+ metadata,
89
+ idx: this.tracks.length,
90
+ queue: this,
91
+ })
92
+ );
93
+ }
94
+
95
+ removeTrack(track) {
96
+ const index = this.tracks.indexOf(track);
97
+ return this.tracks.splice(index, 1);
98
+ }
99
+
100
+ togglePlayPause() {
101
+ if (this.currentTrack) this.currentTrack.togglePlayPause();
102
+ }
103
+
104
+ play() {
105
+ if (this.currentTrack) this.currentTrack.play();
106
+ }
107
+
108
+ pause() {
109
+ if (this.currentTrack) this.currentTrack.pause();
110
+ }
111
+
112
+ playPrevious() {
113
+ if (this.currentTrack?.currentTime > 8) {
114
+ this.currentTrack.seek(0);
115
+ return;
116
+ }
117
+
118
+ this.resetCurrentTrack();
119
+
120
+ if (--this.state.currentTrackIdx < 0) this.state.currentTrackIdx = 0;
121
+
122
+ this.resetCurrentTrack();
123
+
124
+ this.play();
125
+
126
+ if (this.props.onStartNewTrack) this.props.onStartNewTrack(this.currentTrack);
127
+ if (this.props.onPlayPreviousTrack) this.props.onPlayPreviousTrack(this.currentTrack);
128
+ }
129
+
130
+ playNext() {
131
+ this.resetCurrentTrack();
132
+
133
+ this.state.currentTrackIdx++;
134
+
135
+ this.resetCurrentTrack();
136
+
137
+ this.play();
138
+
139
+ if (this.props.onStartNewTrack) this.props.onStartNewTrack(this.currentTrack);
140
+ if (this.props.onPlayNextTrack) this.props.onPlayNextTrack(this.currentTrack);
141
+ }
142
+
143
+ resetCurrentTrack() {
144
+ if (this.currentTrack) {
145
+ this.currentTrack.seek(0);
146
+ this.currentTrack.pause();
147
+ }
148
+ }
149
+
150
+ pauseAll() {
151
+ Object.values(this.tracks).map((track) => {
152
+ track.pause();
153
+ });
154
+ }
155
+
156
+ cleanUp() {
157
+ Object.values(this.tracks).map((track) => {
158
+ if (player.bufferSourceNode && player.bufferSourceNode.buffer && player.audioBuffer) {
159
+ player.bufferSourceNode.buffer = player.audioBuffer = null;
160
+ }
161
+ });
162
+ }
163
+
164
+ gotoTrack(idx, playImmediately = false) {
165
+ this.pauseAll();
166
+ this.state.currentTrackIdx = idx;
167
+
168
+ this.resetCurrentTrack();
169
+
170
+ if (playImmediately) {
171
+ this.play();
172
+ if (this.props.onStartNewTrack) this.props.onStartNewTrack(this.currentTrack);
173
+ }
174
+ }
175
+
176
+ loadTrack(idx, loadHTML5) {
177
+ // only preload if song is within the next 2
178
+ if (this.state.currentTrackIdx + PRELOAD_NUM_TRACKS <= idx) return;
179
+ const track = this.tracks[idx];
180
+
181
+ if (track) track.preload(loadHTML5);
182
+ }
183
+
184
+ setProps(obj = {}) {
185
+ this.props = Object.assign(this.props, obj);
186
+ }
187
+
188
+ onEnded() {
189
+ if (this.props.onEnded) this.props.onEnded();
190
+ }
191
+
192
+ onProgress(track) {
193
+ if (this.props.onProgress) this.props.onProgress(track);
194
+ }
195
+
196
+ get currentTrack() {
197
+ return this.tracks[this.state.currentTrackIdx];
198
+ }
199
+
200
+ get nextTrack() {
201
+ return this.tracks[this.state.currentTrackIdx + 1];
202
+ }
203
+
204
+ disableWebAudio() {
205
+ this.state.webAudioIsDisabled = true;
206
+ }
207
+
208
+ setVolume(nextVolume) {
209
+ if (nextVolume < 0) nextVolume = 0;
210
+ else if (nextVolume > 1) nextVolume = 1;
211
+
212
+ this.state.volume = nextVolume;
213
+
214
+ this.tracks.map((track) => track.setVolume(nextVolume));
215
+ }
216
+
217
+ onError() {
218
+ if (this.props.onError) this.props.onError();
219
+ }
220
+
221
+ onPlayBlocked() {
222
+ if (this.props.onPlayBlocked) this.props.onPlayBlocked();
223
+ }
224
+
225
+ resumeAudioContext() {
226
+ if (audioContext && audioContext.state === 'suspended') {
227
+ return audioContext.resume();
228
+ }
229
+ return Promise.resolve();
230
+ }
231
+ }
232
+
233
+ class Track {
234
+ constructor({ trackUrl, skipHEAD, queue, idx, metadata }) {
235
+ // playback type state
236
+ this.playbackType = GaplessPlaybackType.HTML5;
237
+ this.webAudioLoadingState = GaplessPlaybackLoadingState.NONE;
238
+ this.loadedHEAD = false;
239
+
240
+ // basic inputs from Queue
241
+ this.idx = idx;
242
+ this.queue = queue;
243
+ this.trackUrl = trackUrl;
244
+ this.skipHEAD = skipHEAD;
245
+ this.metadata = metadata;
246
+
247
+ this.onEnded = this.onEnded.bind(this);
248
+ this.onProgress = this.onProgress.bind(this);
249
+
250
+ // HTML5 Audio
251
+ this.audio = new Audio();
252
+ this.audio.onerror = this.audioOnError;
253
+ this.audio.onended = () => this.onEnded('HTML5');
254
+ this.audio.controls = false;
255
+ this.audio.volume = queue.state.volume;
256
+ this.audio.preload = 'none';
257
+ this.audio.src = trackUrl;
258
+ // this.audio.onprogress = () => this.debug(this.idx, this.audio.buffered)
259
+
260
+ if (queue.state.webAudioIsDisabled) return;
261
+
262
+ // WebAudio
263
+ this.audioContext = audioContext;
264
+ this.gainNode = this.audioContext ? this.audioContext.createGain() : null;
265
+ this.gainNode.gain.value = queue.state.volume;
266
+ this.webAudioStartedPlayingAt = 0;
267
+ this.webAudioPausedDuration = 0;
268
+ this.webAudioPausedAt = 0;
269
+ this.audioBuffer = null;
270
+
271
+ this.bufferSourceNode = this.audioContext ? this.audioContext.createBufferSource() : null;
272
+ this.bufferSourceNode.onended = this.onEnded;
273
+ }
274
+
275
+ // private functions
276
+ loadHEAD(cb) {
277
+ if (this.loadedHEAD) return cb();
278
+
279
+ const options = {
280
+ method: 'HEAD',
281
+ };
282
+
283
+ fetch(this.trackUrl, options).then((res) => {
284
+ if (res.redirected) {
285
+ this.trackUrl = res.url;
286
+ }
287
+
288
+ this.loadedHEAD = true;
289
+
290
+ cb();
291
+ });
292
+ }
293
+
294
+ loadBuffer(cb) {
295
+ if (this.webAudioLoadingState !== GaplessPlaybackLoadingState.NONE) return;
296
+
297
+ this.webAudioLoadingState = GaplessPlaybackLoadingState.LOADING;
298
+
299
+ fetch(this.trackUrl)
300
+ .then((res) => res.arrayBuffer())
301
+ .then((res) =>
302
+ this.audioContext.decodeAudioData(
303
+ res,
304
+ (buffer) => {
305
+ this.debug('finished downloading track');
306
+
307
+ this.webAudioLoadingState = GaplessPlaybackLoadingState.LOADED;
308
+
309
+ this.bufferSourceNode.buffer = this.audioBuffer = buffer;
310
+ this.bufferSourceNode.connect(this.gainNode);
311
+
312
+ // try to preload next track
313
+ this.queue.loadTrack(this.idx + 1);
314
+
315
+ // if we loaded the active track, switch to web audio
316
+ if (this.isActiveTrack) this.switchToWebAudio();
317
+ // if its not.. then just turn on web audio
318
+ else this.playbackType = GaplessPlaybackType.WEBAUDIO;
319
+
320
+ cb && cb(buffer);
321
+ },
322
+ (err) => {
323
+ console.error('error decoding audio data', err);
324
+ }
325
+ )
326
+ )
327
+ .catch((e) => this.debug('caught fetch error', e));
328
+ }
329
+
330
+ switchToWebAudio(forcePause) {
331
+ // if we've switched tracks, don't switch to web audio
332
+ if (!this.isActiveTrack && !forcePause) return;
333
+
334
+ this.debug(
335
+ 'switch to web audio',
336
+ this.currentTime,
337
+ this.isPaused,
338
+ this.audio.duration,
339
+ this.audioBuffer.duration,
340
+ this.audio.duration - this.audioBuffer.duration
341
+ );
342
+
343
+ if (this.currentTime !== 0 && isNaN(this.audio.duration)) {
344
+ this.debug('For some reason this.audio.duration === NaN. Weird.', this.audio);
345
+
346
+ return;
347
+ }
348
+
349
+ // if currentTime === 0, this is a new track, so play it
350
+ // otherwise we're hitting this mid-track which may
351
+ // happen in the middle of a paused track
352
+ if (forcePause) {
353
+ this.bufferSourceNode.playbackRate.value = 0;
354
+ this.pause();
355
+ } else {
356
+ this.bufferSourceNode.playbackRate.value = this.currentTime !== 0 && this.isPaused ? 0 : 1;
357
+ }
358
+
359
+ this.connectGainNode();
360
+
361
+ this.webAudioStartedPlayingAt = this.audioContext.currentTime - this.currentTime;
362
+
363
+ // slight blip, could be improved
364
+ this.bufferSourceNode.start(0, this.currentTime);
365
+
366
+ if (this.isPaused) {
367
+ this.webAudioPausedAt = this.audioContext.currentTime;
368
+ this.bufferSourceNode.playbackRate.value = 0;
369
+ this.gainNode.disconnect(this.audioContext.destination);
370
+ this.bufferSourceNode.onended = null;
371
+ }
372
+
373
+ this.audio.pause();
374
+
375
+ this.playbackType = GaplessPlaybackType.WEBAUDIO;
376
+ }
377
+
378
+ // public-ish functions
379
+ pause() {
380
+ this.debug('pause');
381
+ if (this.isUsingWebAudio) {
382
+ if (this.bufferSourceNode.playbackRate.value === 0) return;
383
+ this.webAudioPausedAt = this.audioContext.currentTime;
384
+ this.bufferSourceNode.playbackRate.value = 0;
385
+ try {
386
+ this.gainNode.disconnect(this.audioContext.destination);
387
+ } catch (err) {
388
+ console.error(err);
389
+ }
390
+ this.bufferSourceNode.onended = null;
391
+ } else {
392
+ this.audio.pause();
393
+ }
394
+ }
395
+
396
+ play() {
397
+ this.debug('play');
398
+ if (this.audioBuffer) {
399
+ // if we've already set up the buffer just set playbackRate to 1
400
+ if (this.isUsingWebAudio) {
401
+ if (this.bufferSourceNode.playbackRate.value === 1) return;
402
+
403
+ if (this.webAudioPausedAt) {
404
+ this.webAudioPausedDuration += this.audioContext.currentTime - this.webAudioPausedAt;
405
+ }
406
+
407
+ // use seek to avoid bug where track wouldn't play properly
408
+ // if paused for longer than length of track
409
+ // TODO: fix bug -- must be related to bufferSourceNode
410
+ if (this.currentTime !== 0) {
411
+ this.seek(this.currentTime);
412
+ }
413
+ // was paused, now force play
414
+ this.connectGainNode();
415
+ this.bufferSourceNode.playbackRate.value = 1;
416
+ if (!this.bufferSourceNode.onended) {
417
+ this.bufferSourceNode.onended = () => this.onEnded('webaudio3');
418
+ }
419
+
420
+ this.webAudioPausedAt = 0;
421
+ }
422
+ // otherwise set the bufferSourceNode buffer and switch to WebAudio
423
+ else {
424
+ this.switchToWebAudio();
425
+ }
426
+
427
+ // Try to preload the next track
428
+ this.queue.loadTrack(this.idx + 1);
429
+ } else {
430
+ this.audio.preload = 'auto';
431
+ const playPromise = this.audio.play();
432
+ if (playPromise && typeof playPromise.catch === 'function') {
433
+ playPromise.catch((err) => {
434
+ if (err.name === 'NotAllowedError') {
435
+ this.queue.onPlayBlocked();
436
+ }
437
+ });
438
+ }
439
+ if (!this.queue.state.webAudioIsDisabled) {
440
+ if (this.skipHEAD) {
441
+ this.loadBuffer();
442
+ } else {
443
+ this.loadHEAD(() => this.loadBuffer());
444
+ }
445
+ }
446
+ }
447
+
448
+ this.onProgress();
449
+ }
450
+
451
+ togglePlayPause() {
452
+ this.isPaused ? this.play() : this.pause();
453
+ }
454
+
455
+ preload(HTML5) {
456
+ if (HTML5 && this.audio.preload !== 'auto') {
457
+ this.debug('preload', HTML5);
458
+ this.audio.preload = 'auto';
459
+ } else if (!this.audioBuffer && !this.queue.state.webAudioIsDisabled) {
460
+ this.debug('preload', HTML5);
461
+
462
+ if (this.skipHEAD) {
463
+ this.loadBuffer();
464
+ } else {
465
+ this.loadHEAD(() => this.loadBuffer());
466
+ }
467
+ }
468
+ }
469
+
470
+ // TODO: add checks for to > duration or null or negative (duration - to)
471
+ seek(to = 0) {
472
+ this.debug('seek', to);
473
+ if (this.isUsingWebAudio) {
474
+ this.seekBufferSourceNode(to);
475
+ } else {
476
+ this.audio.currentTime = to;
477
+ }
478
+
479
+ this.onProgress();
480
+ }
481
+
482
+ seekBufferSourceNode(to) {
483
+ const wasPaused = this.isPaused;
484
+ this.bufferSourceNode.onended = null;
485
+ try {
486
+ this.bufferSourceNode.stop();
487
+ } catch (e) {
488
+ console.error(e);
489
+ }
490
+
491
+ this.bufferSourceNode = this.audioContext.createBufferSource();
492
+
493
+ this.bufferSourceNode.buffer = this.audioBuffer;
494
+ this.bufferSourceNode.connect(this.gainNode);
495
+ this.bufferSourceNode.onended = () => this.onEnded('webaudio2');
496
+
497
+ this.webAudioStartedPlayingAt = this.audioContext.currentTime - to;
498
+ this.webAudioPausedDuration = 0;
499
+
500
+ this.bufferSourceNode.start(0, to);
501
+ if (wasPaused) {
502
+ this.connectGainNode();
503
+ this.pause();
504
+ }
505
+ }
506
+
507
+ connectGainNode() {
508
+ this.gainNode.connect(this.audioContext.destination);
509
+ }
510
+
511
+ // basic event handlers
512
+ audioOnError = (e) => {
513
+ this.debug('audioOnError', e);
514
+ this.queue.onError();
515
+ };
516
+
517
+ onEnded(from) {
518
+ this.debug('onEnded', from, this.isActiveTrack, this);
519
+
520
+ // debug: try clearing the onended callback
521
+ if (this.bufferSourceNode && this.bufferSourceNode.onended) {
522
+ this.bufferSourceNode.onended = null;
523
+ }
524
+
525
+ this.queue.playNext();
526
+ this.queue.onEnded();
527
+ }
528
+
529
+ onProgress() {
530
+ if (!this.isActiveTrack) return;
531
+
532
+ const isWithinLastTwentyFiveSeconds = this.duration - this.currentTime <= 25;
533
+ const nextTrack = this.queue.nextTrack;
534
+
535
+ // if in last 25 seconds and next track hasn't loaded yet
536
+ // start loading next track's HTML5
537
+ if (isWithinLastTwentyFiveSeconds && nextTrack && !nextTrack.isLoaded) {
538
+ this.queue.loadTrack(this.idx + 1, true);
539
+ }
540
+
541
+ this.queue.onProgress(this);
542
+
543
+ // if we're paused, we still want to send one final onProgress call
544
+ // and then bow out, hence this being at the end of the function
545
+ if (this.isPaused) return;
546
+
547
+ // this.debug(this.currentTime, this.duration);
548
+ window.requestAnimationFrame(this.onProgress);
549
+ // setTimeout(this.onProgress, 33.33); // 30fps
550
+ }
551
+
552
+ setVolume(nextVolume) {
553
+ this.audio.volume = nextVolume;
554
+ if (this.gainNode) this.gainNode.gain.value = nextVolume;
555
+ }
556
+
557
+ // getter helpers
558
+ get isUsingWebAudio() {
559
+ return this.playbackType === GaplessPlaybackType.WEBAUDIO;
560
+ }
561
+
562
+ get isPaused() {
563
+ if (this.isUsingWebAudio) {
564
+ return this.bufferSourceNode.playbackRate.value === 0;
565
+ } else {
566
+ return this.audio.paused;
567
+ }
568
+ }
569
+
570
+ get currentTime() {
571
+ if (this.isUsingWebAudio) {
572
+ return (
573
+ this.audioContext.currentTime -
574
+ this.webAudioStartedPlayingAt -
575
+ this.webAudioPausedDuration
576
+ );
577
+ } else {
578
+ return this.audio.currentTime;
579
+ }
580
+ }
581
+
582
+ get duration() {
583
+ if (this.isUsingWebAudio) {
584
+ return this.audioBuffer.duration;
585
+ } else {
586
+ return this.audio.duration;
587
+ }
588
+ }
589
+
590
+ get isActiveTrack() {
591
+ return this.queue.currentTrack === this;
592
+ }
593
+
594
+ get isLoaded() {
595
+ return this.webAudioLoadingState === GaplessPlaybackLoadingState.LOADED;
596
+ }
597
+
598
+ get state() {
599
+ return {
600
+ playbackType: this.playbackType,
601
+ webAudioLoadingState: this.webAudioLoadingState,
602
+ };
603
+ }
604
+
605
+ get completeState() {
606
+ return {
607
+ playbackType: this.playbackType,
608
+ webAudioLoadingState: this.webAudioLoadingState,
609
+ isPaused: this.isPaused,
610
+ currentTime: this.currentTime,
611
+ duration: this.duration,
612
+ idx: this.idx,
613
+ id: this.metadata.trackId,
614
+ };
615
+ }
616
+
617
+ // debug helper
618
+ debug(first, ...args) {
619
+ console.debug(new Date(), `${this.idx}:${first}`, ...args, this.state, this);
620
+ }
621
+
622
+ // just a helper to quick jump to the end of a track for testing
623
+ seekToEnd() {
624
+ if (this.isUsingWebAudio) {
625
+ this.seekBufferSourceNode(this.audioBuffer.duration - 6);
626
+ } else {
627
+ this.audio.currentTime = this.audio.duration - 6;
628
+ }
629
+ }
630
+ }
631
+
632
+ Gapless.Queue = Queue;
633
+ Gapless.Track = Track;
634
+
635
+ return Gapless;
636
+ });
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ // ---------------------------------------------------------------------------
2
+ // gapless.js — public API surface
3
+ // ---------------------------------------------------------------------------
4
+
5
+ export { Queue } from './Queue';
6
+ export type {
7
+ GaplessOptions,
8
+ AddTrackOptions,
9
+ TrackInfo,
10
+ TrackMetadata,
11
+ PlaybackType,
12
+ WebAudioLoadingState,
13
+ } from './types';
14
+
15
+ // Default export for convenience: import Gapless from 'gapless.js'
16
+ export { Queue as default } from './Queue';