@arraypress/waveform-player 1.7.2 → 1.8.1
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 +2208 -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 +553 -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 +417 -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 = {}) {
|
|
@@ -789,11 +890,22 @@ export class WaveformPlayer {
|
|
|
789
890
|
// Clear or update markers
|
|
790
891
|
this.options.markers = options.markers || [];
|
|
791
892
|
|
|
893
|
+
// Reset the waveform to the NEW track's peaks, or null to regenerate
|
|
894
|
+
// from the URL. mergeOptions() above keeps the previous track's
|
|
895
|
+
// this.options.waveform when the caller passes none, and load() does
|
|
896
|
+
// `if (this.options.waveform) setWaveformData(...)` — so without this
|
|
897
|
+
// reset a track loaded without peaks would redraw the PREVIOUS track's
|
|
898
|
+
// waveform (audio changes, visualization doesn't).
|
|
899
|
+
this.options.waveform = options.waveform || null;
|
|
900
|
+
|
|
792
901
|
// Load the new track
|
|
793
902
|
await this.load(url);
|
|
794
903
|
|
|
795
|
-
// Auto-play the new track
|
|
796
|
-
|
|
904
|
+
// Auto-play the new track unless the caller opted out — lets a
|
|
905
|
+
// controller load/restore/enqueue without forcing playback.
|
|
906
|
+
if (options.autoplay !== false) {
|
|
907
|
+
this.play()?.catch(() => {});
|
|
908
|
+
}
|
|
797
909
|
}
|
|
798
910
|
|
|
799
911
|
// ============================================
|
|
@@ -801,7 +913,15 @@ export class WaveformPlayer {
|
|
|
801
913
|
// ============================================
|
|
802
914
|
|
|
803
915
|
/**
|
|
804
|
-
*
|
|
916
|
+
* Normalise externally-supplied waveform data into `this.waveformData` and
|
|
917
|
+
* redraw.
|
|
918
|
+
*
|
|
919
|
+
* Accepts several shapes: a `.json` URL (fetched async; peaks and any
|
|
920
|
+
* embedded `markers` are applied on resolve), a JSON-encoded array string,
|
|
921
|
+
* a comma-separated number string, or a plain number array. Malformed
|
|
922
|
+
* input degrades to an empty array rather than throwing.
|
|
923
|
+
* @param {string|number[]} data - Peaks as an array, a JSON/CSV string, or
|
|
924
|
+
* a URL to a `.json` peaks file.
|
|
805
925
|
* @private
|
|
806
926
|
*/
|
|
807
927
|
setWaveformData(data) {
|
|
@@ -835,7 +955,9 @@ export class WaveformPlayer {
|
|
|
835
955
|
}
|
|
836
956
|
|
|
837
957
|
/**
|
|
838
|
-
*
|
|
958
|
+
* Render the current waveform + progress to the canvas via the shared
|
|
959
|
+
* {@link draw} routine, passing the resolved style and colours. No-op
|
|
960
|
+
* before the context exists or while there is no peak data.
|
|
839
961
|
* @private
|
|
840
962
|
*/
|
|
841
963
|
drawWaveform() {
|
|
@@ -850,7 +972,9 @@ export class WaveformPlayer {
|
|
|
850
972
|
}
|
|
851
973
|
|
|
852
974
|
/**
|
|
853
|
-
*
|
|
975
|
+
* Re-fit the canvas backing store to its parent's width and the configured
|
|
976
|
+
* height, scaled by the device pixel ratio for crisp rendering, then
|
|
977
|
+
* redraw. Guards against running after destruction.
|
|
854
978
|
* @private
|
|
855
979
|
*/
|
|
856
980
|
resizeCanvas() {
|
|
@@ -870,7 +994,15 @@ export class WaveformPlayer {
|
|
|
870
994
|
}
|
|
871
995
|
|
|
872
996
|
/**
|
|
873
|
-
* Render markers
|
|
997
|
+
* Render the configured cue markers as positioned, clickable buttons over
|
|
998
|
+
* the waveform.
|
|
999
|
+
*
|
|
1000
|
+
* Clears any existing markers first, then bails out unless `showMarkers` is
|
|
1001
|
+
* on, markers exist, and a duration is known (via the mode-agnostic
|
|
1002
|
+
* {@link WaveformPlayer#getSeekDuration}). Each marker is placed by its
|
|
1003
|
+
* time-as-percentage, carries a tooltip and ARIA label, and seeks on click
|
|
1004
|
+
* (also starting playback when `playOnSeek` is set and currently paused).
|
|
1005
|
+
* Markers past the track duration are skipped with a warning.
|
|
874
1006
|
* @private
|
|
875
1007
|
*/
|
|
876
1008
|
renderMarkers() {
|
|
@@ -881,20 +1013,22 @@ export class WaveformPlayer {
|
|
|
881
1013
|
|
|
882
1014
|
if (!this.options.showMarkers || !this.options.markers?.length) return;
|
|
883
1015
|
|
|
884
|
-
//
|
|
885
|
-
|
|
1016
|
+
// Duration may come from the <audio> (self mode) or the external
|
|
1017
|
+
// controller (external mode) — use the mode-agnostic accessor.
|
|
1018
|
+
const duration = this.getSeekDuration();
|
|
1019
|
+
if (!duration) {
|
|
886
1020
|
return;
|
|
887
1021
|
}
|
|
888
1022
|
|
|
889
1023
|
// Add each marker
|
|
890
1024
|
this.options.markers.forEach((marker, index) => {
|
|
891
1025
|
// 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 ${
|
|
1026
|
+
if (marker.time > duration) {
|
|
1027
|
+
console.warn(`[WaveformPlayer] Marker "${marker.label}" at ${marker.time}s exceeds audio duration of ${duration}s`);
|
|
894
1028
|
return;
|
|
895
1029
|
}
|
|
896
1030
|
|
|
897
|
-
const position = (marker.time /
|
|
1031
|
+
const position = (marker.time / duration) * 100;
|
|
898
1032
|
|
|
899
1033
|
const markerEl = document.createElement('button');
|
|
900
1034
|
markerEl.className = 'waveform-marker';
|
|
@@ -922,13 +1056,34 @@ export class WaveformPlayer {
|
|
|
922
1056
|
});
|
|
923
1057
|
}
|
|
924
1058
|
|
|
1059
|
+
/**
|
|
1060
|
+
* Highlight the marker at `index` (toggling an `active` class) and clear
|
|
1061
|
+
* the rest. Pass `null` to clear all. Lets an external controller (e.g. a
|
|
1062
|
+
* DJ bar) reflect the current section without reaching into the player's
|
|
1063
|
+
* private marker DOM.
|
|
1064
|
+
* @param {number|null} index - Marker index to activate, or `null` to clear.
|
|
1065
|
+
*/
|
|
1066
|
+
setActiveMarker(index) {
|
|
1067
|
+
if (!this.markersContainer) return;
|
|
1068
|
+
const markers = this.markersContainer.querySelectorAll('.waveform-marker');
|
|
1069
|
+
markers.forEach((el, i) => el.classList.toggle('active', i === index));
|
|
1070
|
+
}
|
|
1071
|
+
|
|
925
1072
|
// ============================================
|
|
926
1073
|
// Event Handlers
|
|
927
1074
|
// ============================================
|
|
928
1075
|
|
|
929
1076
|
/**
|
|
930
|
-
*
|
|
1077
|
+
* Seek to the clicked horizontal position on the waveform canvas.
|
|
1078
|
+
*
|
|
1079
|
+
* Converts the click X into a 0..1 percentage. In external mode it
|
|
1080
|
+
* dispatches a cancelable `waveformplayer:request-seek` event (updating the
|
|
1081
|
+
* local visual optimistically unless the controller vetoes it); in self
|
|
1082
|
+
* mode it seeks the owned `<audio>` via
|
|
1083
|
+
* {@link WaveformPlayer#seekToPercent}.
|
|
1084
|
+
* @param {MouseEvent} event - The canvas click event.
|
|
931
1085
|
* @private
|
|
1086
|
+
* @fires WaveformPlayer#waveformplayer:request-seek
|
|
932
1087
|
*/
|
|
933
1088
|
handleCanvasClick(event) {
|
|
934
1089
|
// In external mode the player has no audio of its own —
|
|
@@ -939,19 +1094,10 @@ export class WaveformPlayer {
|
|
|
939
1094
|
// controller's progress event will reconcile shortly after).
|
|
940
1095
|
const rect = this.canvas.getBoundingClientRect();
|
|
941
1096
|
const x = event.clientX - rect.left;
|
|
942
|
-
const targetPercent =
|
|
1097
|
+
const targetPercent = clamp(x / rect.width);
|
|
943
1098
|
|
|
944
1099
|
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
|
-
}
|
|
1100
|
+
this._requestSeek(targetPercent);
|
|
955
1101
|
return;
|
|
956
1102
|
}
|
|
957
1103
|
|
|
@@ -960,7 +1106,10 @@ export class WaveformPlayer {
|
|
|
960
1106
|
}
|
|
961
1107
|
|
|
962
1108
|
/**
|
|
963
|
-
*
|
|
1109
|
+
* Toggle the loading state: show/hide the spinner overlay and set
|
|
1110
|
+
* `aria-busy` on the accessible seek slider so assistive tech knows the
|
|
1111
|
+
* player is fetching/decoding.
|
|
1112
|
+
* @param {boolean} loading - True while audio is loading.
|
|
964
1113
|
* @private
|
|
965
1114
|
*/
|
|
966
1115
|
setLoading(loading) {
|
|
@@ -968,10 +1117,16 @@ export class WaveformPlayer {
|
|
|
968
1117
|
if (this.loadingEl) {
|
|
969
1118
|
this.loadingEl.style.display = loading ? 'block' : 'none';
|
|
970
1119
|
}
|
|
1120
|
+
// Let assistive tech know the player is busy fetching/decoding.
|
|
1121
|
+
if (this.seekEl) {
|
|
1122
|
+
this.seekEl.setAttribute('aria-busy', loading ? 'true' : 'false');
|
|
1123
|
+
}
|
|
971
1124
|
}
|
|
972
1125
|
|
|
973
1126
|
/**
|
|
974
|
-
*
|
|
1127
|
+
* `loadedmetadata` handler (self mode): write the total-time display, now
|
|
1128
|
+
* that duration is known re-render markers, and publish duration to the
|
|
1129
|
+
* accessible seek slider. No-op during destruction.
|
|
975
1130
|
* @private
|
|
976
1131
|
*/
|
|
977
1132
|
onMetadataLoaded() {
|
|
@@ -988,8 +1143,29 @@ export class WaveformPlayer {
|
|
|
988
1143
|
}
|
|
989
1144
|
|
|
990
1145
|
/**
|
|
991
|
-
*
|
|
1146
|
+
* Reflect play/pause state on the transport button: toggle the `playing`
|
|
1147
|
+
* class and swap the play/pause icon visibility. The single source of
|
|
1148
|
+
* truth shared by `onPlay`, `onPause`, and the external-mode
|
|
1149
|
+
* `setPlayingState` pump so they can't drift. No-op without a button.
|
|
1150
|
+
* @param {boolean} isPlaying - Whether playback is active.
|
|
1151
|
+
* @private
|
|
1152
|
+
*/
|
|
1153
|
+
setPlayButtonState(isPlaying) {
|
|
1154
|
+
if (!this.playBtn) return;
|
|
1155
|
+
this.playBtn.classList.toggle('playing', isPlaying);
|
|
1156
|
+
const playIcon = this.playBtn.querySelector('.waveform-icon-play');
|
|
1157
|
+
const pauseIcon = this.playBtn.querySelector('.waveform-icon-pause');
|
|
1158
|
+
if (playIcon) playIcon.style.display = isPlaying ? 'none' : 'flex';
|
|
1159
|
+
if (pauseIcon) pauseIcon.style.display = isPlaying ? 'flex' : 'none';
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* `play` handler (self mode): set the playing flag, swap the button to its
|
|
1164
|
+
* pause icon, start the smooth progress loop, dispatch
|
|
1165
|
+
* `waveformplayer:play`, and fire the `onPlay` callback. No-op during
|
|
1166
|
+
* destruction.
|
|
992
1167
|
* @private
|
|
1168
|
+
* @fires WaveformPlayer#waveformplayer:play
|
|
993
1169
|
*/
|
|
994
1170
|
onPlay() {
|
|
995
1171
|
// Ignore during destruction
|
|
@@ -997,22 +1173,12 @@ export class WaveformPlayer {
|
|
|
997
1173
|
|
|
998
1174
|
this.isPlaying = true;
|
|
999
1175
|
|
|
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
|
-
}
|
|
1176
|
+
this.setPlayButtonState(true);
|
|
1008
1177
|
|
|
1009
1178
|
this.startSmoothUpdate();
|
|
1010
1179
|
|
|
1011
1180
|
// Dispatch play event
|
|
1012
|
-
this.
|
|
1013
|
-
bubbles: true,
|
|
1014
|
-
detail: {player: this, url: this.options.url}
|
|
1015
|
-
}));
|
|
1181
|
+
this._emit('waveformplayer:play', {player: this, url: this.options.url});
|
|
1016
1182
|
|
|
1017
1183
|
if (this.options.onPlay) {
|
|
1018
1184
|
this.options.onPlay(this);
|
|
@@ -1020,8 +1186,12 @@ export class WaveformPlayer {
|
|
|
1020
1186
|
}
|
|
1021
1187
|
|
|
1022
1188
|
/**
|
|
1023
|
-
*
|
|
1189
|
+
* `pause` handler (self mode): clear the playing flag, swap the button back
|
|
1190
|
+
* to its play icon, stop the smooth progress loop, dispatch
|
|
1191
|
+
* `waveformplayer:pause`, and fire the `onPause` callback. No-op during
|
|
1192
|
+
* destruction.
|
|
1024
1193
|
* @private
|
|
1194
|
+
* @fires WaveformPlayer#waveformplayer:pause
|
|
1025
1195
|
*/
|
|
1026
1196
|
onPause() {
|
|
1027
1197
|
// Ignore during destruction
|
|
@@ -1029,22 +1199,12 @@ export class WaveformPlayer {
|
|
|
1029
1199
|
|
|
1030
1200
|
this.isPlaying = false;
|
|
1031
1201
|
|
|
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
|
-
}
|
|
1202
|
+
this.setPlayButtonState(false);
|
|
1040
1203
|
|
|
1041
1204
|
this.stopSmoothUpdate();
|
|
1042
1205
|
|
|
1043
1206
|
// Dispatch pause event
|
|
1044
|
-
this.
|
|
1045
|
-
bubbles: true,
|
|
1046
|
-
detail: {player: this, url: this.options.url}
|
|
1047
|
-
}));
|
|
1207
|
+
this._emit('waveformplayer:pause', {player: this, url: this.options.url});
|
|
1048
1208
|
|
|
1049
1209
|
if (this.options.onPause) {
|
|
1050
1210
|
this.options.onPause(this);
|
|
@@ -1052,13 +1212,19 @@ export class WaveformPlayer {
|
|
|
1052
1212
|
}
|
|
1053
1213
|
|
|
1054
1214
|
/**
|
|
1055
|
-
*
|
|
1215
|
+
* `ended` handler (self mode): reset progress and `currentTime` to the
|
|
1216
|
+
* start, redraw, reset the time display, dispatch `waveformplayer:ended`
|
|
1217
|
+
* (carrying the final time), run {@link WaveformPlayer#onPause}, and fire
|
|
1218
|
+
* the `onEnd` callback. No-op during destruction.
|
|
1056
1219
|
* @private
|
|
1220
|
+
* @fires WaveformPlayer#waveformplayer:ended
|
|
1057
1221
|
*/
|
|
1058
1222
|
onEnded() {
|
|
1059
1223
|
// Ignore during destruction
|
|
1060
1224
|
if (this.isDestroying) return;
|
|
1061
1225
|
|
|
1226
|
+
const duration = this.audio.duration;
|
|
1227
|
+
|
|
1062
1228
|
this.progress = 0;
|
|
1063
1229
|
this.audio.currentTime = 0;
|
|
1064
1230
|
this.drawWaveform();
|
|
@@ -1068,11 +1234,9 @@ export class WaveformPlayer {
|
|
|
1068
1234
|
this.currentTimeEl.textContent = '0:00';
|
|
1069
1235
|
}
|
|
1070
1236
|
|
|
1071
|
-
// Dispatch ended event
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
detail: {player: this, url: this.options.url}
|
|
1075
|
-
}));
|
|
1237
|
+
// Dispatch ended event — carries the final time so listeners (e.g.
|
|
1238
|
+
// analytics) don't have to reach into player.audio.
|
|
1239
|
+
this._emit('waveformplayer:ended', {player: this, url: this.options.url, currentTime: duration, duration});
|
|
1076
1240
|
|
|
1077
1241
|
this.onPause();
|
|
1078
1242
|
|
|
@@ -1082,14 +1246,18 @@ export class WaveformPlayer {
|
|
|
1082
1246
|
}
|
|
1083
1247
|
|
|
1084
1248
|
/**
|
|
1085
|
-
*
|
|
1249
|
+
* `error` handler: set the error flag, hide the spinner, reveal the error
|
|
1250
|
+
* overlay, dim the canvas, disable the play button, and fire the `onError`
|
|
1251
|
+
* callback. No-op during destruction.
|
|
1252
|
+
* @param {Event|Error} error - The audio error event, or an Error thrown
|
|
1253
|
+
* during loading.
|
|
1086
1254
|
* @private
|
|
1087
1255
|
*/
|
|
1088
1256
|
onError(error) {
|
|
1089
1257
|
// Ignore errors during destruction
|
|
1090
1258
|
if (this.isDestroying) return;
|
|
1091
1259
|
|
|
1092
|
-
console.error('Audio error:', error);
|
|
1260
|
+
console.error('[WaveformPlayer] Audio error:', error);
|
|
1093
1261
|
this.hasError = true;
|
|
1094
1262
|
this.setLoading(false);
|
|
1095
1263
|
|
|
@@ -1115,7 +1283,10 @@ export class WaveformPlayer {
|
|
|
1115
1283
|
// ============================================
|
|
1116
1284
|
|
|
1117
1285
|
/**
|
|
1118
|
-
* Start smooth
|
|
1286
|
+
* Start the `requestAnimationFrame` loop that drives smooth progress
|
|
1287
|
+
* updates while playing (self mode only — external mode is redrawn by
|
|
1288
|
+
* controller {@link WaveformPlayer#setProgress} pushes). Cancels any
|
|
1289
|
+
* existing loop first so it's safe to call repeatedly.
|
|
1119
1290
|
* @private
|
|
1120
1291
|
*/
|
|
1121
1292
|
startSmoothUpdate() {
|
|
@@ -1135,7 +1306,7 @@ export class WaveformPlayer {
|
|
|
1135
1306
|
}
|
|
1136
1307
|
|
|
1137
1308
|
/**
|
|
1138
|
-
*
|
|
1309
|
+
* Cancel the smooth-update animation frame, if one is scheduled.
|
|
1139
1310
|
* @private
|
|
1140
1311
|
*/
|
|
1141
1312
|
stopSmoothUpdate() {
|
|
@@ -1146,8 +1317,15 @@ export class WaveformPlayer {
|
|
|
1146
1317
|
}
|
|
1147
1318
|
|
|
1148
1319
|
/**
|
|
1149
|
-
*
|
|
1320
|
+
* Recompute progress from the owned `<audio>` clock and reflect it
|
|
1321
|
+
* everywhere (self mode only — external mode uses
|
|
1322
|
+
* {@link WaveformPlayer#setProgress}).
|
|
1323
|
+
*
|
|
1324
|
+
* Redraws the canvas when progress moves meaningfully, updates the
|
|
1325
|
+
* current-time display, dispatches `waveformplayer:timeupdate`, fires the
|
|
1326
|
+
* `onTimeUpdate` callback, and refreshes the accessible slider values.
|
|
1150
1327
|
* @private
|
|
1328
|
+
* @fires WaveformPlayer#waveformplayer:timeupdate
|
|
1151
1329
|
*/
|
|
1152
1330
|
updateProgress() {
|
|
1153
1331
|
// Self-mode only — external mode receives progress via
|
|
@@ -1166,15 +1344,13 @@ export class WaveformPlayer {
|
|
|
1166
1344
|
}
|
|
1167
1345
|
|
|
1168
1346
|
// Dispatch timeupdate event
|
|
1169
|
-
this.
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
}
|
|
1177
|
-
}));
|
|
1347
|
+
this._emit('waveformplayer:timeupdate', {
|
|
1348
|
+
player: this,
|
|
1349
|
+
currentTime: this.audio.currentTime,
|
|
1350
|
+
duration: this.audio.duration,
|
|
1351
|
+
progress: this.progress,
|
|
1352
|
+
url: this.options.url
|
|
1353
|
+
});
|
|
1178
1354
|
|
|
1179
1355
|
if (this.options.onTimeUpdate) {
|
|
1180
1356
|
this.options.onTimeUpdate(this.audio.currentTime, this.audio.duration, this);
|
|
@@ -1188,7 +1364,7 @@ export class WaveformPlayer {
|
|
|
1188
1364
|
// ============================================
|
|
1189
1365
|
|
|
1190
1366
|
/**
|
|
1191
|
-
*
|
|
1367
|
+
* Show the detected BPM in the badge, once a value has been detected.
|
|
1192
1368
|
* @private
|
|
1193
1369
|
*/
|
|
1194
1370
|
updateBPMDisplay() {
|
|
@@ -1199,10 +1375,17 @@ export class WaveformPlayer {
|
|
|
1199
1375
|
}
|
|
1200
1376
|
|
|
1201
1377
|
/**
|
|
1202
|
-
*
|
|
1378
|
+
* Sync the speed control's label and the menu's active-option highlight to
|
|
1379
|
+
* the audio element's current `playbackRate`. No-op in external mode (no
|
|
1380
|
+
* owned `<audio>`), which also avoids reading `playbackRate` before the
|
|
1381
|
+
* element exists.
|
|
1203
1382
|
* @private
|
|
1204
1383
|
*/
|
|
1205
1384
|
updateSpeedUI() {
|
|
1385
|
+
// External mode owns no <audio>; nothing to reflect (and reading
|
|
1386
|
+
// this.audio.playbackRate here would throw during construction).
|
|
1387
|
+
if (!this.audio) return;
|
|
1388
|
+
|
|
1206
1389
|
const speedValue = this.container.querySelector('.speed-value');
|
|
1207
1390
|
if (speedValue) {
|
|
1208
1391
|
const rate = this.audio.playbackRate;
|
|
@@ -1233,7 +1416,12 @@ export class WaveformPlayer {
|
|
|
1233
1416
|
* setPlayingState() / setProgress(). Calling preventDefault() on
|
|
1234
1417
|
* the event lets the controller veto the play (state is unchanged).
|
|
1235
1418
|
*
|
|
1236
|
-
*
|
|
1419
|
+
* When `singlePlay` is enabled, any other currently-playing instance is
|
|
1420
|
+
* paused first.
|
|
1421
|
+
*
|
|
1422
|
+
* @return {Promise|undefined} The promise from `HTMLMediaElement.play()` in
|
|
1423
|
+
* self mode; `undefined` in external mode.
|
|
1424
|
+
* @fires WaveformPlayer#waveformplayer:request-play
|
|
1237
1425
|
*/
|
|
1238
1426
|
play() {
|
|
1239
1427
|
if (this.options.singlePlay && WaveformPlayer.currentlyPlaying &&
|
|
@@ -1242,12 +1430,7 @@ export class WaveformPlayer {
|
|
|
1242
1430
|
}
|
|
1243
1431
|
|
|
1244
1432
|
if (this.options.audioMode === 'external') {
|
|
1245
|
-
const evt =
|
|
1246
|
-
bubbles: true,
|
|
1247
|
-
cancelable: true,
|
|
1248
|
-
detail: this._buildTrackDetail()
|
|
1249
|
-
});
|
|
1250
|
-
this.container.dispatchEvent(evt);
|
|
1433
|
+
const evt = this._emit('waveformplayer:request-play', this._buildTrackDetail(), true);
|
|
1251
1434
|
// If the controller cancels (preventDefault), don't claim
|
|
1252
1435
|
// "currentlyPlaying" — the controller didn't accept the play.
|
|
1253
1436
|
if (!evt.defaultPrevented) {
|
|
@@ -1265,17 +1448,15 @@ export class WaveformPlayer {
|
|
|
1265
1448
|
*
|
|
1266
1449
|
* In `audioMode: 'external'`, dispatches `waveformplayer:request-pause`
|
|
1267
1450
|
* (cancelable) and does NOT touch any audio element. See play().
|
|
1451
|
+
*
|
|
1452
|
+
* @fires WaveformPlayer#waveformplayer:request-pause
|
|
1268
1453
|
*/
|
|
1269
1454
|
pause() {
|
|
1270
1455
|
if (WaveformPlayer.currentlyPlaying === this) {
|
|
1271
1456
|
WaveformPlayer.currentlyPlaying = null;
|
|
1272
1457
|
}
|
|
1273
1458
|
if (this.options.audioMode === 'external') {
|
|
1274
|
-
this.
|
|
1275
|
-
bubbles: true,
|
|
1276
|
-
cancelable: true,
|
|
1277
|
-
detail: this._buildTrackDetail()
|
|
1278
|
-
}));
|
|
1459
|
+
this._emit('waveformplayer:request-pause', this._buildTrackDetail(), true);
|
|
1279
1460
|
return;
|
|
1280
1461
|
}
|
|
1281
1462
|
this.audio.pause();
|
|
@@ -1295,8 +1476,12 @@ export class WaveformPlayer {
|
|
|
1295
1476
|
url: this.options.url,
|
|
1296
1477
|
title: this.options.title,
|
|
1297
1478
|
subtitle: this.options.subtitle,
|
|
1298
|
-
artist
|
|
1479
|
+
// Core has no separate `artist` option; mirror subtitle so the
|
|
1480
|
+
// published event detail is self-consistent for controllers.
|
|
1481
|
+
artist: this.options.artist || this.options.subtitle,
|
|
1299
1482
|
artwork: this.options.artwork,
|
|
1483
|
+
markers: this.options.markers,
|
|
1484
|
+
waveform: this.options.waveform,
|
|
1300
1485
|
id: this.id,
|
|
1301
1486
|
player: this
|
|
1302
1487
|
};
|
|
@@ -1307,31 +1492,26 @@ export class WaveformPlayer {
|
|
|
1307
1492
|
* touching audio. Mirrors what onPlay()/onPause() do but skips the
|
|
1308
1493
|
* audio-element interactions. Safe to call repeatedly — idempotent.
|
|
1309
1494
|
*
|
|
1310
|
-
*
|
|
1495
|
+
* Only dispatches `waveformplayer:play`/`waveformplayer:pause` (and runs
|
|
1496
|
+
* the matching callback) on an actual transition, starting/stopping the
|
|
1497
|
+
* smooth-update loop accordingly.
|
|
1498
|
+
*
|
|
1499
|
+
* @param {boolean} playing - True to enter the playing state, false to
|
|
1500
|
+
* enter the paused state.
|
|
1501
|
+
* @fires WaveformPlayer#waveformplayer:play
|
|
1502
|
+
* @fires WaveformPlayer#waveformplayer:pause
|
|
1311
1503
|
*/
|
|
1312
1504
|
setPlayingState(playing) {
|
|
1313
1505
|
const wasPlaying = this.isPlaying;
|
|
1314
1506
|
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
|
-
}
|
|
1507
|
+
this.setPlayButtonState(this.isPlaying);
|
|
1322
1508
|
if (this.isPlaying && !wasPlaying) {
|
|
1323
1509
|
this.startSmoothUpdate?.();
|
|
1324
|
-
this.
|
|
1325
|
-
bubbles: true,
|
|
1326
|
-
detail: {player: this, url: this.options.url}
|
|
1327
|
-
}));
|
|
1510
|
+
this._emit('waveformplayer:play', {player: this, url: this.options.url});
|
|
1328
1511
|
if (this.options.onPlay) this.options.onPlay(this);
|
|
1329
1512
|
} else if (!this.isPlaying && wasPlaying) {
|
|
1330
1513
|
this.stopSmoothUpdate?.();
|
|
1331
|
-
this.
|
|
1332
|
-
bubbles: true,
|
|
1333
|
-
detail: {player: this, url: this.options.url}
|
|
1334
|
-
}));
|
|
1514
|
+
this._emit('waveformplayer:pause', {player: this, url: this.options.url});
|
|
1335
1515
|
if (this.options.onPause) this.options.onPause(this);
|
|
1336
1516
|
}
|
|
1337
1517
|
}
|
|
@@ -1341,32 +1521,58 @@ export class WaveformPlayer {
|
|
|
1341
1521
|
* from an external clock (e.g. WaveformBar's audio element's
|
|
1342
1522
|
* timeupdate). Drives the canvas redraw + the time displays.
|
|
1343
1523
|
*
|
|
1344
|
-
*
|
|
1345
|
-
*
|
|
1524
|
+
* Redraws the canvas, updates the current/total time displays, stores the
|
|
1525
|
+
* external duration for the accessible slider, dispatches
|
|
1526
|
+
* `waveformplayer:timeupdate`, runs `onTimeUpdate`, and synthesizes a
|
|
1527
|
+
* one-shot `waveformplayer:ended` (with `onEnd`) when progress reaches the
|
|
1528
|
+
* end. No-op for a non-positive duration.
|
|
1529
|
+
*
|
|
1530
|
+
* @param {number} currentTime - Current playback position in seconds.
|
|
1531
|
+
* @param {number} duration - Total track duration in seconds.
|
|
1532
|
+
* @fires WaveformPlayer#waveformplayer:timeupdate
|
|
1533
|
+
* @fires WaveformPlayer#waveformplayer:ended
|
|
1346
1534
|
*/
|
|
1347
1535
|
setProgress(currentTime, duration) {
|
|
1348
1536
|
if (!duration || duration <= 0) return;
|
|
1349
|
-
this.progress =
|
|
1537
|
+
this.progress = clamp(currentTime / duration);
|
|
1350
1538
|
// Mirror the existing display update code so callers don't have
|
|
1351
1539
|
// to know which DOM elements live where.
|
|
1352
1540
|
if (this.currentTimeEl) this.currentTimeEl.textContent = formatTime(currentTime);
|
|
1353
|
-
|
|
1541
|
+
// Publish the duration unconditionally — the accessible seek slider
|
|
1542
|
+
// and keyboard seeking read getSeekDuration()/_extDuration even when
|
|
1543
|
+
// there's no time display to update.
|
|
1544
|
+
this._extDuration = duration;
|
|
1545
|
+
if (this.totalTimeEl && (!this.totalTimeEl.dataset._extSet || this.totalTimeEl.dataset._extDur !== String(duration))) {
|
|
1354
1546
|
this.totalTimeEl.textContent = formatTime(duration);
|
|
1355
1547
|
this.totalTimeEl.dataset._extSet = '1';
|
|
1356
|
-
this.
|
|
1548
|
+
this.totalTimeEl.dataset._extDur = String(duration);
|
|
1357
1549
|
}
|
|
1358
1550
|
this.drawWaveform?.();
|
|
1359
|
-
this.
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
if (this.options.onTimeUpdate) this.options.onTimeUpdate(
|
|
1551
|
+
this._emit('waveformplayer:timeupdate', {player: this, currentTime, duration, progress: this.progress, url: this.options.url});
|
|
1552
|
+
// Same (currentTime, duration, player) signature as self mode — the
|
|
1553
|
+
// arg order used to be swapped here, which made one shared handler
|
|
1554
|
+
// impossible across audioModes.
|
|
1555
|
+
if (this.options.onTimeUpdate) this.options.onTimeUpdate(currentTime, duration, this);
|
|
1556
|
+
|
|
1557
|
+
// External mode has no <audio> 'ended' event — synthesize one when the
|
|
1558
|
+
// controller's progress reaches the end (fires once per playthrough).
|
|
1559
|
+
if (this.progress >= 1) {
|
|
1560
|
+
if (!this._extEnded) {
|
|
1561
|
+
this._extEnded = true;
|
|
1562
|
+
this._emit('waveformplayer:ended', {player: this, url: this.options.url, currentTime: duration, duration});
|
|
1563
|
+
if (this.options.onEnd) this.options.onEnd(this);
|
|
1564
|
+
}
|
|
1565
|
+
} else {
|
|
1566
|
+
this._extEnded = false;
|
|
1567
|
+
}
|
|
1364
1568
|
|
|
1365
1569
|
this.updateSeekAccessibility();
|
|
1366
1570
|
}
|
|
1367
1571
|
|
|
1368
1572
|
/**
|
|
1369
|
-
* Toggle play
|
|
1573
|
+
* Toggle between play and pause based on the current `isPlaying` state.
|
|
1574
|
+
* Works in both audio modes (in external mode it routes through the
|
|
1575
|
+
* request-play/pause events).
|
|
1370
1576
|
*/
|
|
1371
1577
|
togglePlay() {
|
|
1372
1578
|
if (this.isPlaying) {
|
|
@@ -1377,45 +1583,56 @@ export class WaveformPlayer {
|
|
|
1377
1583
|
}
|
|
1378
1584
|
|
|
1379
1585
|
/**
|
|
1380
|
-
* Seek to time
|
|
1381
|
-
*
|
|
1586
|
+
* Seek the owned `<audio>` element to an absolute time, clamped to
|
|
1587
|
+
* `[0, duration]`, and refresh progress. Self mode only — a no-op when
|
|
1588
|
+
* there is no audio element or duration. External-mode keyboard/click
|
|
1589
|
+
* seeks go through {@link WaveformPlayer#seekToSeconds} instead.
|
|
1590
|
+
* @param {number} seconds - Target time in seconds.
|
|
1382
1591
|
*/
|
|
1383
1592
|
seekTo(seconds) {
|
|
1384
1593
|
if (this.audio && this.audio.duration) {
|
|
1385
|
-
this.audio.currentTime =
|
|
1594
|
+
this.audio.currentTime = clamp(seconds, 0, this.audio.duration);
|
|
1386
1595
|
this.updateProgress();
|
|
1387
1596
|
}
|
|
1388
1597
|
}
|
|
1389
1598
|
|
|
1390
1599
|
/**
|
|
1391
|
-
* Seek to
|
|
1392
|
-
*
|
|
1600
|
+
* Seek the owned `<audio>` element to a fraction of the track, clamped to
|
|
1601
|
+
* `[0, 1]`, and refresh progress. Self mode only — a no-op without an audio
|
|
1602
|
+
* element or duration.
|
|
1603
|
+
* @param {number} percent - Position as a fraction from 0 to 1.
|
|
1393
1604
|
*/
|
|
1394
1605
|
seekToPercent(percent) {
|
|
1395
1606
|
if (this.audio && this.audio.duration) {
|
|
1396
|
-
this.audio.currentTime = this.audio.duration *
|
|
1607
|
+
this.audio.currentTime = this.audio.duration * clamp(percent);
|
|
1397
1608
|
this.updateProgress();
|
|
1398
1609
|
}
|
|
1399
1610
|
}
|
|
1400
1611
|
|
|
1401
1612
|
/**
|
|
1402
|
-
* Set volume
|
|
1403
|
-
*
|
|
1613
|
+
* Set the owned `<audio>` element's volume, clamped to `[0, 1]`. Self mode
|
|
1614
|
+
* only — a no-op in external mode where the controller owns volume.
|
|
1615
|
+
* @param {number} volume - Volume from 0 (silent) to 1 (full).
|
|
1404
1616
|
*/
|
|
1405
1617
|
setVolume(volume) {
|
|
1406
|
-
|
|
1407
|
-
|
|
1618
|
+
// Coerce + guard: a non-finite value (e.g. from a bad config or stale
|
|
1619
|
+
// storage) must not propagate NaN into audio.volume (which throws).
|
|
1620
|
+
const v = Number(volume);
|
|
1621
|
+
if (this.audio && Number.isFinite(v)) {
|
|
1622
|
+
this.audio.volume = clamp(v);
|
|
1408
1623
|
}
|
|
1409
1624
|
}
|
|
1410
1625
|
|
|
1411
1626
|
/**
|
|
1412
|
-
* Set playback rate
|
|
1413
|
-
*
|
|
1627
|
+
* Set the owned `<audio>` element's playback rate (clamped to 0.5–2),
|
|
1628
|
+
* persist it onto `this.options.playbackRate`, and refresh the speed UI.
|
|
1629
|
+
* Self mode only — a no-op in external mode.
|
|
1630
|
+
* @param {number} rate - Desired playback rate; clamped to the 0.5–2 range.
|
|
1414
1631
|
*/
|
|
1415
1632
|
setPlaybackRate(rate) {
|
|
1416
1633
|
if (!this.audio) return;
|
|
1417
1634
|
|
|
1418
|
-
const clampedRate =
|
|
1635
|
+
const clampedRate = clamp(rate, 0.5, 2);
|
|
1419
1636
|
this.audio.playbackRate = clampedRate;
|
|
1420
1637
|
this.options.playbackRate = clampedRate;
|
|
1421
1638
|
|
|
@@ -1423,16 +1640,31 @@ export class WaveformPlayer {
|
|
|
1423
1640
|
}
|
|
1424
1641
|
|
|
1425
1642
|
/**
|
|
1426
|
-
*
|
|
1643
|
+
* Tear down the player and release all resources.
|
|
1644
|
+
*
|
|
1645
|
+
* Flags destruction (so in-flight handlers bail), dispatches
|
|
1646
|
+
* `waveformplayer:destroy`, stops playback and the animation loop, aborts
|
|
1647
|
+
* every listener registered on the instance signal, disconnects the resize
|
|
1648
|
+
* observer, removes the window-resize handler, drops the instance from the
|
|
1649
|
+
* static map and `currentlyPlaying`, resets/releases the audio element, and
|
|
1650
|
+
* empties the container.
|
|
1651
|
+
* @fires WaveformPlayer#waveformplayer:destroy
|
|
1427
1652
|
*/
|
|
1428
1653
|
destroy() {
|
|
1429
1654
|
// Set a flag to indicate we're destroying
|
|
1430
1655
|
this.isDestroying = true;
|
|
1431
1656
|
|
|
1657
|
+
// Let listeners (analytics, controllers) release their references
|
|
1658
|
+
// before teardown — the symmetric counterpart to waveformplayer:ready.
|
|
1659
|
+
this._emit('waveformplayer:destroy', {player: this, url: this.options.url});
|
|
1660
|
+
|
|
1432
1661
|
// Stop playback and animations
|
|
1433
1662
|
this.pause();
|
|
1434
1663
|
this.stopSmoothUpdate();
|
|
1435
1664
|
|
|
1665
|
+
// Tear down every document/container/seek listener in one shot.
|
|
1666
|
+
this._ac?.abort();
|
|
1667
|
+
|
|
1436
1668
|
// Disconnect observer
|
|
1437
1669
|
if (this.resizeObserver) {
|
|
1438
1670
|
this.resizeObserver.disconnect();
|
|
@@ -1526,7 +1758,7 @@ export class WaveformPlayer {
|
|
|
1526
1758
|
const result = await generateWaveform(url, samples);
|
|
1527
1759
|
return result.peaks;
|
|
1528
1760
|
} catch (error) {
|
|
1529
|
-
console.error('Failed to generate waveform:', error);
|
|
1761
|
+
console.error('[WaveformPlayer] Failed to generate waveform:', error);
|
|
1530
1762
|
throw error;
|
|
1531
1763
|
}
|
|
1532
1764
|
}
|