@arraypress/waveform-player 1.7.1 → 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 +179 -366
- 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 +676 -260
- 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 +26 -3
- package/src/js/audio.js +61 -25
- package/src/js/bpm.js +26 -5
- package/src/js/core.js +557 -170
- package/src/js/drawing.js +208 -44
- package/src/js/index.js +56 -11
- package/src/js/themes.js +95 -47
- package/src/js/utils.js +231 -65
package/src/js/core.js
CHANGED
|
@@ -11,11 +11,17 @@ 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';
|
|
18
20
|
|
|
21
|
+
// Keyboard seek steps (seconds) for the accessible slider.
|
|
22
|
+
const SEEK_STEP_SECONDS = 5;
|
|
23
|
+
const SEEK_PAGE_SECONDS = 10;
|
|
24
|
+
|
|
19
25
|
/**
|
|
20
26
|
* WaveformPlayer - Modern audio player with waveform visualization
|
|
21
27
|
* @class
|
|
@@ -28,9 +34,21 @@ export class WaveformPlayer {
|
|
|
28
34
|
static currentlyPlaying = null;
|
|
29
35
|
|
|
30
36
|
/**
|
|
31
|
-
* Create a new WaveformPlayer instance
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
34
52
|
*/
|
|
35
53
|
constructor(container, options = {}) {
|
|
36
54
|
// Resolve container
|
|
@@ -39,14 +57,20 @@ export class WaveformPlayer {
|
|
|
39
57
|
: container;
|
|
40
58
|
|
|
41
59
|
if (!this.container) {
|
|
42
|
-
throw new Error('WaveformPlayer
|
|
60
|
+
throw new Error('[WaveformPlayer] Container element not found');
|
|
43
61
|
}
|
|
44
62
|
|
|
45
63
|
// Parse data attributes if present
|
|
46
64
|
const dataOptions = parseDataAttributes(this.container);
|
|
47
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
|
+
|
|
48
72
|
// Merge options: defaults < data attributes < constructor options
|
|
49
|
-
this.options = mergeOptions(DEFAULT_OPTIONS, dataOptions,
|
|
73
|
+
this.options = mergeOptions(DEFAULT_OPTIONS, dataOptions, userOptions);
|
|
50
74
|
|
|
51
75
|
// Apply color preset (auto-detect if not specified)
|
|
52
76
|
const preset = getColorPreset(this.options.colorPreset);
|
|
@@ -81,6 +105,11 @@ export class WaveformPlayer {
|
|
|
81
105
|
this.updateTimer = null;
|
|
82
106
|
this.resizeObserver = null;
|
|
83
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
|
+
|
|
84
113
|
// Generate unique ID
|
|
85
114
|
this.id = this.container.id || generateId(this.options.url);
|
|
86
115
|
|
|
@@ -92,19 +121,53 @@ export class WaveformPlayer {
|
|
|
92
121
|
|
|
93
122
|
// Dispatch ready event after initialization
|
|
94
123
|
setTimeout(() => {
|
|
95
|
-
this.
|
|
96
|
-
bubbles: true,
|
|
97
|
-
detail: {player: this, url: this.options.url}
|
|
98
|
-
}));
|
|
124
|
+
this._emit('waveformplayer:ready', {player: this, url: this.options.url});
|
|
99
125
|
}, 100);
|
|
100
126
|
}
|
|
101
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
|
+
|
|
102
162
|
// ============================================
|
|
103
163
|
// Initialization
|
|
104
164
|
// ============================================
|
|
105
165
|
|
|
106
166
|
/**
|
|
107
|
-
* 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.
|
|
108
171
|
* @private
|
|
109
172
|
*/
|
|
110
173
|
init() {
|
|
@@ -112,6 +175,7 @@ export class WaveformPlayer {
|
|
|
112
175
|
this.createAudio();
|
|
113
176
|
this.initPlaybackSpeed();
|
|
114
177
|
this.initKeyboardControls();
|
|
178
|
+
this.initSeekControl();
|
|
115
179
|
this.bindEvents();
|
|
116
180
|
this.setupResizeObserver();
|
|
117
181
|
|
|
@@ -123,17 +187,24 @@ export class WaveformPlayer {
|
|
|
123
187
|
if (this.options.url) {
|
|
124
188
|
this.load(this.options.url).then(() => {
|
|
125
189
|
if (this.options.autoplay) {
|
|
126
|
-
this.play();
|
|
190
|
+
this.play()?.catch(() => {});
|
|
127
191
|
}
|
|
128
192
|
}).catch(error => {
|
|
129
|
-
console.error('Failed to load audio:', error);
|
|
193
|
+
console.error('[WaveformPlayer] Failed to load audio:', error);
|
|
130
194
|
});
|
|
131
195
|
}
|
|
132
196
|
});
|
|
133
197
|
}
|
|
134
198
|
|
|
135
199
|
/**
|
|
136
|
-
*
|
|
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.
|
|
137
208
|
* @private
|
|
138
209
|
*/
|
|
139
210
|
createDOM() {
|
|
@@ -218,8 +289,8 @@ export class WaveformPlayer {
|
|
|
218
289
|
<canvas></canvas>
|
|
219
290
|
<div class="waveform-markers"></div>
|
|
220
291
|
<div class="waveform-loading" style="display:none;"></div>
|
|
221
|
-
<div class="waveform-error" style="display:none;">
|
|
222
|
-
<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>
|
|
223
294
|
</div>
|
|
224
295
|
</div>
|
|
225
296
|
</div>
|
|
@@ -275,7 +346,9 @@ export class WaveformPlayer {
|
|
|
275
346
|
// ============================================
|
|
276
347
|
|
|
277
348
|
/**
|
|
278
|
-
*
|
|
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}.
|
|
279
352
|
* @private
|
|
280
353
|
*/
|
|
281
354
|
initPlaybackSpeed() {
|
|
@@ -295,7 +368,11 @@ export class WaveformPlayer {
|
|
|
295
368
|
}
|
|
296
369
|
|
|
297
370
|
/**
|
|
298
|
-
*
|
|
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.
|
|
299
376
|
* @private
|
|
300
377
|
*/
|
|
301
378
|
initSpeedControls() {
|
|
@@ -308,12 +385,12 @@ export class WaveformPlayer {
|
|
|
308
385
|
speedBtn.addEventListener('click', (e) => {
|
|
309
386
|
e.stopPropagation();
|
|
310
387
|
speedMenu.style.display = speedMenu.style.display === 'none' ? 'block' : 'none';
|
|
311
|
-
});
|
|
388
|
+
}, {signal: this._ac.signal});
|
|
312
389
|
|
|
313
390
|
// Close menu when clicking outside
|
|
314
391
|
document.addEventListener('click', () => {
|
|
315
392
|
speedMenu.style.display = 'none';
|
|
316
|
-
});
|
|
393
|
+
}, {signal: this._ac.signal});
|
|
317
394
|
|
|
318
395
|
// Handle speed selection
|
|
319
396
|
speedMenu.addEventListener('click', (e) => {
|
|
@@ -323,14 +400,21 @@ export class WaveformPlayer {
|
|
|
323
400
|
this.setPlaybackRate(rate);
|
|
324
401
|
speedMenu.style.display = 'none';
|
|
325
402
|
}
|
|
326
|
-
});
|
|
403
|
+
}, {signal: this._ac.signal});
|
|
327
404
|
|
|
328
405
|
// Set initial UI state
|
|
329
406
|
this.updateSpeedUI();
|
|
330
407
|
}
|
|
331
408
|
|
|
332
409
|
/**
|
|
333
|
-
*
|
|
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.
|
|
334
418
|
* @private
|
|
335
419
|
*/
|
|
336
420
|
initKeyboardControls() {
|
|
@@ -348,7 +432,7 @@ export class WaveformPlayer {
|
|
|
348
432
|
// Make this one focusable
|
|
349
433
|
this.container.setAttribute('tabindex', '0');
|
|
350
434
|
this.container.focus();
|
|
351
|
-
});
|
|
435
|
+
}, {signal: this._ac.signal});
|
|
352
436
|
|
|
353
437
|
// Keyboard events. In external mode `this.audio` is null, so
|
|
354
438
|
// seek/volume/mute keys are no-ops (the external controller
|
|
@@ -375,10 +459,10 @@ export class WaveformPlayer {
|
|
|
375
459
|
' ': () => this.togglePlay(),
|
|
376
460
|
};
|
|
377
461
|
if (hasAudio) {
|
|
378
|
-
actions['ArrowLeft'] = () => this.seekTo(
|
|
379
|
-
actions['ArrowRight'] = () => this.seekTo(
|
|
380
|
-
actions['ArrowUp'] = () => this.setVolume(
|
|
381
|
-
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));
|
|
382
466
|
actions['m'] = actions['M'] = () => this.audio.muted = !this.audio.muted;
|
|
383
467
|
}
|
|
384
468
|
|
|
@@ -386,7 +470,155 @@ export class WaveformPlayer {
|
|
|
386
470
|
e.preventDefault();
|
|
387
471
|
actions[key]();
|
|
388
472
|
}
|
|
389
|
-
});
|
|
473
|
+
}, {signal: this._ac.signal});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Expose the waveform as an accessible, keyboard-operable slider.
|
|
478
|
+
*
|
|
479
|
+
* Adds role="slider" + ARIA value attributes to the waveform surface,
|
|
480
|
+
* makes it focusable in the tab order, and handles the standard slider
|
|
481
|
+
* keys (arrows, Page Up/Down, Home/End) to seek. Works in both self and
|
|
482
|
+
* external audio modes. Opt out with `accessibleSeek: false`.
|
|
483
|
+
* @private
|
|
484
|
+
*/
|
|
485
|
+
initSeekControl() {
|
|
486
|
+
if (!this.options.accessibleSeek) return;
|
|
487
|
+
|
|
488
|
+
this.seekEl = this.container.querySelector('.waveform-container');
|
|
489
|
+
if (!this.seekEl) return;
|
|
490
|
+
|
|
491
|
+
this.seekEl.setAttribute('role', 'slider');
|
|
492
|
+
this.seekEl.setAttribute('tabindex', '0');
|
|
493
|
+
this.seekEl.setAttribute('aria-valuemin', '0');
|
|
494
|
+
this.applySeekLabel();
|
|
495
|
+
this.updateSeekAccessibility();
|
|
496
|
+
|
|
497
|
+
this.seekEl.addEventListener('keydown', (e) => {
|
|
498
|
+
const duration = this.getSeekDuration();
|
|
499
|
+
if (!duration) return;
|
|
500
|
+
|
|
501
|
+
const current = this.getSeekCurrentTime();
|
|
502
|
+
let target;
|
|
503
|
+
switch (e.key) {
|
|
504
|
+
case 'ArrowLeft':
|
|
505
|
+
case 'ArrowDown':
|
|
506
|
+
target = current - SEEK_STEP_SECONDS;
|
|
507
|
+
break;
|
|
508
|
+
case 'ArrowRight':
|
|
509
|
+
case 'ArrowUp':
|
|
510
|
+
target = current + SEEK_STEP_SECONDS;
|
|
511
|
+
break;
|
|
512
|
+
case 'PageDown':
|
|
513
|
+
target = current - SEEK_PAGE_SECONDS;
|
|
514
|
+
break;
|
|
515
|
+
case 'PageUp':
|
|
516
|
+
target = current + SEEK_PAGE_SECONDS;
|
|
517
|
+
break;
|
|
518
|
+
case 'Home':
|
|
519
|
+
target = 0;
|
|
520
|
+
break;
|
|
521
|
+
case 'End':
|
|
522
|
+
target = duration;
|
|
523
|
+
break;
|
|
524
|
+
default:
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Prevent page scroll and stop the container-level keydown
|
|
529
|
+
// handler from also seeking (it would double-fire / change
|
|
530
|
+
// volume on the vertical arrows).
|
|
531
|
+
e.preventDefault();
|
|
532
|
+
e.stopPropagation();
|
|
533
|
+
this.seekToSeconds(target);
|
|
534
|
+
}, {signal: this._ac.signal});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Total seekable duration in seconds, regardless of audio mode.
|
|
539
|
+
* @returns {number}
|
|
540
|
+
* @private
|
|
541
|
+
*/
|
|
542
|
+
getSeekDuration() {
|
|
543
|
+
if (this.options.audioMode === 'external') {
|
|
544
|
+
return this._extDuration || 0;
|
|
545
|
+
}
|
|
546
|
+
return this.audio && Number.isFinite(this.audio.duration)
|
|
547
|
+
? this.audio.duration
|
|
548
|
+
: 0;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Current playback position in seconds, regardless of audio mode.
|
|
553
|
+
* @returns {number}
|
|
554
|
+
* @private
|
|
555
|
+
*/
|
|
556
|
+
getSeekCurrentTime() {
|
|
557
|
+
if (this.options.audioMode === 'external') {
|
|
558
|
+
return this.progress * (this._extDuration || 0);
|
|
559
|
+
}
|
|
560
|
+
return this.audio && Number.isFinite(this.audio.currentTime)
|
|
561
|
+
? this.audio.currentTime
|
|
562
|
+
: 0;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Seek the slider to an absolute time, clamped to the track length.
|
|
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.
|
|
573
|
+
* @param {number} seconds - Target time in seconds.
|
|
574
|
+
* @private
|
|
575
|
+
* @fires WaveformPlayer#waveformplayer:request-seek
|
|
576
|
+
*/
|
|
577
|
+
seekToSeconds(seconds) {
|
|
578
|
+
const duration = this.getSeekDuration();
|
|
579
|
+
if (!duration) return;
|
|
580
|
+
|
|
581
|
+
const clamped = clamp(seconds, 0, duration);
|
|
582
|
+
|
|
583
|
+
if (this.options.audioMode === 'external') {
|
|
584
|
+
this._requestSeek(clamped / duration);
|
|
585
|
+
this.updateSeekAccessibility();
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// seekTo() calls updateProgress(), which refreshes the ARIA values.
|
|
590
|
+
this.seekTo(clamped);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Set the slider's accessible name from `seekLabel`, falling back to the
|
|
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.
|
|
598
|
+
* @private
|
|
599
|
+
*/
|
|
600
|
+
applySeekLabel(title = this.options.title) {
|
|
601
|
+
if (!this.seekEl) return;
|
|
602
|
+
const label = this.options.seekLabel || title || 'Seek';
|
|
603
|
+
this.seekEl.setAttribute('aria-label', label);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Keep the slider's ARIA value attributes in sync with playback.
|
|
608
|
+
* @private
|
|
609
|
+
*/
|
|
610
|
+
updateSeekAccessibility() {
|
|
611
|
+
if (!this.seekEl) return;
|
|
612
|
+
|
|
613
|
+
const duration = this.getSeekDuration();
|
|
614
|
+
const current = Math.min(this.getSeekCurrentTime(), duration);
|
|
615
|
+
|
|
616
|
+
this.seekEl.setAttribute('aria-valuemax', String(Math.round(duration)));
|
|
617
|
+
this.seekEl.setAttribute('aria-valuenow', String(Math.round(current)));
|
|
618
|
+
this.seekEl.setAttribute(
|
|
619
|
+
'aria-valuetext',
|
|
620
|
+
`${formatTime(current)} of ${formatTime(duration)}`
|
|
621
|
+
);
|
|
390
622
|
}
|
|
391
623
|
|
|
392
624
|
/**
|
|
@@ -414,10 +646,10 @@ export class WaveformPlayer {
|
|
|
414
646
|
navigator.mediaSession.setActionHandler('play', () => this.play());
|
|
415
647
|
navigator.mediaSession.setActionHandler('pause', () => this.pause());
|
|
416
648
|
navigator.mediaSession.setActionHandler('seekbackward', () => {
|
|
417
|
-
this.seekTo(
|
|
649
|
+
this.seekTo(clamp(this.audio.currentTime - 10, 0, this.audio.duration));
|
|
418
650
|
});
|
|
419
651
|
navigator.mediaSession.setActionHandler('seekforward', () => {
|
|
420
|
-
this.seekTo(
|
|
652
|
+
this.seekTo(clamp(this.audio.currentTime + 10, 0, this.audio.duration));
|
|
421
653
|
});
|
|
422
654
|
navigator.mediaSession.setActionHandler('seekto', (details) => {
|
|
423
655
|
if (details.seekTime !== null) {
|
|
@@ -431,7 +663,10 @@ export class WaveformPlayer {
|
|
|
431
663
|
// ============================================
|
|
432
664
|
|
|
433
665
|
/**
|
|
434
|
-
* 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.
|
|
435
670
|
* @private
|
|
436
671
|
*/
|
|
437
672
|
bindEvents() {
|
|
@@ -467,7 +702,8 @@ export class WaveformPlayer {
|
|
|
467
702
|
}
|
|
468
703
|
|
|
469
704
|
/**
|
|
470
|
-
*
|
|
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.
|
|
471
707
|
* @private
|
|
472
708
|
*/
|
|
473
709
|
setupResizeObserver() {
|
|
@@ -487,9 +723,20 @@ export class WaveformPlayer {
|
|
|
487
723
|
// ============================================
|
|
488
724
|
|
|
489
725
|
/**
|
|
490
|
-
* Load audio
|
|
491
|
-
*
|
|
492
|
-
*
|
|
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}).
|
|
493
740
|
*/
|
|
494
741
|
async load(url) {
|
|
495
742
|
try {
|
|
@@ -528,6 +775,8 @@ export class WaveformPlayer {
|
|
|
528
775
|
if (this.titleEl) {
|
|
529
776
|
this.titleEl.textContent = title;
|
|
530
777
|
}
|
|
778
|
+
// Keep the seek slider's accessible name in sync with the track.
|
|
779
|
+
this.applySeekLabel(title);
|
|
531
780
|
|
|
532
781
|
// Load or generate waveform
|
|
533
782
|
if (this.options.waveform) {
|
|
@@ -544,7 +793,7 @@ export class WaveformPlayer {
|
|
|
544
793
|
this.updateBPMDisplay();
|
|
545
794
|
}
|
|
546
795
|
} catch (error) {
|
|
547
|
-
console.warn('Using placeholder waveform:', error);
|
|
796
|
+
console.warn('[WaveformPlayer] Using placeholder waveform:', error);
|
|
548
797
|
this.waveformData = generatePlaceholderWaveform(this.options.samples);
|
|
549
798
|
}
|
|
550
799
|
}
|
|
@@ -558,7 +807,7 @@ export class WaveformPlayer {
|
|
|
558
807
|
this.options.onLoad(this);
|
|
559
808
|
}
|
|
560
809
|
} catch (error) {
|
|
561
|
-
|
|
810
|
+
// onError() is the single funnel for surfacing + logging errors.
|
|
562
811
|
this.onError(error);
|
|
563
812
|
} finally {
|
|
564
813
|
this.setLoading(false);
|
|
@@ -566,11 +815,20 @@ export class WaveformPlayer {
|
|
|
566
815
|
}
|
|
567
816
|
|
|
568
817
|
/**
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
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`).
|
|
574
832
|
* @returns {Promise<void>}
|
|
575
833
|
*/
|
|
576
834
|
async loadTrack(url, title = null, subtitle = null, options = {}) {
|
|
@@ -635,8 +893,11 @@ export class WaveformPlayer {
|
|
|
635
893
|
// Load the new track
|
|
636
894
|
await this.load(url);
|
|
637
895
|
|
|
638
|
-
// Auto-play the new track
|
|
639
|
-
|
|
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
|
+
}
|
|
640
901
|
}
|
|
641
902
|
|
|
642
903
|
// ============================================
|
|
@@ -644,7 +905,15 @@ export class WaveformPlayer {
|
|
|
644
905
|
// ============================================
|
|
645
906
|
|
|
646
907
|
/**
|
|
647
|
-
*
|
|
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.
|
|
648
917
|
* @private
|
|
649
918
|
*/
|
|
650
919
|
setWaveformData(data) {
|
|
@@ -678,7 +947,9 @@ export class WaveformPlayer {
|
|
|
678
947
|
}
|
|
679
948
|
|
|
680
949
|
/**
|
|
681
|
-
*
|
|
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.
|
|
682
953
|
* @private
|
|
683
954
|
*/
|
|
684
955
|
drawWaveform() {
|
|
@@ -693,7 +964,9 @@ export class WaveformPlayer {
|
|
|
693
964
|
}
|
|
694
965
|
|
|
695
966
|
/**
|
|
696
|
-
*
|
|
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.
|
|
697
970
|
* @private
|
|
698
971
|
*/
|
|
699
972
|
resizeCanvas() {
|
|
@@ -713,7 +986,15 @@ export class WaveformPlayer {
|
|
|
713
986
|
}
|
|
714
987
|
|
|
715
988
|
/**
|
|
716
|
-
* 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.
|
|
717
998
|
* @private
|
|
718
999
|
*/
|
|
719
1000
|
renderMarkers() {
|
|
@@ -724,20 +1005,22 @@ export class WaveformPlayer {
|
|
|
724
1005
|
|
|
725
1006
|
if (!this.options.showMarkers || !this.options.markers?.length) return;
|
|
726
1007
|
|
|
727
|
-
//
|
|
728
|
-
|
|
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) {
|
|
729
1012
|
return;
|
|
730
1013
|
}
|
|
731
1014
|
|
|
732
1015
|
// Add each marker
|
|
733
1016
|
this.options.markers.forEach((marker, index) => {
|
|
734
1017
|
// Skip markers that are beyond the audio duration
|
|
735
|
-
if (marker.time >
|
|
736
|
-
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`);
|
|
737
1020
|
return;
|
|
738
1021
|
}
|
|
739
1022
|
|
|
740
|
-
const position = (marker.time /
|
|
1023
|
+
const position = (marker.time / duration) * 100;
|
|
741
1024
|
|
|
742
1025
|
const markerEl = document.createElement('button');
|
|
743
1026
|
markerEl.className = 'waveform-marker';
|
|
@@ -765,13 +1048,34 @@ export class WaveformPlayer {
|
|
|
765
1048
|
});
|
|
766
1049
|
}
|
|
767
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
|
+
|
|
768
1064
|
// ============================================
|
|
769
1065
|
// Event Handlers
|
|
770
1066
|
// ============================================
|
|
771
1067
|
|
|
772
1068
|
/**
|
|
773
|
-
*
|
|
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.
|
|
774
1077
|
* @private
|
|
1078
|
+
* @fires WaveformPlayer#waveformplayer:request-seek
|
|
775
1079
|
*/
|
|
776
1080
|
handleCanvasClick(event) {
|
|
777
1081
|
// In external mode the player has no audio of its own —
|
|
@@ -782,19 +1086,10 @@ export class WaveformPlayer {
|
|
|
782
1086
|
// controller's progress event will reconcile shortly after).
|
|
783
1087
|
const rect = this.canvas.getBoundingClientRect();
|
|
784
1088
|
const x = event.clientX - rect.left;
|
|
785
|
-
const targetPercent =
|
|
1089
|
+
const targetPercent = clamp(x / rect.width);
|
|
786
1090
|
|
|
787
1091
|
if (this.options.audioMode === 'external') {
|
|
788
|
-
|
|
789
|
-
bubbles: true,
|
|
790
|
-
cancelable: true,
|
|
791
|
-
detail: { ...this._buildTrackDetail(), percent: targetPercent }
|
|
792
|
-
});
|
|
793
|
-
this.container.dispatchEvent(evt);
|
|
794
|
-
if (!evt.defaultPrevented) {
|
|
795
|
-
this.progress = targetPercent;
|
|
796
|
-
this.drawWaveform?.();
|
|
797
|
-
}
|
|
1092
|
+
this._requestSeek(targetPercent);
|
|
798
1093
|
return;
|
|
799
1094
|
}
|
|
800
1095
|
|
|
@@ -803,7 +1098,10 @@ export class WaveformPlayer {
|
|
|
803
1098
|
}
|
|
804
1099
|
|
|
805
1100
|
/**
|
|
806
|
-
*
|
|
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.
|
|
807
1105
|
* @private
|
|
808
1106
|
*/
|
|
809
1107
|
setLoading(loading) {
|
|
@@ -811,10 +1109,16 @@ export class WaveformPlayer {
|
|
|
811
1109
|
if (this.loadingEl) {
|
|
812
1110
|
this.loadingEl.style.display = loading ? 'block' : 'none';
|
|
813
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
|
+
}
|
|
814
1116
|
}
|
|
815
1117
|
|
|
816
1118
|
/**
|
|
817
|
-
*
|
|
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.
|
|
818
1122
|
* @private
|
|
819
1123
|
*/
|
|
820
1124
|
onMetadataLoaded() {
|
|
@@ -826,34 +1130,47 @@ export class WaveformPlayer {
|
|
|
826
1130
|
}
|
|
827
1131
|
// Re-render markers when duration is known
|
|
828
1132
|
this.renderMarkers();
|
|
1133
|
+
// Duration is now known — publish it to the accessible slider.
|
|
1134
|
+
this.updateSeekAccessibility();
|
|
829
1135
|
}
|
|
830
1136
|
|
|
831
1137
|
/**
|
|
832
|
-
*
|
|
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.
|
|
833
1143
|
* @private
|
|
834
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.
|
|
1159
|
+
* @private
|
|
1160
|
+
* @fires WaveformPlayer#waveformplayer:play
|
|
1161
|
+
*/
|
|
835
1162
|
onPlay() {
|
|
836
1163
|
// Ignore during destruction
|
|
837
1164
|
if (this.isDestroying) return;
|
|
838
1165
|
|
|
839
1166
|
this.isPlaying = true;
|
|
840
1167
|
|
|
841
|
-
|
|
842
|
-
this.playBtn.classList.add('playing');
|
|
843
|
-
|
|
844
|
-
const playIcon = this.playBtn.querySelector('.waveform-icon-play');
|
|
845
|
-
const pauseIcon = this.playBtn.querySelector('.waveform-icon-pause');
|
|
846
|
-
if (playIcon) playIcon.style.display = 'none';
|
|
847
|
-
if (pauseIcon) pauseIcon.style.display = 'flex';
|
|
848
|
-
}
|
|
1168
|
+
this.setPlayButtonState(true);
|
|
849
1169
|
|
|
850
1170
|
this.startSmoothUpdate();
|
|
851
1171
|
|
|
852
1172
|
// Dispatch play event
|
|
853
|
-
this.
|
|
854
|
-
bubbles: true,
|
|
855
|
-
detail: {player: this, url: this.options.url}
|
|
856
|
-
}));
|
|
1173
|
+
this._emit('waveformplayer:play', {player: this, url: this.options.url});
|
|
857
1174
|
|
|
858
1175
|
if (this.options.onPlay) {
|
|
859
1176
|
this.options.onPlay(this);
|
|
@@ -861,8 +1178,12 @@ export class WaveformPlayer {
|
|
|
861
1178
|
}
|
|
862
1179
|
|
|
863
1180
|
/**
|
|
864
|
-
*
|
|
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.
|
|
865
1185
|
* @private
|
|
1186
|
+
* @fires WaveformPlayer#waveformplayer:pause
|
|
866
1187
|
*/
|
|
867
1188
|
onPause() {
|
|
868
1189
|
// Ignore during destruction
|
|
@@ -870,22 +1191,12 @@ export class WaveformPlayer {
|
|
|
870
1191
|
|
|
871
1192
|
this.isPlaying = false;
|
|
872
1193
|
|
|
873
|
-
|
|
874
|
-
this.playBtn.classList.remove('playing');
|
|
875
|
-
|
|
876
|
-
const playIcon = this.playBtn.querySelector('.waveform-icon-play');
|
|
877
|
-
const pauseIcon = this.playBtn.querySelector('.waveform-icon-pause');
|
|
878
|
-
if (playIcon) playIcon.style.display = 'flex';
|
|
879
|
-
if (pauseIcon) pauseIcon.style.display = 'none';
|
|
880
|
-
}
|
|
1194
|
+
this.setPlayButtonState(false);
|
|
881
1195
|
|
|
882
1196
|
this.stopSmoothUpdate();
|
|
883
1197
|
|
|
884
1198
|
// Dispatch pause event
|
|
885
|
-
this.
|
|
886
|
-
bubbles: true,
|
|
887
|
-
detail: {player: this, url: this.options.url}
|
|
888
|
-
}));
|
|
1199
|
+
this._emit('waveformplayer:pause', {player: this, url: this.options.url});
|
|
889
1200
|
|
|
890
1201
|
if (this.options.onPause) {
|
|
891
1202
|
this.options.onPause(this);
|
|
@@ -893,13 +1204,19 @@ export class WaveformPlayer {
|
|
|
893
1204
|
}
|
|
894
1205
|
|
|
895
1206
|
/**
|
|
896
|
-
*
|
|
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.
|
|
897
1211
|
* @private
|
|
1212
|
+
* @fires WaveformPlayer#waveformplayer:ended
|
|
898
1213
|
*/
|
|
899
1214
|
onEnded() {
|
|
900
1215
|
// Ignore during destruction
|
|
901
1216
|
if (this.isDestroying) return;
|
|
902
1217
|
|
|
1218
|
+
const duration = this.audio.duration;
|
|
1219
|
+
|
|
903
1220
|
this.progress = 0;
|
|
904
1221
|
this.audio.currentTime = 0;
|
|
905
1222
|
this.drawWaveform();
|
|
@@ -909,11 +1226,9 @@ export class WaveformPlayer {
|
|
|
909
1226
|
this.currentTimeEl.textContent = '0:00';
|
|
910
1227
|
}
|
|
911
1228
|
|
|
912
|
-
// Dispatch ended event
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
detail: {player: this, url: this.options.url}
|
|
916
|
-
}));
|
|
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});
|
|
917
1232
|
|
|
918
1233
|
this.onPause();
|
|
919
1234
|
|
|
@@ -923,14 +1238,18 @@ export class WaveformPlayer {
|
|
|
923
1238
|
}
|
|
924
1239
|
|
|
925
1240
|
/**
|
|
926
|
-
*
|
|
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.
|
|
927
1246
|
* @private
|
|
928
1247
|
*/
|
|
929
1248
|
onError(error) {
|
|
930
1249
|
// Ignore errors during destruction
|
|
931
1250
|
if (this.isDestroying) return;
|
|
932
1251
|
|
|
933
|
-
console.error('Audio error:', error);
|
|
1252
|
+
console.error('[WaveformPlayer] Audio error:', error);
|
|
934
1253
|
this.hasError = true;
|
|
935
1254
|
this.setLoading(false);
|
|
936
1255
|
|
|
@@ -956,7 +1275,10 @@ export class WaveformPlayer {
|
|
|
956
1275
|
// ============================================
|
|
957
1276
|
|
|
958
1277
|
/**
|
|
959
|
-
* 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.
|
|
960
1282
|
* @private
|
|
961
1283
|
*/
|
|
962
1284
|
startSmoothUpdate() {
|
|
@@ -976,7 +1298,7 @@ export class WaveformPlayer {
|
|
|
976
1298
|
}
|
|
977
1299
|
|
|
978
1300
|
/**
|
|
979
|
-
*
|
|
1301
|
+
* Cancel the smooth-update animation frame, if one is scheduled.
|
|
980
1302
|
* @private
|
|
981
1303
|
*/
|
|
982
1304
|
stopSmoothUpdate() {
|
|
@@ -987,8 +1309,15 @@ export class WaveformPlayer {
|
|
|
987
1309
|
}
|
|
988
1310
|
|
|
989
1311
|
/**
|
|
990
|
-
*
|
|
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.
|
|
991
1319
|
* @private
|
|
1320
|
+
* @fires WaveformPlayer#waveformplayer:timeupdate
|
|
992
1321
|
*/
|
|
993
1322
|
updateProgress() {
|
|
994
1323
|
// Self-mode only — external mode receives progress via
|
|
@@ -1007,19 +1336,19 @@ export class WaveformPlayer {
|
|
|
1007
1336
|
}
|
|
1008
1337
|
|
|
1009
1338
|
// Dispatch timeupdate event
|
|
1010
|
-
this.
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
}
|
|
1018
|
-
}));
|
|
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
|
+
});
|
|
1019
1346
|
|
|
1020
1347
|
if (this.options.onTimeUpdate) {
|
|
1021
1348
|
this.options.onTimeUpdate(this.audio.currentTime, this.audio.duration, this);
|
|
1022
1349
|
}
|
|
1350
|
+
|
|
1351
|
+
this.updateSeekAccessibility();
|
|
1023
1352
|
}
|
|
1024
1353
|
|
|
1025
1354
|
// ============================================
|
|
@@ -1027,7 +1356,7 @@ export class WaveformPlayer {
|
|
|
1027
1356
|
// ============================================
|
|
1028
1357
|
|
|
1029
1358
|
/**
|
|
1030
|
-
*
|
|
1359
|
+
* Show the detected BPM in the badge, once a value has been detected.
|
|
1031
1360
|
* @private
|
|
1032
1361
|
*/
|
|
1033
1362
|
updateBPMDisplay() {
|
|
@@ -1038,10 +1367,17 @@ export class WaveformPlayer {
|
|
|
1038
1367
|
}
|
|
1039
1368
|
|
|
1040
1369
|
/**
|
|
1041
|
-
*
|
|
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.
|
|
1042
1374
|
* @private
|
|
1043
1375
|
*/
|
|
1044
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
|
+
|
|
1045
1381
|
const speedValue = this.container.querySelector('.speed-value');
|
|
1046
1382
|
if (speedValue) {
|
|
1047
1383
|
const rate = this.audio.playbackRate;
|
|
@@ -1072,7 +1408,12 @@ export class WaveformPlayer {
|
|
|
1072
1408
|
* setPlayingState() / setProgress(). Calling preventDefault() on
|
|
1073
1409
|
* the event lets the controller veto the play (state is unchanged).
|
|
1074
1410
|
*
|
|
1075
|
-
*
|
|
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
|
|
1076
1417
|
*/
|
|
1077
1418
|
play() {
|
|
1078
1419
|
if (this.options.singlePlay && WaveformPlayer.currentlyPlaying &&
|
|
@@ -1081,12 +1422,7 @@ export class WaveformPlayer {
|
|
|
1081
1422
|
}
|
|
1082
1423
|
|
|
1083
1424
|
if (this.options.audioMode === 'external') {
|
|
1084
|
-
const evt =
|
|
1085
|
-
bubbles: true,
|
|
1086
|
-
cancelable: true,
|
|
1087
|
-
detail: this._buildTrackDetail()
|
|
1088
|
-
});
|
|
1089
|
-
this.container.dispatchEvent(evt);
|
|
1425
|
+
const evt = this._emit('waveformplayer:request-play', this._buildTrackDetail(), true);
|
|
1090
1426
|
// If the controller cancels (preventDefault), don't claim
|
|
1091
1427
|
// "currentlyPlaying" — the controller didn't accept the play.
|
|
1092
1428
|
if (!evt.defaultPrevented) {
|
|
@@ -1104,17 +1440,15 @@ export class WaveformPlayer {
|
|
|
1104
1440
|
*
|
|
1105
1441
|
* In `audioMode: 'external'`, dispatches `waveformplayer:request-pause`
|
|
1106
1442
|
* (cancelable) and does NOT touch any audio element. See play().
|
|
1443
|
+
*
|
|
1444
|
+
* @fires WaveformPlayer#waveformplayer:request-pause
|
|
1107
1445
|
*/
|
|
1108
1446
|
pause() {
|
|
1109
1447
|
if (WaveformPlayer.currentlyPlaying === this) {
|
|
1110
1448
|
WaveformPlayer.currentlyPlaying = null;
|
|
1111
1449
|
}
|
|
1112
1450
|
if (this.options.audioMode === 'external') {
|
|
1113
|
-
this.
|
|
1114
|
-
bubbles: true,
|
|
1115
|
-
cancelable: true,
|
|
1116
|
-
detail: this._buildTrackDetail()
|
|
1117
|
-
}));
|
|
1451
|
+
this._emit('waveformplayer:request-pause', this._buildTrackDetail(), true);
|
|
1118
1452
|
return;
|
|
1119
1453
|
}
|
|
1120
1454
|
this.audio.pause();
|
|
@@ -1134,8 +1468,12 @@ export class WaveformPlayer {
|
|
|
1134
1468
|
url: this.options.url,
|
|
1135
1469
|
title: this.options.title,
|
|
1136
1470
|
subtitle: this.options.subtitle,
|
|
1137
|
-
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,
|
|
1138
1474
|
artwork: this.options.artwork,
|
|
1475
|
+
markers: this.options.markers,
|
|
1476
|
+
waveform: this.options.waveform,
|
|
1139
1477
|
id: this.id,
|
|
1140
1478
|
player: this
|
|
1141
1479
|
};
|
|
@@ -1146,31 +1484,26 @@ export class WaveformPlayer {
|
|
|
1146
1484
|
* touching audio. Mirrors what onPlay()/onPause() do but skips the
|
|
1147
1485
|
* audio-element interactions. Safe to call repeatedly — idempotent.
|
|
1148
1486
|
*
|
|
1149
|
-
*
|
|
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
|
|
1150
1495
|
*/
|
|
1151
1496
|
setPlayingState(playing) {
|
|
1152
1497
|
const wasPlaying = this.isPlaying;
|
|
1153
1498
|
this.isPlaying = !!playing;
|
|
1154
|
-
|
|
1155
|
-
this.playBtn.classList.toggle('playing', this.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 = this.isPlaying ? 'none' : 'flex';
|
|
1159
|
-
if (pauseIcon) pauseIcon.style.display = this.isPlaying ? 'flex' : 'none';
|
|
1160
|
-
}
|
|
1499
|
+
this.setPlayButtonState(this.isPlaying);
|
|
1161
1500
|
if (this.isPlaying && !wasPlaying) {
|
|
1162
1501
|
this.startSmoothUpdate?.();
|
|
1163
|
-
this.
|
|
1164
|
-
bubbles: true,
|
|
1165
|
-
detail: {player: this, url: this.options.url}
|
|
1166
|
-
}));
|
|
1502
|
+
this._emit('waveformplayer:play', {player: this, url: this.options.url});
|
|
1167
1503
|
if (this.options.onPlay) this.options.onPlay(this);
|
|
1168
1504
|
} else if (!this.isPlaying && wasPlaying) {
|
|
1169
1505
|
this.stopSmoothUpdate?.();
|
|
1170
|
-
this.
|
|
1171
|
-
bubbles: true,
|
|
1172
|
-
detail: {player: this, url: this.options.url}
|
|
1173
|
-
}));
|
|
1506
|
+
this._emit('waveformplayer:pause', {player: this, url: this.options.url});
|
|
1174
1507
|
if (this.options.onPause) this.options.onPause(this);
|
|
1175
1508
|
}
|
|
1176
1509
|
}
|
|
@@ -1180,30 +1513,58 @@ export class WaveformPlayer {
|
|
|
1180
1513
|
* from an external clock (e.g. WaveformBar's audio element's
|
|
1181
1514
|
* timeupdate). Drives the canvas redraw + the time displays.
|
|
1182
1515
|
*
|
|
1183
|
-
*
|
|
1184
|
-
*
|
|
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
|
|
1185
1526
|
*/
|
|
1186
1527
|
setProgress(currentTime, duration) {
|
|
1187
1528
|
if (!duration || duration <= 0) return;
|
|
1188
|
-
this.progress =
|
|
1529
|
+
this.progress = clamp(currentTime / duration);
|
|
1189
1530
|
// Mirror the existing display update code so callers don't have
|
|
1190
1531
|
// to know which DOM elements live where.
|
|
1191
1532
|
if (this.currentTimeEl) this.currentTimeEl.textContent = formatTime(currentTime);
|
|
1192
|
-
|
|
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))) {
|
|
1193
1538
|
this.totalTimeEl.textContent = formatTime(duration);
|
|
1194
1539
|
this.totalTimeEl.dataset._extSet = '1';
|
|
1195
|
-
this.
|
|
1540
|
+
this.totalTimeEl.dataset._extDur = String(duration);
|
|
1196
1541
|
}
|
|
1197
1542
|
this.drawWaveform?.();
|
|
1198
|
-
this.
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
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
|
+
}
|
|
1560
|
+
|
|
1561
|
+
this.updateSeekAccessibility();
|
|
1203
1562
|
}
|
|
1204
1563
|
|
|
1205
1564
|
/**
|
|
1206
|
-
* 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).
|
|
1207
1568
|
*/
|
|
1208
1569
|
togglePlay() {
|
|
1209
1570
|
if (this.isPlaying) {
|
|
@@ -1214,45 +1575,56 @@ export class WaveformPlayer {
|
|
|
1214
1575
|
}
|
|
1215
1576
|
|
|
1216
1577
|
/**
|
|
1217
|
-
* Seek to time
|
|
1218
|
-
*
|
|
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.
|
|
1219
1583
|
*/
|
|
1220
1584
|
seekTo(seconds) {
|
|
1221
1585
|
if (this.audio && this.audio.duration) {
|
|
1222
|
-
this.audio.currentTime =
|
|
1586
|
+
this.audio.currentTime = clamp(seconds, 0, this.audio.duration);
|
|
1223
1587
|
this.updateProgress();
|
|
1224
1588
|
}
|
|
1225
1589
|
}
|
|
1226
1590
|
|
|
1227
1591
|
/**
|
|
1228
|
-
* Seek to
|
|
1229
|
-
*
|
|
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.
|
|
1230
1596
|
*/
|
|
1231
1597
|
seekToPercent(percent) {
|
|
1232
1598
|
if (this.audio && this.audio.duration) {
|
|
1233
|
-
this.audio.currentTime = this.audio.duration *
|
|
1599
|
+
this.audio.currentTime = this.audio.duration * clamp(percent);
|
|
1234
1600
|
this.updateProgress();
|
|
1235
1601
|
}
|
|
1236
1602
|
}
|
|
1237
1603
|
|
|
1238
1604
|
/**
|
|
1239
|
-
* Set volume
|
|
1240
|
-
*
|
|
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).
|
|
1241
1608
|
*/
|
|
1242
1609
|
setVolume(volume) {
|
|
1243
|
-
|
|
1244
|
-
|
|
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);
|
|
1245
1615
|
}
|
|
1246
1616
|
}
|
|
1247
1617
|
|
|
1248
1618
|
/**
|
|
1249
|
-
* Set playback rate
|
|
1250
|
-
*
|
|
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.
|
|
1251
1623
|
*/
|
|
1252
1624
|
setPlaybackRate(rate) {
|
|
1253
1625
|
if (!this.audio) return;
|
|
1254
1626
|
|
|
1255
|
-
const clampedRate =
|
|
1627
|
+
const clampedRate = clamp(rate, 0.5, 2);
|
|
1256
1628
|
this.audio.playbackRate = clampedRate;
|
|
1257
1629
|
this.options.playbackRate = clampedRate;
|
|
1258
1630
|
|
|
@@ -1260,16 +1632,31 @@ export class WaveformPlayer {
|
|
|
1260
1632
|
}
|
|
1261
1633
|
|
|
1262
1634
|
/**
|
|
1263
|
-
*
|
|
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
|
|
1264
1644
|
*/
|
|
1265
1645
|
destroy() {
|
|
1266
1646
|
// Set a flag to indicate we're destroying
|
|
1267
1647
|
this.isDestroying = true;
|
|
1268
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
|
+
|
|
1269
1653
|
// Stop playback and animations
|
|
1270
1654
|
this.pause();
|
|
1271
1655
|
this.stopSmoothUpdate();
|
|
1272
1656
|
|
|
1657
|
+
// Tear down every document/container/seek listener in one shot.
|
|
1658
|
+
this._ac?.abort();
|
|
1659
|
+
|
|
1273
1660
|
// Disconnect observer
|
|
1274
1661
|
if (this.resizeObserver) {
|
|
1275
1662
|
this.resizeObserver.disconnect();
|
|
@@ -1363,7 +1750,7 @@ export class WaveformPlayer {
|
|
|
1363
1750
|
const result = await generateWaveform(url, samples);
|
|
1364
1751
|
return result.peaks;
|
|
1365
1752
|
} catch (error) {
|
|
1366
|
-
console.error('Failed to generate waveform:', error);
|
|
1753
|
+
console.error('[WaveformPlayer] Failed to generate waveform:', error);
|
|
1367
1754
|
throw error;
|
|
1368
1755
|
}
|
|
1369
1756
|
}
|