@arraypress/waveform-player 1.7.2 → 1.8.0
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 +176 -377
- package/dist/waveform-player.cjs +2207 -0
- package/dist/waveform-player.cjs.map +7 -0
- package/dist/waveform-player.css +1 -1
- package/dist/waveform-player.esm.js +8 -8
- package/dist/waveform-player.esm.js.map +7 -0
- package/dist/waveform-player.js +552 -275
- package/dist/waveform-player.min.js +8 -8
- package/dist/waveform-player.min.js.map +7 -0
- package/index.d.ts +344 -0
- package/package.json +18 -8
- package/src/css/waveform-player.css +21 -3
- package/src/js/audio.js +61 -25
- package/src/js/bpm.js +26 -5
- package/src/js/core.js +409 -185
- package/src/js/drawing.js +208 -44
- package/src/js/index.js +56 -11
- package/src/js/themes.js +88 -47
- package/src/js/utils.js +231 -65
package/src/js/core.js
CHANGED
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
generateId,
|
|
12
12
|
parseDataAttributes,
|
|
13
13
|
mergeOptions,
|
|
14
|
-
debounce
|
|
14
|
+
debounce,
|
|
15
|
+
clamp,
|
|
16
|
+
escapeHtml
|
|
15
17
|
} from './utils.js';
|
|
16
18
|
|
|
17
19
|
import {DEFAULT_OPTIONS, STYLE_DEFAULTS, getColorPreset} from './themes.js';
|
|
@@ -32,9 +34,21 @@ export class WaveformPlayer {
|
|
|
32
34
|
static currentlyPlaying = null;
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
|
-
* Create a new WaveformPlayer instance
|
|
36
|
-
*
|
|
37
|
-
*
|
|
37
|
+
* Create a new WaveformPlayer instance.
|
|
38
|
+
*
|
|
39
|
+
* Resolves the container, merges options (defaults < `data-*` attributes <
|
|
40
|
+
* constructor options), applies the colour preset and style-specific
|
|
41
|
+
* defaults, registers the instance in the static map, and kicks off
|
|
42
|
+
* {@link WaveformPlayer#init}. A `waveformplayer:ready` event is dispatched
|
|
43
|
+
* ~100ms later, once initialization has settled.
|
|
44
|
+
*
|
|
45
|
+
* @param {string|HTMLElement} container - Container element, or a CSS
|
|
46
|
+
* selector resolved with `document.querySelector`.
|
|
47
|
+
* @param {Object} [options={}] - Player options. Accepts the shorthand
|
|
48
|
+
* aliases `style` (→ `waveformStyle`) and `src` (→ `url`); the canonical
|
|
49
|
+
* names win if both are supplied.
|
|
50
|
+
* @throws {Error} If the container element cannot be found.
|
|
51
|
+
* @fires WaveformPlayer#waveformplayer:ready
|
|
38
52
|
*/
|
|
39
53
|
constructor(container, options = {}) {
|
|
40
54
|
// Resolve container
|
|
@@ -43,14 +57,20 @@ export class WaveformPlayer {
|
|
|
43
57
|
: container;
|
|
44
58
|
|
|
45
59
|
if (!this.container) {
|
|
46
|
-
throw new Error('WaveformPlayer
|
|
60
|
+
throw new Error('[WaveformPlayer] Container element not found');
|
|
47
61
|
}
|
|
48
62
|
|
|
49
63
|
// Parse data attributes if present
|
|
50
64
|
const dataOptions = parseDataAttributes(this.container);
|
|
51
65
|
|
|
66
|
+
// Shorthand option aliases — `style` -> `waveformStyle`, `src` -> `url`.
|
|
67
|
+
// The canonical names still work and win if both are supplied.
|
|
68
|
+
const userOptions = { ...options };
|
|
69
|
+
if (userOptions.style && !userOptions.waveformStyle) userOptions.waveformStyle = userOptions.style;
|
|
70
|
+
if (userOptions.src && !userOptions.url) userOptions.url = userOptions.src;
|
|
71
|
+
|
|
52
72
|
// Merge options: defaults < data attributes < constructor options
|
|
53
|
-
this.options = mergeOptions(DEFAULT_OPTIONS, dataOptions,
|
|
73
|
+
this.options = mergeOptions(DEFAULT_OPTIONS, dataOptions, userOptions);
|
|
54
74
|
|
|
55
75
|
// Apply color preset (auto-detect if not specified)
|
|
56
76
|
const preset = getColorPreset(this.options.colorPreset);
|
|
@@ -85,6 +105,11 @@ export class WaveformPlayer {
|
|
|
85
105
|
this.updateTimer = null;
|
|
86
106
|
this.resizeObserver = null;
|
|
87
107
|
|
|
108
|
+
// All DOM/document listeners are registered with this signal so a
|
|
109
|
+
// single abort() in destroy() tears every one of them down (the old
|
|
110
|
+
// destroy left the document-click and container listeners attached).
|
|
111
|
+
this._ac = new AbortController();
|
|
112
|
+
|
|
88
113
|
// Generate unique ID
|
|
89
114
|
this.id = this.container.id || generateId(this.options.url);
|
|
90
115
|
|
|
@@ -96,19 +121,53 @@ export class WaveformPlayer {
|
|
|
96
121
|
|
|
97
122
|
// Dispatch ready event after initialization
|
|
98
123
|
setTimeout(() => {
|
|
99
|
-
this.
|
|
100
|
-
bubbles: true,
|
|
101
|
-
detail: {player: this, url: this.options.url}
|
|
102
|
-
}));
|
|
124
|
+
this._emit('waveformplayer:ready', {player: this, url: this.options.url});
|
|
103
125
|
}, 100);
|
|
104
126
|
}
|
|
105
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Build and dispatch a bubbling `waveformplayer:*` CustomEvent on the
|
|
130
|
+
* container, returning the event so cancelable (request-*) events can have
|
|
131
|
+
* their `defaultPrevented` checked. Single source of truth for the event
|
|
132
|
+
* shape — every player event bubbles and carries the supplied detail.
|
|
133
|
+
* @param {string} type - Full event type, e.g. `'waveformplayer:play'`.
|
|
134
|
+
* @param {Object} detail - Event detail payload.
|
|
135
|
+
* @param {boolean} [cancelable=false] - Whether the event is cancelable.
|
|
136
|
+
* @returns {CustomEvent} The dispatched event.
|
|
137
|
+
* @private
|
|
138
|
+
*/
|
|
139
|
+
_emit(type, detail, cancelable = false) {
|
|
140
|
+
const event = new CustomEvent(type, { bubbles: true, cancelable, detail });
|
|
141
|
+
this.container.dispatchEvent(event);
|
|
142
|
+
return event;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* External-mode seek request: dispatch a cancelable
|
|
147
|
+
* `waveformplayer:request-seek` and, unless the controller calls
|
|
148
|
+
* `preventDefault()`, optimistically advance the local progress overlay so
|
|
149
|
+
* the canvas repaints at once. Shared by the keyboard slider and canvas click.
|
|
150
|
+
* @param {number} percent - Target position as a 0..1 fraction.
|
|
151
|
+
* @private
|
|
152
|
+
* @fires WaveformPlayer#waveformplayer:request-seek
|
|
153
|
+
*/
|
|
154
|
+
_requestSeek(percent) {
|
|
155
|
+
const evt = this._emit('waveformplayer:request-seek', { ...this._buildTrackDetail(), percent }, true);
|
|
156
|
+
if (!evt.defaultPrevented) {
|
|
157
|
+
this.progress = percent;
|
|
158
|
+
this.drawWaveform?.();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
106
162
|
// ============================================
|
|
107
163
|
// Initialization
|
|
108
164
|
// ============================================
|
|
109
165
|
|
|
110
166
|
/**
|
|
111
|
-
* Initialize the player
|
|
167
|
+
* Initialize the player: build the DOM, create the audio element (self
|
|
168
|
+
* mode only), wire up the feature controls (speed, keyboard, accessible
|
|
169
|
+
* seek), bind events, attach the resize observer, then size the canvas and
|
|
170
|
+
* — if a `url` option was given — load it and optionally autoplay.
|
|
112
171
|
* @private
|
|
113
172
|
*/
|
|
114
173
|
init() {
|
|
@@ -128,17 +187,24 @@ export class WaveformPlayer {
|
|
|
128
187
|
if (this.options.url) {
|
|
129
188
|
this.load(this.options.url).then(() => {
|
|
130
189
|
if (this.options.autoplay) {
|
|
131
|
-
this.play();
|
|
190
|
+
this.play()?.catch(() => {});
|
|
132
191
|
}
|
|
133
192
|
}).catch(error => {
|
|
134
|
-
console.error('Failed to load audio:', error);
|
|
193
|
+
console.error('[WaveformPlayer] Failed to load audio:', error);
|
|
135
194
|
});
|
|
136
195
|
}
|
|
137
196
|
});
|
|
138
197
|
}
|
|
139
198
|
|
|
140
199
|
/**
|
|
141
|
-
*
|
|
200
|
+
* Build the player's DOM tree inside the container and cache element
|
|
201
|
+
* references.
|
|
202
|
+
*
|
|
203
|
+
* Clears the container, resolves button alignment (`auto` → `bottom` for
|
|
204
|
+
* the `bars` style, `center` otherwise), and conditionally renders the play
|
|
205
|
+
* button, info row (artwork/title/subtitle), BPM badge, playback-speed
|
|
206
|
+
* menu, and time display based on the relevant `show*` options. Caches the
|
|
207
|
+
* canvas, controls, and text elements onto `this`, then sizes the canvas.
|
|
142
208
|
* @private
|
|
143
209
|
*/
|
|
144
210
|
createDOM() {
|
|
@@ -223,8 +289,8 @@ export class WaveformPlayer {
|
|
|
223
289
|
<canvas></canvas>
|
|
224
290
|
<div class="waveform-markers"></div>
|
|
225
291
|
<div class="waveform-loading" style="display:none;"></div>
|
|
226
|
-
<div class="waveform-error" style="display:none;">
|
|
227
|
-
<span class="waveform-error-text"
|
|
292
|
+
<div class="waveform-error" style="display:none;" role="alert">
|
|
293
|
+
<span class="waveform-error-text">${escapeHtml(this.options.errorText)}</span>
|
|
228
294
|
</div>
|
|
229
295
|
</div>
|
|
230
296
|
</div>
|
|
@@ -280,7 +346,9 @@ export class WaveformPlayer {
|
|
|
280
346
|
// ============================================
|
|
281
347
|
|
|
282
348
|
/**
|
|
283
|
-
*
|
|
349
|
+
* Apply the configured initial playback rate to the audio element (self
|
|
350
|
+
* mode only) and, when `showPlaybackSpeed` is enabled, wire up the speed
|
|
351
|
+
* menu UI via {@link WaveformPlayer#initSpeedControls}.
|
|
284
352
|
* @private
|
|
285
353
|
*/
|
|
286
354
|
initPlaybackSpeed() {
|
|
@@ -300,7 +368,11 @@ export class WaveformPlayer {
|
|
|
300
368
|
}
|
|
301
369
|
|
|
302
370
|
/**
|
|
303
|
-
*
|
|
371
|
+
* Wire up the playback-speed menu: toggle it open on the speed button,
|
|
372
|
+
* close it on any outside click, and apply the chosen rate when a
|
|
373
|
+
* `.speed-option` is clicked. All listeners are registered against the
|
|
374
|
+
* instance `AbortController` signal so {@link WaveformPlayer#destroy} tears
|
|
375
|
+
* them down. No-op if the speed elements are absent.
|
|
304
376
|
* @private
|
|
305
377
|
*/
|
|
306
378
|
initSpeedControls() {
|
|
@@ -313,12 +385,12 @@ export class WaveformPlayer {
|
|
|
313
385
|
speedBtn.addEventListener('click', (e) => {
|
|
314
386
|
e.stopPropagation();
|
|
315
387
|
speedMenu.style.display = speedMenu.style.display === 'none' ? 'block' : 'none';
|
|
316
|
-
});
|
|
388
|
+
}, {signal: this._ac.signal});
|
|
317
389
|
|
|
318
390
|
// Close menu when clicking outside
|
|
319
391
|
document.addEventListener('click', () => {
|
|
320
392
|
speedMenu.style.display = 'none';
|
|
321
|
-
});
|
|
393
|
+
}, {signal: this._ac.signal});
|
|
322
394
|
|
|
323
395
|
// Handle speed selection
|
|
324
396
|
speedMenu.addEventListener('click', (e) => {
|
|
@@ -328,14 +400,21 @@ export class WaveformPlayer {
|
|
|
328
400
|
this.setPlaybackRate(rate);
|
|
329
401
|
speedMenu.style.display = 'none';
|
|
330
402
|
}
|
|
331
|
-
});
|
|
403
|
+
}, {signal: this._ac.signal});
|
|
332
404
|
|
|
333
405
|
// Set initial UI state
|
|
334
406
|
this.updateSpeedUI();
|
|
335
407
|
}
|
|
336
408
|
|
|
337
409
|
/**
|
|
338
|
-
*
|
|
410
|
+
* Enable keyboard transport controls on the container.
|
|
411
|
+
*
|
|
412
|
+
* The container is focusable only after it is clicked (it carries
|
|
413
|
+
* `tabindex="-1"` until then, and clicking steals focus from sibling
|
|
414
|
+
* players). While focused it handles: digits 0-9 (seek to that tenth of
|
|
415
|
+
* the track), Space (toggle play), and — in self mode only, since
|
|
416
|
+
* `this.audio` is null in external mode — arrow keys (seek ±5s, volume
|
|
417
|
+
* ±0.1) and `m`/`M` (mute). Listeners use the instance abort signal.
|
|
339
418
|
* @private
|
|
340
419
|
*/
|
|
341
420
|
initKeyboardControls() {
|
|
@@ -353,7 +432,7 @@ export class WaveformPlayer {
|
|
|
353
432
|
// Make this one focusable
|
|
354
433
|
this.container.setAttribute('tabindex', '0');
|
|
355
434
|
this.container.focus();
|
|
356
|
-
});
|
|
435
|
+
}, {signal: this._ac.signal});
|
|
357
436
|
|
|
358
437
|
// Keyboard events. In external mode `this.audio` is null, so
|
|
359
438
|
// seek/volume/mute keys are no-ops (the external controller
|
|
@@ -380,10 +459,10 @@ export class WaveformPlayer {
|
|
|
380
459
|
' ': () => this.togglePlay(),
|
|
381
460
|
};
|
|
382
461
|
if (hasAudio) {
|
|
383
|
-
actions['ArrowLeft'] = () => this.seekTo(
|
|
384
|
-
actions['ArrowRight'] = () => this.seekTo(
|
|
385
|
-
actions['ArrowUp'] = () => this.setVolume(
|
|
386
|
-
actions['ArrowDown'] = () => this.setVolume(
|
|
462
|
+
actions['ArrowLeft'] = () => this.seekTo(clamp(currentTime - 5, 0, this.audio.duration));
|
|
463
|
+
actions['ArrowRight'] = () => this.seekTo(clamp(currentTime + 5, 0, this.audio.duration));
|
|
464
|
+
actions['ArrowUp'] = () => this.setVolume(clamp(this.audio.volume + 0.1));
|
|
465
|
+
actions['ArrowDown'] = () => this.setVolume(clamp(this.audio.volume - 0.1));
|
|
387
466
|
actions['m'] = actions['M'] = () => this.audio.muted = !this.audio.muted;
|
|
388
467
|
}
|
|
389
468
|
|
|
@@ -391,7 +470,7 @@ export class WaveformPlayer {
|
|
|
391
470
|
e.preventDefault();
|
|
392
471
|
actions[key]();
|
|
393
472
|
}
|
|
394
|
-
});
|
|
473
|
+
}, {signal: this._ac.signal});
|
|
395
474
|
}
|
|
396
475
|
|
|
397
476
|
/**
|
|
@@ -452,7 +531,7 @@ export class WaveformPlayer {
|
|
|
452
531
|
e.preventDefault();
|
|
453
532
|
e.stopPropagation();
|
|
454
533
|
this.seekToSeconds(target);
|
|
455
|
-
});
|
|
534
|
+
}, {signal: this._ac.signal});
|
|
456
535
|
}
|
|
457
536
|
|
|
458
537
|
/**
|
|
@@ -485,28 +564,24 @@ export class WaveformPlayer {
|
|
|
485
564
|
|
|
486
565
|
/**
|
|
487
566
|
* Seek the slider to an absolute time, clamped to the track length.
|
|
488
|
-
*
|
|
567
|
+
*
|
|
568
|
+
* In self mode this defers to {@link WaveformPlayer#seekTo}. In external
|
|
569
|
+
* mode it dispatches a cancelable `waveformplayer:request-seek` event with
|
|
570
|
+
* the target percentage; if the controller doesn't `preventDefault()`, the
|
|
571
|
+
* local progress/visual is updated optimistically. Either way the ARIA
|
|
572
|
+
* slider values are refreshed.
|
|
489
573
|
* @param {number} seconds - Target time in seconds.
|
|
490
574
|
* @private
|
|
575
|
+
* @fires WaveformPlayer#waveformplayer:request-seek
|
|
491
576
|
*/
|
|
492
577
|
seekToSeconds(seconds) {
|
|
493
578
|
const duration = this.getSeekDuration();
|
|
494
579
|
if (!duration) return;
|
|
495
580
|
|
|
496
|
-
const clamped =
|
|
581
|
+
const clamped = clamp(seconds, 0, duration);
|
|
497
582
|
|
|
498
583
|
if (this.options.audioMode === 'external') {
|
|
499
|
-
|
|
500
|
-
const evt = new CustomEvent('waveformplayer:request-seek', {
|
|
501
|
-
bubbles: true,
|
|
502
|
-
cancelable: true,
|
|
503
|
-
detail: { ...this._buildTrackDetail(), percent }
|
|
504
|
-
});
|
|
505
|
-
this.container.dispatchEvent(evt);
|
|
506
|
-
if (!evt.defaultPrevented) {
|
|
507
|
-
this.progress = percent;
|
|
508
|
-
this.drawWaveform?.();
|
|
509
|
-
}
|
|
584
|
+
this._requestSeek(clamped / duration);
|
|
510
585
|
this.updateSeekAccessibility();
|
|
511
586
|
return;
|
|
512
587
|
}
|
|
@@ -517,7 +592,9 @@ export class WaveformPlayer {
|
|
|
517
592
|
|
|
518
593
|
/**
|
|
519
594
|
* Set the slider's accessible name from `seekLabel`, falling back to the
|
|
520
|
-
* track title, then a generic 'Seek'.
|
|
595
|
+
* track title, then a generic 'Seek'. No-op if the slider isn't present.
|
|
596
|
+
* @param {string} [title=this.options.title] - Track title to fall back to
|
|
597
|
+
* when `seekLabel` is not set.
|
|
521
598
|
* @private
|
|
522
599
|
*/
|
|
523
600
|
applySeekLabel(title = this.options.title) {
|
|
@@ -569,10 +646,10 @@ export class WaveformPlayer {
|
|
|
569
646
|
navigator.mediaSession.setActionHandler('play', () => this.play());
|
|
570
647
|
navigator.mediaSession.setActionHandler('pause', () => this.pause());
|
|
571
648
|
navigator.mediaSession.setActionHandler('seekbackward', () => {
|
|
572
|
-
this.seekTo(
|
|
649
|
+
this.seekTo(clamp(this.audio.currentTime - 10, 0, this.audio.duration));
|
|
573
650
|
});
|
|
574
651
|
navigator.mediaSession.setActionHandler('seekforward', () => {
|
|
575
|
-
this.seekTo(
|
|
652
|
+
this.seekTo(clamp(this.audio.currentTime + 10, 0, this.audio.duration));
|
|
576
653
|
});
|
|
577
654
|
navigator.mediaSession.setActionHandler('seekto', (details) => {
|
|
578
655
|
if (details.seekTime !== null) {
|
|
@@ -586,7 +663,10 @@ export class WaveformPlayer {
|
|
|
586
663
|
// ============================================
|
|
587
664
|
|
|
588
665
|
/**
|
|
589
|
-
* Bind
|
|
666
|
+
* Bind the core interaction listeners: play-button click, the `<audio>`
|
|
667
|
+
* media events (self mode only — external mode is fed state via
|
|
668
|
+
* {@link WaveformPlayer#setPlayingState}/{@link WaveformPlayer#setProgress}),
|
|
669
|
+
* canvas click-to-seek, and a debounced window-resize redraw.
|
|
590
670
|
* @private
|
|
591
671
|
*/
|
|
592
672
|
bindEvents() {
|
|
@@ -622,7 +702,8 @@ export class WaveformPlayer {
|
|
|
622
702
|
}
|
|
623
703
|
|
|
624
704
|
/**
|
|
625
|
-
*
|
|
705
|
+
* Observe the canvas's parent element for size changes and re-fit the
|
|
706
|
+
* canvas on each one. No-op where `ResizeObserver` is unavailable.
|
|
626
707
|
* @private
|
|
627
708
|
*/
|
|
628
709
|
setupResizeObserver() {
|
|
@@ -642,9 +723,20 @@ export class WaveformPlayer {
|
|
|
642
723
|
// ============================================
|
|
643
724
|
|
|
644
725
|
/**
|
|
645
|
-
* Load audio
|
|
646
|
-
*
|
|
647
|
-
*
|
|
726
|
+
* Load an audio source: set the title, fetch/generate the waveform peaks,
|
|
727
|
+
* draw them, render markers, and initialise Media Session.
|
|
728
|
+
*
|
|
729
|
+
* In self mode the `<audio>` src is assigned and the method awaits
|
|
730
|
+
* `loadedmetadata` before proceeding. In external mode there is no audio
|
|
731
|
+
* element, so the src/metadata step is skipped and only the visualization
|
|
732
|
+
* is built (duration/time come from the controller via
|
|
733
|
+
* {@link WaveformPlayer#setProgress}). Peaks come from the `waveform`
|
|
734
|
+
* option when provided, otherwise they are decoded from the audio; a
|
|
735
|
+
* decode failure falls back to a placeholder waveform. The `onLoad`
|
|
736
|
+
* callback fires on success.
|
|
737
|
+
* @param {string} url - Audio URL.
|
|
738
|
+
* @returns {Promise<void>} Resolves once loading settles (errors are caught
|
|
739
|
+
* internally and surfaced through {@link WaveformPlayer#onError}).
|
|
648
740
|
*/
|
|
649
741
|
async load(url) {
|
|
650
742
|
try {
|
|
@@ -701,7 +793,7 @@ export class WaveformPlayer {
|
|
|
701
793
|
this.updateBPMDisplay();
|
|
702
794
|
}
|
|
703
795
|
} catch (error) {
|
|
704
|
-
console.warn('Using placeholder waveform:', error);
|
|
796
|
+
console.warn('[WaveformPlayer] Using placeholder waveform:', error);
|
|
705
797
|
this.waveformData = generatePlaceholderWaveform(this.options.samples);
|
|
706
798
|
}
|
|
707
799
|
}
|
|
@@ -715,7 +807,7 @@ export class WaveformPlayer {
|
|
|
715
807
|
this.options.onLoad(this);
|
|
716
808
|
}
|
|
717
809
|
} catch (error) {
|
|
718
|
-
|
|
810
|
+
// onError() is the single funnel for surfacing + logging errors.
|
|
719
811
|
this.onError(error);
|
|
720
812
|
} finally {
|
|
721
813
|
this.setLoading(false);
|
|
@@ -723,11 +815,20 @@ export class WaveformPlayer {
|
|
|
723
815
|
}
|
|
724
816
|
|
|
725
817
|
/**
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
*
|
|
729
|
-
*
|
|
730
|
-
*
|
|
818
|
+
* Swap the player to a new track at runtime.
|
|
819
|
+
*
|
|
820
|
+
* Pauses any current playback, fully resets the audio element (self mode),
|
|
821
|
+
* clears error/marker/progress state, merges the new metadata into
|
|
822
|
+
* `this.options`, updates the subtitle/artwork DOM, then calls
|
|
823
|
+
* {@link WaveformPlayer#load}. Auto-plays the new track unless
|
|
824
|
+
* `options.autoplay === false`.
|
|
825
|
+
* @param {string} url - Audio URL.
|
|
826
|
+
* @param {string|null} [title=null] - Track title; keeps the existing
|
|
827
|
+
* title when null.
|
|
828
|
+
* @param {string|null} [subtitle=null] - Track subtitle; pass `''` to hide
|
|
829
|
+
* the subtitle row, or null to keep the existing one.
|
|
830
|
+
* @param {Object} [options={}] - Additional options to merge (e.g.
|
|
831
|
+
* `preload`, `artwork`, `markers`, `autoplay`).
|
|
731
832
|
* @returns {Promise<void>}
|
|
732
833
|
*/
|
|
733
834
|
async loadTrack(url, title = null, subtitle = null, options = {}) {
|
|
@@ -792,8 +893,11 @@ export class WaveformPlayer {
|
|
|
792
893
|
// Load the new track
|
|
793
894
|
await this.load(url);
|
|
794
895
|
|
|
795
|
-
// Auto-play the new track
|
|
796
|
-
|
|
896
|
+
// Auto-play the new track unless the caller opted out — lets a
|
|
897
|
+
// controller load/restore/enqueue without forcing playback.
|
|
898
|
+
if (options.autoplay !== false) {
|
|
899
|
+
this.play()?.catch(() => {});
|
|
900
|
+
}
|
|
797
901
|
}
|
|
798
902
|
|
|
799
903
|
// ============================================
|
|
@@ -801,7 +905,15 @@ export class WaveformPlayer {
|
|
|
801
905
|
// ============================================
|
|
802
906
|
|
|
803
907
|
/**
|
|
804
|
-
*
|
|
908
|
+
* Normalise externally-supplied waveform data into `this.waveformData` and
|
|
909
|
+
* redraw.
|
|
910
|
+
*
|
|
911
|
+
* Accepts several shapes: a `.json` URL (fetched async; peaks and any
|
|
912
|
+
* embedded `markers` are applied on resolve), a JSON-encoded array string,
|
|
913
|
+
* a comma-separated number string, or a plain number array. Malformed
|
|
914
|
+
* input degrades to an empty array rather than throwing.
|
|
915
|
+
* @param {string|number[]} data - Peaks as an array, a JSON/CSV string, or
|
|
916
|
+
* a URL to a `.json` peaks file.
|
|
805
917
|
* @private
|
|
806
918
|
*/
|
|
807
919
|
setWaveformData(data) {
|
|
@@ -835,7 +947,9 @@ export class WaveformPlayer {
|
|
|
835
947
|
}
|
|
836
948
|
|
|
837
949
|
/**
|
|
838
|
-
*
|
|
950
|
+
* Render the current waveform + progress to the canvas via the shared
|
|
951
|
+
* {@link draw} routine, passing the resolved style and colours. No-op
|
|
952
|
+
* before the context exists or while there is no peak data.
|
|
839
953
|
* @private
|
|
840
954
|
*/
|
|
841
955
|
drawWaveform() {
|
|
@@ -850,7 +964,9 @@ export class WaveformPlayer {
|
|
|
850
964
|
}
|
|
851
965
|
|
|
852
966
|
/**
|
|
853
|
-
*
|
|
967
|
+
* Re-fit the canvas backing store to its parent's width and the configured
|
|
968
|
+
* height, scaled by the device pixel ratio for crisp rendering, then
|
|
969
|
+
* redraw. Guards against running after destruction.
|
|
854
970
|
* @private
|
|
855
971
|
*/
|
|
856
972
|
resizeCanvas() {
|
|
@@ -870,7 +986,15 @@ export class WaveformPlayer {
|
|
|
870
986
|
}
|
|
871
987
|
|
|
872
988
|
/**
|
|
873
|
-
* Render markers
|
|
989
|
+
* Render the configured cue markers as positioned, clickable buttons over
|
|
990
|
+
* the waveform.
|
|
991
|
+
*
|
|
992
|
+
* Clears any existing markers first, then bails out unless `showMarkers` is
|
|
993
|
+
* on, markers exist, and a duration is known (via the mode-agnostic
|
|
994
|
+
* {@link WaveformPlayer#getSeekDuration}). Each marker is placed by its
|
|
995
|
+
* time-as-percentage, carries a tooltip and ARIA label, and seeks on click
|
|
996
|
+
* (also starting playback when `playOnSeek` is set and currently paused).
|
|
997
|
+
* Markers past the track duration are skipped with a warning.
|
|
874
998
|
* @private
|
|
875
999
|
*/
|
|
876
1000
|
renderMarkers() {
|
|
@@ -881,20 +1005,22 @@ export class WaveformPlayer {
|
|
|
881
1005
|
|
|
882
1006
|
if (!this.options.showMarkers || !this.options.markers?.length) return;
|
|
883
1007
|
|
|
884
|
-
//
|
|
885
|
-
|
|
1008
|
+
// Duration may come from the <audio> (self mode) or the external
|
|
1009
|
+
// controller (external mode) — use the mode-agnostic accessor.
|
|
1010
|
+
const duration = this.getSeekDuration();
|
|
1011
|
+
if (!duration) {
|
|
886
1012
|
return;
|
|
887
1013
|
}
|
|
888
1014
|
|
|
889
1015
|
// Add each marker
|
|
890
1016
|
this.options.markers.forEach((marker, index) => {
|
|
891
1017
|
// Skip markers that are beyond the audio duration
|
|
892
|
-
if (marker.time >
|
|
893
|
-
console.warn(`Marker "${marker.label}" at ${marker.time}s exceeds audio duration of ${
|
|
1018
|
+
if (marker.time > duration) {
|
|
1019
|
+
console.warn(`[WaveformPlayer] Marker "${marker.label}" at ${marker.time}s exceeds audio duration of ${duration}s`);
|
|
894
1020
|
return;
|
|
895
1021
|
}
|
|
896
1022
|
|
|
897
|
-
const position = (marker.time /
|
|
1023
|
+
const position = (marker.time / duration) * 100;
|
|
898
1024
|
|
|
899
1025
|
const markerEl = document.createElement('button');
|
|
900
1026
|
markerEl.className = 'waveform-marker';
|
|
@@ -922,13 +1048,34 @@ export class WaveformPlayer {
|
|
|
922
1048
|
});
|
|
923
1049
|
}
|
|
924
1050
|
|
|
1051
|
+
/**
|
|
1052
|
+
* Highlight the marker at `index` (toggling an `active` class) and clear
|
|
1053
|
+
* the rest. Pass `null` to clear all. Lets an external controller (e.g. a
|
|
1054
|
+
* DJ bar) reflect the current section without reaching into the player's
|
|
1055
|
+
* private marker DOM.
|
|
1056
|
+
* @param {number|null} index - Marker index to activate, or `null` to clear.
|
|
1057
|
+
*/
|
|
1058
|
+
setActiveMarker(index) {
|
|
1059
|
+
if (!this.markersContainer) return;
|
|
1060
|
+
const markers = this.markersContainer.querySelectorAll('.waveform-marker');
|
|
1061
|
+
markers.forEach((el, i) => el.classList.toggle('active', i === index));
|
|
1062
|
+
}
|
|
1063
|
+
|
|
925
1064
|
// ============================================
|
|
926
1065
|
// Event Handlers
|
|
927
1066
|
// ============================================
|
|
928
1067
|
|
|
929
1068
|
/**
|
|
930
|
-
*
|
|
1069
|
+
* Seek to the clicked horizontal position on the waveform canvas.
|
|
1070
|
+
*
|
|
1071
|
+
* Converts the click X into a 0..1 percentage. In external mode it
|
|
1072
|
+
* dispatches a cancelable `waveformplayer:request-seek` event (updating the
|
|
1073
|
+
* local visual optimistically unless the controller vetoes it); in self
|
|
1074
|
+
* mode it seeks the owned `<audio>` via
|
|
1075
|
+
* {@link WaveformPlayer#seekToPercent}.
|
|
1076
|
+
* @param {MouseEvent} event - The canvas click event.
|
|
931
1077
|
* @private
|
|
1078
|
+
* @fires WaveformPlayer#waveformplayer:request-seek
|
|
932
1079
|
*/
|
|
933
1080
|
handleCanvasClick(event) {
|
|
934
1081
|
// In external mode the player has no audio of its own —
|
|
@@ -939,19 +1086,10 @@ export class WaveformPlayer {
|
|
|
939
1086
|
// controller's progress event will reconcile shortly after).
|
|
940
1087
|
const rect = this.canvas.getBoundingClientRect();
|
|
941
1088
|
const x = event.clientX - rect.left;
|
|
942
|
-
const targetPercent =
|
|
1089
|
+
const targetPercent = clamp(x / rect.width);
|
|
943
1090
|
|
|
944
1091
|
if (this.options.audioMode === 'external') {
|
|
945
|
-
|
|
946
|
-
bubbles: true,
|
|
947
|
-
cancelable: true,
|
|
948
|
-
detail: { ...this._buildTrackDetail(), percent: targetPercent }
|
|
949
|
-
});
|
|
950
|
-
this.container.dispatchEvent(evt);
|
|
951
|
-
if (!evt.defaultPrevented) {
|
|
952
|
-
this.progress = targetPercent;
|
|
953
|
-
this.drawWaveform?.();
|
|
954
|
-
}
|
|
1092
|
+
this._requestSeek(targetPercent);
|
|
955
1093
|
return;
|
|
956
1094
|
}
|
|
957
1095
|
|
|
@@ -960,7 +1098,10 @@ export class WaveformPlayer {
|
|
|
960
1098
|
}
|
|
961
1099
|
|
|
962
1100
|
/**
|
|
963
|
-
*
|
|
1101
|
+
* Toggle the loading state: show/hide the spinner overlay and set
|
|
1102
|
+
* `aria-busy` on the accessible seek slider so assistive tech knows the
|
|
1103
|
+
* player is fetching/decoding.
|
|
1104
|
+
* @param {boolean} loading - True while audio is loading.
|
|
964
1105
|
* @private
|
|
965
1106
|
*/
|
|
966
1107
|
setLoading(loading) {
|
|
@@ -968,10 +1109,16 @@ export class WaveformPlayer {
|
|
|
968
1109
|
if (this.loadingEl) {
|
|
969
1110
|
this.loadingEl.style.display = loading ? 'block' : 'none';
|
|
970
1111
|
}
|
|
1112
|
+
// Let assistive tech know the player is busy fetching/decoding.
|
|
1113
|
+
if (this.seekEl) {
|
|
1114
|
+
this.seekEl.setAttribute('aria-busy', loading ? 'true' : 'false');
|
|
1115
|
+
}
|
|
971
1116
|
}
|
|
972
1117
|
|
|
973
1118
|
/**
|
|
974
|
-
*
|
|
1119
|
+
* `loadedmetadata` handler (self mode): write the total-time display, now
|
|
1120
|
+
* that duration is known re-render markers, and publish duration to the
|
|
1121
|
+
* accessible seek slider. No-op during destruction.
|
|
975
1122
|
* @private
|
|
976
1123
|
*/
|
|
977
1124
|
onMetadataLoaded() {
|
|
@@ -988,8 +1135,29 @@ export class WaveformPlayer {
|
|
|
988
1135
|
}
|
|
989
1136
|
|
|
990
1137
|
/**
|
|
991
|
-
*
|
|
1138
|
+
* Reflect play/pause state on the transport button: toggle the `playing`
|
|
1139
|
+
* class and swap the play/pause icon visibility. The single source of
|
|
1140
|
+
* truth shared by `onPlay`, `onPause`, and the external-mode
|
|
1141
|
+
* `setPlayingState` pump so they can't drift. No-op without a button.
|
|
1142
|
+
* @param {boolean} isPlaying - Whether playback is active.
|
|
1143
|
+
* @private
|
|
1144
|
+
*/
|
|
1145
|
+
setPlayButtonState(isPlaying) {
|
|
1146
|
+
if (!this.playBtn) return;
|
|
1147
|
+
this.playBtn.classList.toggle('playing', isPlaying);
|
|
1148
|
+
const playIcon = this.playBtn.querySelector('.waveform-icon-play');
|
|
1149
|
+
const pauseIcon = this.playBtn.querySelector('.waveform-icon-pause');
|
|
1150
|
+
if (playIcon) playIcon.style.display = isPlaying ? 'none' : 'flex';
|
|
1151
|
+
if (pauseIcon) pauseIcon.style.display = isPlaying ? 'flex' : 'none';
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* `play` handler (self mode): set the playing flag, swap the button to its
|
|
1156
|
+
* pause icon, start the smooth progress loop, dispatch
|
|
1157
|
+
* `waveformplayer:play`, and fire the `onPlay` callback. No-op during
|
|
1158
|
+
* destruction.
|
|
992
1159
|
* @private
|
|
1160
|
+
* @fires WaveformPlayer#waveformplayer:play
|
|
993
1161
|
*/
|
|
994
1162
|
onPlay() {
|
|
995
1163
|
// Ignore during destruction
|
|
@@ -997,22 +1165,12 @@ export class WaveformPlayer {
|
|
|
997
1165
|
|
|
998
1166
|
this.isPlaying = true;
|
|
999
1167
|
|
|
1000
|
-
|
|
1001
|
-
this.playBtn.classList.add('playing');
|
|
1002
|
-
|
|
1003
|
-
const playIcon = this.playBtn.querySelector('.waveform-icon-play');
|
|
1004
|
-
const pauseIcon = this.playBtn.querySelector('.waveform-icon-pause');
|
|
1005
|
-
if (playIcon) playIcon.style.display = 'none';
|
|
1006
|
-
if (pauseIcon) pauseIcon.style.display = 'flex';
|
|
1007
|
-
}
|
|
1168
|
+
this.setPlayButtonState(true);
|
|
1008
1169
|
|
|
1009
1170
|
this.startSmoothUpdate();
|
|
1010
1171
|
|
|
1011
1172
|
// Dispatch play event
|
|
1012
|
-
this.
|
|
1013
|
-
bubbles: true,
|
|
1014
|
-
detail: {player: this, url: this.options.url}
|
|
1015
|
-
}));
|
|
1173
|
+
this._emit('waveformplayer:play', {player: this, url: this.options.url});
|
|
1016
1174
|
|
|
1017
1175
|
if (this.options.onPlay) {
|
|
1018
1176
|
this.options.onPlay(this);
|
|
@@ -1020,8 +1178,12 @@ export class WaveformPlayer {
|
|
|
1020
1178
|
}
|
|
1021
1179
|
|
|
1022
1180
|
/**
|
|
1023
|
-
*
|
|
1181
|
+
* `pause` handler (self mode): clear the playing flag, swap the button back
|
|
1182
|
+
* to its play icon, stop the smooth progress loop, dispatch
|
|
1183
|
+
* `waveformplayer:pause`, and fire the `onPause` callback. No-op during
|
|
1184
|
+
* destruction.
|
|
1024
1185
|
* @private
|
|
1186
|
+
* @fires WaveformPlayer#waveformplayer:pause
|
|
1025
1187
|
*/
|
|
1026
1188
|
onPause() {
|
|
1027
1189
|
// Ignore during destruction
|
|
@@ -1029,22 +1191,12 @@ export class WaveformPlayer {
|
|
|
1029
1191
|
|
|
1030
1192
|
this.isPlaying = false;
|
|
1031
1193
|
|
|
1032
|
-
|
|
1033
|
-
this.playBtn.classList.remove('playing');
|
|
1034
|
-
|
|
1035
|
-
const playIcon = this.playBtn.querySelector('.waveform-icon-play');
|
|
1036
|
-
const pauseIcon = this.playBtn.querySelector('.waveform-icon-pause');
|
|
1037
|
-
if (playIcon) playIcon.style.display = 'flex';
|
|
1038
|
-
if (pauseIcon) pauseIcon.style.display = 'none';
|
|
1039
|
-
}
|
|
1194
|
+
this.setPlayButtonState(false);
|
|
1040
1195
|
|
|
1041
1196
|
this.stopSmoothUpdate();
|
|
1042
1197
|
|
|
1043
1198
|
// Dispatch pause event
|
|
1044
|
-
this.
|
|
1045
|
-
bubbles: true,
|
|
1046
|
-
detail: {player: this, url: this.options.url}
|
|
1047
|
-
}));
|
|
1199
|
+
this._emit('waveformplayer:pause', {player: this, url: this.options.url});
|
|
1048
1200
|
|
|
1049
1201
|
if (this.options.onPause) {
|
|
1050
1202
|
this.options.onPause(this);
|
|
@@ -1052,13 +1204,19 @@ export class WaveformPlayer {
|
|
|
1052
1204
|
}
|
|
1053
1205
|
|
|
1054
1206
|
/**
|
|
1055
|
-
*
|
|
1207
|
+
* `ended` handler (self mode): reset progress and `currentTime` to the
|
|
1208
|
+
* start, redraw, reset the time display, dispatch `waveformplayer:ended`
|
|
1209
|
+
* (carrying the final time), run {@link WaveformPlayer#onPause}, and fire
|
|
1210
|
+
* the `onEnd` callback. No-op during destruction.
|
|
1056
1211
|
* @private
|
|
1212
|
+
* @fires WaveformPlayer#waveformplayer:ended
|
|
1057
1213
|
*/
|
|
1058
1214
|
onEnded() {
|
|
1059
1215
|
// Ignore during destruction
|
|
1060
1216
|
if (this.isDestroying) return;
|
|
1061
1217
|
|
|
1218
|
+
const duration = this.audio.duration;
|
|
1219
|
+
|
|
1062
1220
|
this.progress = 0;
|
|
1063
1221
|
this.audio.currentTime = 0;
|
|
1064
1222
|
this.drawWaveform();
|
|
@@ -1068,11 +1226,9 @@ export class WaveformPlayer {
|
|
|
1068
1226
|
this.currentTimeEl.textContent = '0:00';
|
|
1069
1227
|
}
|
|
1070
1228
|
|
|
1071
|
-
// Dispatch ended event
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
detail: {player: this, url: this.options.url}
|
|
1075
|
-
}));
|
|
1229
|
+
// Dispatch ended event — carries the final time so listeners (e.g.
|
|
1230
|
+
// analytics) don't have to reach into player.audio.
|
|
1231
|
+
this._emit('waveformplayer:ended', {player: this, url: this.options.url, currentTime: duration, duration});
|
|
1076
1232
|
|
|
1077
1233
|
this.onPause();
|
|
1078
1234
|
|
|
@@ -1082,14 +1238,18 @@ export class WaveformPlayer {
|
|
|
1082
1238
|
}
|
|
1083
1239
|
|
|
1084
1240
|
/**
|
|
1085
|
-
*
|
|
1241
|
+
* `error` handler: set the error flag, hide the spinner, reveal the error
|
|
1242
|
+
* overlay, dim the canvas, disable the play button, and fire the `onError`
|
|
1243
|
+
* callback. No-op during destruction.
|
|
1244
|
+
* @param {Event|Error} error - The audio error event, or an Error thrown
|
|
1245
|
+
* during loading.
|
|
1086
1246
|
* @private
|
|
1087
1247
|
*/
|
|
1088
1248
|
onError(error) {
|
|
1089
1249
|
// Ignore errors during destruction
|
|
1090
1250
|
if (this.isDestroying) return;
|
|
1091
1251
|
|
|
1092
|
-
console.error('Audio error:', error);
|
|
1252
|
+
console.error('[WaveformPlayer] Audio error:', error);
|
|
1093
1253
|
this.hasError = true;
|
|
1094
1254
|
this.setLoading(false);
|
|
1095
1255
|
|
|
@@ -1115,7 +1275,10 @@ export class WaveformPlayer {
|
|
|
1115
1275
|
// ============================================
|
|
1116
1276
|
|
|
1117
1277
|
/**
|
|
1118
|
-
* Start smooth
|
|
1278
|
+
* Start the `requestAnimationFrame` loop that drives smooth progress
|
|
1279
|
+
* updates while playing (self mode only — external mode is redrawn by
|
|
1280
|
+
* controller {@link WaveformPlayer#setProgress} pushes). Cancels any
|
|
1281
|
+
* existing loop first so it's safe to call repeatedly.
|
|
1119
1282
|
* @private
|
|
1120
1283
|
*/
|
|
1121
1284
|
startSmoothUpdate() {
|
|
@@ -1135,7 +1298,7 @@ export class WaveformPlayer {
|
|
|
1135
1298
|
}
|
|
1136
1299
|
|
|
1137
1300
|
/**
|
|
1138
|
-
*
|
|
1301
|
+
* Cancel the smooth-update animation frame, if one is scheduled.
|
|
1139
1302
|
* @private
|
|
1140
1303
|
*/
|
|
1141
1304
|
stopSmoothUpdate() {
|
|
@@ -1146,8 +1309,15 @@ export class WaveformPlayer {
|
|
|
1146
1309
|
}
|
|
1147
1310
|
|
|
1148
1311
|
/**
|
|
1149
|
-
*
|
|
1312
|
+
* Recompute progress from the owned `<audio>` clock and reflect it
|
|
1313
|
+
* everywhere (self mode only — external mode uses
|
|
1314
|
+
* {@link WaveformPlayer#setProgress}).
|
|
1315
|
+
*
|
|
1316
|
+
* Redraws the canvas when progress moves meaningfully, updates the
|
|
1317
|
+
* current-time display, dispatches `waveformplayer:timeupdate`, fires the
|
|
1318
|
+
* `onTimeUpdate` callback, and refreshes the accessible slider values.
|
|
1150
1319
|
* @private
|
|
1320
|
+
* @fires WaveformPlayer#waveformplayer:timeupdate
|
|
1151
1321
|
*/
|
|
1152
1322
|
updateProgress() {
|
|
1153
1323
|
// Self-mode only — external mode receives progress via
|
|
@@ -1166,15 +1336,13 @@ export class WaveformPlayer {
|
|
|
1166
1336
|
}
|
|
1167
1337
|
|
|
1168
1338
|
// Dispatch timeupdate event
|
|
1169
|
-
this.
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
}
|
|
1177
|
-
}));
|
|
1339
|
+
this._emit('waveformplayer:timeupdate', {
|
|
1340
|
+
player: this,
|
|
1341
|
+
currentTime: this.audio.currentTime,
|
|
1342
|
+
duration: this.audio.duration,
|
|
1343
|
+
progress: this.progress,
|
|
1344
|
+
url: this.options.url
|
|
1345
|
+
});
|
|
1178
1346
|
|
|
1179
1347
|
if (this.options.onTimeUpdate) {
|
|
1180
1348
|
this.options.onTimeUpdate(this.audio.currentTime, this.audio.duration, this);
|
|
@@ -1188,7 +1356,7 @@ export class WaveformPlayer {
|
|
|
1188
1356
|
// ============================================
|
|
1189
1357
|
|
|
1190
1358
|
/**
|
|
1191
|
-
*
|
|
1359
|
+
* Show the detected BPM in the badge, once a value has been detected.
|
|
1192
1360
|
* @private
|
|
1193
1361
|
*/
|
|
1194
1362
|
updateBPMDisplay() {
|
|
@@ -1199,10 +1367,17 @@ export class WaveformPlayer {
|
|
|
1199
1367
|
}
|
|
1200
1368
|
|
|
1201
1369
|
/**
|
|
1202
|
-
*
|
|
1370
|
+
* Sync the speed control's label and the menu's active-option highlight to
|
|
1371
|
+
* the audio element's current `playbackRate`. No-op in external mode (no
|
|
1372
|
+
* owned `<audio>`), which also avoids reading `playbackRate` before the
|
|
1373
|
+
* element exists.
|
|
1203
1374
|
* @private
|
|
1204
1375
|
*/
|
|
1205
1376
|
updateSpeedUI() {
|
|
1377
|
+
// External mode owns no <audio>; nothing to reflect (and reading
|
|
1378
|
+
// this.audio.playbackRate here would throw during construction).
|
|
1379
|
+
if (!this.audio) return;
|
|
1380
|
+
|
|
1206
1381
|
const speedValue = this.container.querySelector('.speed-value');
|
|
1207
1382
|
if (speedValue) {
|
|
1208
1383
|
const rate = this.audio.playbackRate;
|
|
@@ -1233,7 +1408,12 @@ export class WaveformPlayer {
|
|
|
1233
1408
|
* setPlayingState() / setProgress(). Calling preventDefault() on
|
|
1234
1409
|
* the event lets the controller veto the play (state is unchanged).
|
|
1235
1410
|
*
|
|
1236
|
-
*
|
|
1411
|
+
* When `singlePlay` is enabled, any other currently-playing instance is
|
|
1412
|
+
* paused first.
|
|
1413
|
+
*
|
|
1414
|
+
* @return {Promise|undefined} The promise from `HTMLMediaElement.play()` in
|
|
1415
|
+
* self mode; `undefined` in external mode.
|
|
1416
|
+
* @fires WaveformPlayer#waveformplayer:request-play
|
|
1237
1417
|
*/
|
|
1238
1418
|
play() {
|
|
1239
1419
|
if (this.options.singlePlay && WaveformPlayer.currentlyPlaying &&
|
|
@@ -1242,12 +1422,7 @@ export class WaveformPlayer {
|
|
|
1242
1422
|
}
|
|
1243
1423
|
|
|
1244
1424
|
if (this.options.audioMode === 'external') {
|
|
1245
|
-
const evt =
|
|
1246
|
-
bubbles: true,
|
|
1247
|
-
cancelable: true,
|
|
1248
|
-
detail: this._buildTrackDetail()
|
|
1249
|
-
});
|
|
1250
|
-
this.container.dispatchEvent(evt);
|
|
1425
|
+
const evt = this._emit('waveformplayer:request-play', this._buildTrackDetail(), true);
|
|
1251
1426
|
// If the controller cancels (preventDefault), don't claim
|
|
1252
1427
|
// "currentlyPlaying" — the controller didn't accept the play.
|
|
1253
1428
|
if (!evt.defaultPrevented) {
|
|
@@ -1265,17 +1440,15 @@ export class WaveformPlayer {
|
|
|
1265
1440
|
*
|
|
1266
1441
|
* In `audioMode: 'external'`, dispatches `waveformplayer:request-pause`
|
|
1267
1442
|
* (cancelable) and does NOT touch any audio element. See play().
|
|
1443
|
+
*
|
|
1444
|
+
* @fires WaveformPlayer#waveformplayer:request-pause
|
|
1268
1445
|
*/
|
|
1269
1446
|
pause() {
|
|
1270
1447
|
if (WaveformPlayer.currentlyPlaying === this) {
|
|
1271
1448
|
WaveformPlayer.currentlyPlaying = null;
|
|
1272
1449
|
}
|
|
1273
1450
|
if (this.options.audioMode === 'external') {
|
|
1274
|
-
this.
|
|
1275
|
-
bubbles: true,
|
|
1276
|
-
cancelable: true,
|
|
1277
|
-
detail: this._buildTrackDetail()
|
|
1278
|
-
}));
|
|
1451
|
+
this._emit('waveformplayer:request-pause', this._buildTrackDetail(), true);
|
|
1279
1452
|
return;
|
|
1280
1453
|
}
|
|
1281
1454
|
this.audio.pause();
|
|
@@ -1295,8 +1468,12 @@ export class WaveformPlayer {
|
|
|
1295
1468
|
url: this.options.url,
|
|
1296
1469
|
title: this.options.title,
|
|
1297
1470
|
subtitle: this.options.subtitle,
|
|
1298
|
-
artist
|
|
1471
|
+
// Core has no separate `artist` option; mirror subtitle so the
|
|
1472
|
+
// published event detail is self-consistent for controllers.
|
|
1473
|
+
artist: this.options.artist || this.options.subtitle,
|
|
1299
1474
|
artwork: this.options.artwork,
|
|
1475
|
+
markers: this.options.markers,
|
|
1476
|
+
waveform: this.options.waveform,
|
|
1300
1477
|
id: this.id,
|
|
1301
1478
|
player: this
|
|
1302
1479
|
};
|
|
@@ -1307,31 +1484,26 @@ export class WaveformPlayer {
|
|
|
1307
1484
|
* touching audio. Mirrors what onPlay()/onPause() do but skips the
|
|
1308
1485
|
* audio-element interactions. Safe to call repeatedly — idempotent.
|
|
1309
1486
|
*
|
|
1310
|
-
*
|
|
1487
|
+
* Only dispatches `waveformplayer:play`/`waveformplayer:pause` (and runs
|
|
1488
|
+
* the matching callback) on an actual transition, starting/stopping the
|
|
1489
|
+
* smooth-update loop accordingly.
|
|
1490
|
+
*
|
|
1491
|
+
* @param {boolean} playing - True to enter the playing state, false to
|
|
1492
|
+
* enter the paused state.
|
|
1493
|
+
* @fires WaveformPlayer#waveformplayer:play
|
|
1494
|
+
* @fires WaveformPlayer#waveformplayer:pause
|
|
1311
1495
|
*/
|
|
1312
1496
|
setPlayingState(playing) {
|
|
1313
1497
|
const wasPlaying = this.isPlaying;
|
|
1314
1498
|
this.isPlaying = !!playing;
|
|
1315
|
-
|
|
1316
|
-
this.playBtn.classList.toggle('playing', this.isPlaying);
|
|
1317
|
-
const playIcon = this.playBtn.querySelector('.waveform-icon-play');
|
|
1318
|
-
const pauseIcon = this.playBtn.querySelector('.waveform-icon-pause');
|
|
1319
|
-
if (playIcon) playIcon.style.display = this.isPlaying ? 'none' : 'flex';
|
|
1320
|
-
if (pauseIcon) pauseIcon.style.display = this.isPlaying ? 'flex' : 'none';
|
|
1321
|
-
}
|
|
1499
|
+
this.setPlayButtonState(this.isPlaying);
|
|
1322
1500
|
if (this.isPlaying && !wasPlaying) {
|
|
1323
1501
|
this.startSmoothUpdate?.();
|
|
1324
|
-
this.
|
|
1325
|
-
bubbles: true,
|
|
1326
|
-
detail: {player: this, url: this.options.url}
|
|
1327
|
-
}));
|
|
1502
|
+
this._emit('waveformplayer:play', {player: this, url: this.options.url});
|
|
1328
1503
|
if (this.options.onPlay) this.options.onPlay(this);
|
|
1329
1504
|
} else if (!this.isPlaying && wasPlaying) {
|
|
1330
1505
|
this.stopSmoothUpdate?.();
|
|
1331
|
-
this.
|
|
1332
|
-
bubbles: true,
|
|
1333
|
-
detail: {player: this, url: this.options.url}
|
|
1334
|
-
}));
|
|
1506
|
+
this._emit('waveformplayer:pause', {player: this, url: this.options.url});
|
|
1335
1507
|
if (this.options.onPause) this.options.onPause(this);
|
|
1336
1508
|
}
|
|
1337
1509
|
}
|
|
@@ -1341,32 +1513,58 @@ export class WaveformPlayer {
|
|
|
1341
1513
|
* from an external clock (e.g. WaveformBar's audio element's
|
|
1342
1514
|
* timeupdate). Drives the canvas redraw + the time displays.
|
|
1343
1515
|
*
|
|
1344
|
-
*
|
|
1345
|
-
*
|
|
1516
|
+
* Redraws the canvas, updates the current/total time displays, stores the
|
|
1517
|
+
* external duration for the accessible slider, dispatches
|
|
1518
|
+
* `waveformplayer:timeupdate`, runs `onTimeUpdate`, and synthesizes a
|
|
1519
|
+
* one-shot `waveformplayer:ended` (with `onEnd`) when progress reaches the
|
|
1520
|
+
* end. No-op for a non-positive duration.
|
|
1521
|
+
*
|
|
1522
|
+
* @param {number} currentTime - Current playback position in seconds.
|
|
1523
|
+
* @param {number} duration - Total track duration in seconds.
|
|
1524
|
+
* @fires WaveformPlayer#waveformplayer:timeupdate
|
|
1525
|
+
* @fires WaveformPlayer#waveformplayer:ended
|
|
1346
1526
|
*/
|
|
1347
1527
|
setProgress(currentTime, duration) {
|
|
1348
1528
|
if (!duration || duration <= 0) return;
|
|
1349
|
-
this.progress =
|
|
1529
|
+
this.progress = clamp(currentTime / duration);
|
|
1350
1530
|
// Mirror the existing display update code so callers don't have
|
|
1351
1531
|
// to know which DOM elements live where.
|
|
1352
1532
|
if (this.currentTimeEl) this.currentTimeEl.textContent = formatTime(currentTime);
|
|
1353
|
-
|
|
1533
|
+
// Publish the duration unconditionally — the accessible seek slider
|
|
1534
|
+
// and keyboard seeking read getSeekDuration()/_extDuration even when
|
|
1535
|
+
// there's no time display to update.
|
|
1536
|
+
this._extDuration = duration;
|
|
1537
|
+
if (this.totalTimeEl && (!this.totalTimeEl.dataset._extSet || this.totalTimeEl.dataset._extDur !== String(duration))) {
|
|
1354
1538
|
this.totalTimeEl.textContent = formatTime(duration);
|
|
1355
1539
|
this.totalTimeEl.dataset._extSet = '1';
|
|
1356
|
-
this.
|
|
1540
|
+
this.totalTimeEl.dataset._extDur = String(duration);
|
|
1357
1541
|
}
|
|
1358
1542
|
this.drawWaveform?.();
|
|
1359
|
-
this.
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
if (this.options.onTimeUpdate) this.options.onTimeUpdate(
|
|
1543
|
+
this._emit('waveformplayer:timeupdate', {player: this, currentTime, duration, progress: this.progress, url: this.options.url});
|
|
1544
|
+
// Same (currentTime, duration, player) signature as self mode — the
|
|
1545
|
+
// arg order used to be swapped here, which made one shared handler
|
|
1546
|
+
// impossible across audioModes.
|
|
1547
|
+
if (this.options.onTimeUpdate) this.options.onTimeUpdate(currentTime, duration, this);
|
|
1548
|
+
|
|
1549
|
+
// External mode has no <audio> 'ended' event — synthesize one when the
|
|
1550
|
+
// controller's progress reaches the end (fires once per playthrough).
|
|
1551
|
+
if (this.progress >= 1) {
|
|
1552
|
+
if (!this._extEnded) {
|
|
1553
|
+
this._extEnded = true;
|
|
1554
|
+
this._emit('waveformplayer:ended', {player: this, url: this.options.url, currentTime: duration, duration});
|
|
1555
|
+
if (this.options.onEnd) this.options.onEnd(this);
|
|
1556
|
+
}
|
|
1557
|
+
} else {
|
|
1558
|
+
this._extEnded = false;
|
|
1559
|
+
}
|
|
1364
1560
|
|
|
1365
1561
|
this.updateSeekAccessibility();
|
|
1366
1562
|
}
|
|
1367
1563
|
|
|
1368
1564
|
/**
|
|
1369
|
-
* Toggle play
|
|
1565
|
+
* Toggle between play and pause based on the current `isPlaying` state.
|
|
1566
|
+
* Works in both audio modes (in external mode it routes through the
|
|
1567
|
+
* request-play/pause events).
|
|
1370
1568
|
*/
|
|
1371
1569
|
togglePlay() {
|
|
1372
1570
|
if (this.isPlaying) {
|
|
@@ -1377,45 +1575,56 @@ export class WaveformPlayer {
|
|
|
1377
1575
|
}
|
|
1378
1576
|
|
|
1379
1577
|
/**
|
|
1380
|
-
* Seek to time
|
|
1381
|
-
*
|
|
1578
|
+
* Seek the owned `<audio>` element to an absolute time, clamped to
|
|
1579
|
+
* `[0, duration]`, and refresh progress. Self mode only — a no-op when
|
|
1580
|
+
* there is no audio element or duration. External-mode keyboard/click
|
|
1581
|
+
* seeks go through {@link WaveformPlayer#seekToSeconds} instead.
|
|
1582
|
+
* @param {number} seconds - Target time in seconds.
|
|
1382
1583
|
*/
|
|
1383
1584
|
seekTo(seconds) {
|
|
1384
1585
|
if (this.audio && this.audio.duration) {
|
|
1385
|
-
this.audio.currentTime =
|
|
1586
|
+
this.audio.currentTime = clamp(seconds, 0, this.audio.duration);
|
|
1386
1587
|
this.updateProgress();
|
|
1387
1588
|
}
|
|
1388
1589
|
}
|
|
1389
1590
|
|
|
1390
1591
|
/**
|
|
1391
|
-
* Seek to
|
|
1392
|
-
*
|
|
1592
|
+
* Seek the owned `<audio>` element to a fraction of the track, clamped to
|
|
1593
|
+
* `[0, 1]`, and refresh progress. Self mode only — a no-op without an audio
|
|
1594
|
+
* element or duration.
|
|
1595
|
+
* @param {number} percent - Position as a fraction from 0 to 1.
|
|
1393
1596
|
*/
|
|
1394
1597
|
seekToPercent(percent) {
|
|
1395
1598
|
if (this.audio && this.audio.duration) {
|
|
1396
|
-
this.audio.currentTime = this.audio.duration *
|
|
1599
|
+
this.audio.currentTime = this.audio.duration * clamp(percent);
|
|
1397
1600
|
this.updateProgress();
|
|
1398
1601
|
}
|
|
1399
1602
|
}
|
|
1400
1603
|
|
|
1401
1604
|
/**
|
|
1402
|
-
* Set volume
|
|
1403
|
-
*
|
|
1605
|
+
* Set the owned `<audio>` element's volume, clamped to `[0, 1]`. Self mode
|
|
1606
|
+
* only — a no-op in external mode where the controller owns volume.
|
|
1607
|
+
* @param {number} volume - Volume from 0 (silent) to 1 (full).
|
|
1404
1608
|
*/
|
|
1405
1609
|
setVolume(volume) {
|
|
1406
|
-
|
|
1407
|
-
|
|
1610
|
+
// Coerce + guard: a non-finite value (e.g. from a bad config or stale
|
|
1611
|
+
// storage) must not propagate NaN into audio.volume (which throws).
|
|
1612
|
+
const v = Number(volume);
|
|
1613
|
+
if (this.audio && Number.isFinite(v)) {
|
|
1614
|
+
this.audio.volume = clamp(v);
|
|
1408
1615
|
}
|
|
1409
1616
|
}
|
|
1410
1617
|
|
|
1411
1618
|
/**
|
|
1412
|
-
* Set playback rate
|
|
1413
|
-
*
|
|
1619
|
+
* Set the owned `<audio>` element's playback rate (clamped to 0.5–2),
|
|
1620
|
+
* persist it onto `this.options.playbackRate`, and refresh the speed UI.
|
|
1621
|
+
* Self mode only — a no-op in external mode.
|
|
1622
|
+
* @param {number} rate - Desired playback rate; clamped to the 0.5–2 range.
|
|
1414
1623
|
*/
|
|
1415
1624
|
setPlaybackRate(rate) {
|
|
1416
1625
|
if (!this.audio) return;
|
|
1417
1626
|
|
|
1418
|
-
const clampedRate =
|
|
1627
|
+
const clampedRate = clamp(rate, 0.5, 2);
|
|
1419
1628
|
this.audio.playbackRate = clampedRate;
|
|
1420
1629
|
this.options.playbackRate = clampedRate;
|
|
1421
1630
|
|
|
@@ -1423,16 +1632,31 @@ export class WaveformPlayer {
|
|
|
1423
1632
|
}
|
|
1424
1633
|
|
|
1425
1634
|
/**
|
|
1426
|
-
*
|
|
1635
|
+
* Tear down the player and release all resources.
|
|
1636
|
+
*
|
|
1637
|
+
* Flags destruction (so in-flight handlers bail), dispatches
|
|
1638
|
+
* `waveformplayer:destroy`, stops playback and the animation loop, aborts
|
|
1639
|
+
* every listener registered on the instance signal, disconnects the resize
|
|
1640
|
+
* observer, removes the window-resize handler, drops the instance from the
|
|
1641
|
+
* static map and `currentlyPlaying`, resets/releases the audio element, and
|
|
1642
|
+
* empties the container.
|
|
1643
|
+
* @fires WaveformPlayer#waveformplayer:destroy
|
|
1427
1644
|
*/
|
|
1428
1645
|
destroy() {
|
|
1429
1646
|
// Set a flag to indicate we're destroying
|
|
1430
1647
|
this.isDestroying = true;
|
|
1431
1648
|
|
|
1649
|
+
// Let listeners (analytics, controllers) release their references
|
|
1650
|
+
// before teardown — the symmetric counterpart to waveformplayer:ready.
|
|
1651
|
+
this._emit('waveformplayer:destroy', {player: this, url: this.options.url});
|
|
1652
|
+
|
|
1432
1653
|
// Stop playback and animations
|
|
1433
1654
|
this.pause();
|
|
1434
1655
|
this.stopSmoothUpdate();
|
|
1435
1656
|
|
|
1657
|
+
// Tear down every document/container/seek listener in one shot.
|
|
1658
|
+
this._ac?.abort();
|
|
1659
|
+
|
|
1436
1660
|
// Disconnect observer
|
|
1437
1661
|
if (this.resizeObserver) {
|
|
1438
1662
|
this.resizeObserver.disconnect();
|
|
@@ -1526,7 +1750,7 @@ export class WaveformPlayer {
|
|
|
1526
1750
|
const result = await generateWaveform(url, samples);
|
|
1527
1751
|
return result.peaks;
|
|
1528
1752
|
} catch (error) {
|
|
1529
|
-
console.error('Failed to generate waveform:', error);
|
|
1753
|
+
console.error('[WaveformPlayer] Failed to generate waveform:', error);
|
|
1530
1754
|
throw error;
|
|
1531
1755
|
}
|
|
1532
1756
|
}
|