@arraypress/waveform-player 1.7.2 → 1.8.1

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