@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/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
- * @param {string|HTMLElement} container - Container element or selector
33
- * @param {Object} options - Player options
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: Container element not found');
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, options);
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.container.dispatchEvent(new CustomEvent('waveformplayer:ready', {
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
- * Create DOM elements
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">Unable to load audio</span>
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
- * Initialize playback speed controls
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
- * Initialize speed control UI
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
- * Initialize keyboard controls
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(Math.max(0, currentTime - 5));
379
- actions['ArrowRight'] = () => this.seekTo(Math.min(this.audio.duration, currentTime + 5));
380
- actions['ArrowUp'] = () => this.setVolume(Math.min(1, this.audio.volume + 0.1));
381
- actions['ArrowDown'] = () => this.setVolume(Math.max(0, this.audio.volume - 0.1));
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(Math.max(0, this.audio.currentTime - 10));
649
+ this.seekTo(clamp(this.audio.currentTime - 10, 0, this.audio.duration));
418
650
  });
419
651
  navigator.mediaSession.setActionHandler('seekforward', () => {
420
- this.seekTo(Math.min(this.audio.duration, this.audio.currentTime + 10));
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 event listeners
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
- * Setup resize observer
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 file
491
- * @param {string} url - Audio URL
492
- * @returns {Promise<void>}
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
- console.error('Failed to load audio:', error);
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
- * Load a new track
570
- * @param {string} url - Audio URL
571
- * @param {string} [title] - Track title
572
- * @param {string} [subtitle] - Track subtitle
573
- * @param {Object} [options] - Additional options
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
- this.play().catch(() => {});
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
- * Set waveform data
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
- * Draw waveform
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
- * Resize canvas
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 on the waveform
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
- // Don't render if audio duration isn't available yet
728
- if (!this.audio || !this.audio.duration || this.audio.duration === 0) {
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 > this.audio.duration) {
736
- console.warn(`Marker "${marker.label}" at ${marker.time}s exceeds audio duration of ${this.audio.duration}s`);
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 / this.audio.duration) * 100;
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
- * Handle canvas click
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 = Math.max(0, Math.min(1, x / rect.width));
1089
+ const targetPercent = clamp(x / rect.width);
786
1090
 
787
1091
  if (this.options.audioMode === 'external') {
788
- const evt = new CustomEvent('waveformplayer:request-seek', {
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
- * Set loading state
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
- * Handle metadata loaded
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
- * Handle play event
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
- if (this.playBtn) {
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.container.dispatchEvent(new CustomEvent('waveformplayer:play', {
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
- * Handle pause event
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
- if (this.playBtn) {
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.container.dispatchEvent(new CustomEvent('waveformplayer:pause', {
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
- * Handle ended event
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
- this.container.dispatchEvent(new CustomEvent('waveformplayer:ended', {
914
- bubbles: true,
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
- * Handle error event
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 update animation
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
- * Stop smooth update animation
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
- * Update progress
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.container.dispatchEvent(new CustomEvent('waveformplayer:timeupdate', {
1011
- bubbles: true,
1012
- detail: {
1013
- player: this,
1014
- currentTime: this.audio.currentTime,
1015
- duration: this.audio.duration,
1016
- url: this.options.url
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
- * Update BPM display
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
- * Update speed UI to reflect current rate
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
- * @return {Promise|undefined}
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 = new CustomEvent('waveformplayer:request-play', {
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.container.dispatchEvent(new CustomEvent('waveformplayer:request-pause', {
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: this.options.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
- * @param {boolean} playing
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
- if (this.playBtn) {
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.container.dispatchEvent(new CustomEvent('waveformplayer:play', {
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.container.dispatchEvent(new CustomEvent('waveformplayer:pause', {
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
- * @param {number} currentTime Current playback position in seconds.
1184
- * @param {number} duration Total track duration in seconds.
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 = Math.max(0, Math.min(1, currentTime / duration));
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
- if (this.totalTimeEl && (!this.totalTimeEl.dataset._extSet || this._extDuration !== duration)) {
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._extDuration = duration;
1540
+ this.totalTimeEl.dataset._extDur = String(duration);
1196
1541
  }
1197
1542
  this.drawWaveform?.();
1198
- this.container.dispatchEvent(new CustomEvent('waveformplayer:timeupdate', {
1199
- bubbles: true,
1200
- detail: {player: this, currentTime, duration, progress: this.progress}
1201
- }));
1202
- if (this.options.onTimeUpdate) this.options.onTimeUpdate(this, currentTime, duration);
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/pause
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 in seconds
1218
- * @param {number} seconds - Time in seconds
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 = Math.max(0, Math.min(seconds, this.audio.duration));
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 percentage
1229
- * @param {number} percent - Percentage (0-1)
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 * Math.max(0, Math.min(1, percent));
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
- * @param {number} volume - Volume (0-1)
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
- if (this.audio) {
1244
- this.audio.volume = Math.max(0, Math.min(1, volume));
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
- * @param {number} rate - Playback rate (0.5 to 2)
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 = Math.max(0.5, Math.min(2, rate));
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
- * Destroy player instance
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
  }