@arraypress/waveform-player 1.7.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 = {}) {
@@ -792,8 +893,11 @@ export class WaveformPlayer {
792
893
  // Load the new track
793
894
  await this.load(url);
794
895
 
795
- // Auto-play the new track
796
- 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
+ }
797
901
  }
798
902
 
799
903
  // ============================================
@@ -801,7 +905,15 @@ export class WaveformPlayer {
801
905
  // ============================================
802
906
 
803
907
  /**
804
- * 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.
805
917
  * @private
806
918
  */
807
919
  setWaveformData(data) {
@@ -835,7 +947,9 @@ export class WaveformPlayer {
835
947
  }
836
948
 
837
949
  /**
838
- * 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.
839
953
  * @private
840
954
  */
841
955
  drawWaveform() {
@@ -850,7 +964,9 @@ export class WaveformPlayer {
850
964
  }
851
965
 
852
966
  /**
853
- * 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.
854
970
  * @private
855
971
  */
856
972
  resizeCanvas() {
@@ -870,7 +986,15 @@ export class WaveformPlayer {
870
986
  }
871
987
 
872
988
  /**
873
- * 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.
874
998
  * @private
875
999
  */
876
1000
  renderMarkers() {
@@ -881,20 +1005,22 @@ export class WaveformPlayer {
881
1005
 
882
1006
  if (!this.options.showMarkers || !this.options.markers?.length) return;
883
1007
 
884
- // Don't render if audio duration isn't available yet
885
- 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) {
886
1012
  return;
887
1013
  }
888
1014
 
889
1015
  // Add each marker
890
1016
  this.options.markers.forEach((marker, index) => {
891
1017
  // Skip markers that are beyond the audio duration
892
- if (marker.time > this.audio.duration) {
893
- 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`);
894
1020
  return;
895
1021
  }
896
1022
 
897
- const position = (marker.time / this.audio.duration) * 100;
1023
+ const position = (marker.time / duration) * 100;
898
1024
 
899
1025
  const markerEl = document.createElement('button');
900
1026
  markerEl.className = 'waveform-marker';
@@ -922,13 +1048,34 @@ export class WaveformPlayer {
922
1048
  });
923
1049
  }
924
1050
 
1051
+ /**
1052
+ * Highlight the marker at `index` (toggling an `active` class) and clear
1053
+ * the rest. Pass `null` to clear all. Lets an external controller (e.g. a
1054
+ * DJ bar) reflect the current section without reaching into the player's
1055
+ * private marker DOM.
1056
+ * @param {number|null} index - Marker index to activate, or `null` to clear.
1057
+ */
1058
+ setActiveMarker(index) {
1059
+ if (!this.markersContainer) return;
1060
+ const markers = this.markersContainer.querySelectorAll('.waveform-marker');
1061
+ markers.forEach((el, i) => el.classList.toggle('active', i === index));
1062
+ }
1063
+
925
1064
  // ============================================
926
1065
  // Event Handlers
927
1066
  // ============================================
928
1067
 
929
1068
  /**
930
- * 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.
931
1077
  * @private
1078
+ * @fires WaveformPlayer#waveformplayer:request-seek
932
1079
  */
933
1080
  handleCanvasClick(event) {
934
1081
  // In external mode the player has no audio of its own —
@@ -939,19 +1086,10 @@ export class WaveformPlayer {
939
1086
  // controller's progress event will reconcile shortly after).
940
1087
  const rect = this.canvas.getBoundingClientRect();
941
1088
  const x = event.clientX - rect.left;
942
- const targetPercent = Math.max(0, Math.min(1, x / rect.width));
1089
+ const targetPercent = clamp(x / rect.width);
943
1090
 
944
1091
  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
- }
1092
+ this._requestSeek(targetPercent);
955
1093
  return;
956
1094
  }
957
1095
 
@@ -960,7 +1098,10 @@ export class WaveformPlayer {
960
1098
  }
961
1099
 
962
1100
  /**
963
- * 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.
964
1105
  * @private
965
1106
  */
966
1107
  setLoading(loading) {
@@ -968,10 +1109,16 @@ export class WaveformPlayer {
968
1109
  if (this.loadingEl) {
969
1110
  this.loadingEl.style.display = loading ? 'block' : 'none';
970
1111
  }
1112
+ // Let assistive tech know the player is busy fetching/decoding.
1113
+ if (this.seekEl) {
1114
+ this.seekEl.setAttribute('aria-busy', loading ? 'true' : 'false');
1115
+ }
971
1116
  }
972
1117
 
973
1118
  /**
974
- * 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.
975
1122
  * @private
976
1123
  */
977
1124
  onMetadataLoaded() {
@@ -988,8 +1135,29 @@ export class WaveformPlayer {
988
1135
  }
989
1136
 
990
1137
  /**
991
- * 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.
1143
+ * @private
1144
+ */
1145
+ setPlayButtonState(isPlaying) {
1146
+ if (!this.playBtn) return;
1147
+ this.playBtn.classList.toggle('playing', isPlaying);
1148
+ const playIcon = this.playBtn.querySelector('.waveform-icon-play');
1149
+ const pauseIcon = this.playBtn.querySelector('.waveform-icon-pause');
1150
+ if (playIcon) playIcon.style.display = isPlaying ? 'none' : 'flex';
1151
+ if (pauseIcon) pauseIcon.style.display = isPlaying ? 'flex' : 'none';
1152
+ }
1153
+
1154
+ /**
1155
+ * `play` handler (self mode): set the playing flag, swap the button to its
1156
+ * pause icon, start the smooth progress loop, dispatch
1157
+ * `waveformplayer:play`, and fire the `onPlay` callback. No-op during
1158
+ * destruction.
992
1159
  * @private
1160
+ * @fires WaveformPlayer#waveformplayer:play
993
1161
  */
994
1162
  onPlay() {
995
1163
  // Ignore during destruction
@@ -997,22 +1165,12 @@ export class WaveformPlayer {
997
1165
 
998
1166
  this.isPlaying = true;
999
1167
 
1000
- 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
- }
1168
+ this.setPlayButtonState(true);
1008
1169
 
1009
1170
  this.startSmoothUpdate();
1010
1171
 
1011
1172
  // Dispatch play event
1012
- this.container.dispatchEvent(new CustomEvent('waveformplayer:play', {
1013
- bubbles: true,
1014
- detail: {player: this, url: this.options.url}
1015
- }));
1173
+ this._emit('waveformplayer:play', {player: this, url: this.options.url});
1016
1174
 
1017
1175
  if (this.options.onPlay) {
1018
1176
  this.options.onPlay(this);
@@ -1020,8 +1178,12 @@ export class WaveformPlayer {
1020
1178
  }
1021
1179
 
1022
1180
  /**
1023
- * 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.
1024
1185
  * @private
1186
+ * @fires WaveformPlayer#waveformplayer:pause
1025
1187
  */
1026
1188
  onPause() {
1027
1189
  // Ignore during destruction
@@ -1029,22 +1191,12 @@ export class WaveformPlayer {
1029
1191
 
1030
1192
  this.isPlaying = false;
1031
1193
 
1032
- 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
- }
1194
+ this.setPlayButtonState(false);
1040
1195
 
1041
1196
  this.stopSmoothUpdate();
1042
1197
 
1043
1198
  // Dispatch pause event
1044
- this.container.dispatchEvent(new CustomEvent('waveformplayer:pause', {
1045
- bubbles: true,
1046
- detail: {player: this, url: this.options.url}
1047
- }));
1199
+ this._emit('waveformplayer:pause', {player: this, url: this.options.url});
1048
1200
 
1049
1201
  if (this.options.onPause) {
1050
1202
  this.options.onPause(this);
@@ -1052,13 +1204,19 @@ export class WaveformPlayer {
1052
1204
  }
1053
1205
 
1054
1206
  /**
1055
- * 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.
1056
1211
  * @private
1212
+ * @fires WaveformPlayer#waveformplayer:ended
1057
1213
  */
1058
1214
  onEnded() {
1059
1215
  // Ignore during destruction
1060
1216
  if (this.isDestroying) return;
1061
1217
 
1218
+ const duration = this.audio.duration;
1219
+
1062
1220
  this.progress = 0;
1063
1221
  this.audio.currentTime = 0;
1064
1222
  this.drawWaveform();
@@ -1068,11 +1226,9 @@ export class WaveformPlayer {
1068
1226
  this.currentTimeEl.textContent = '0:00';
1069
1227
  }
1070
1228
 
1071
- // Dispatch ended event
1072
- this.container.dispatchEvent(new CustomEvent('waveformplayer:ended', {
1073
- bubbles: true,
1074
- detail: {player: this, url: this.options.url}
1075
- }));
1229
+ // Dispatch ended event — carries the final time so listeners (e.g.
1230
+ // analytics) don't have to reach into player.audio.
1231
+ this._emit('waveformplayer:ended', {player: this, url: this.options.url, currentTime: duration, duration});
1076
1232
 
1077
1233
  this.onPause();
1078
1234
 
@@ -1082,14 +1238,18 @@ export class WaveformPlayer {
1082
1238
  }
1083
1239
 
1084
1240
  /**
1085
- * 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.
1086
1246
  * @private
1087
1247
  */
1088
1248
  onError(error) {
1089
1249
  // Ignore errors during destruction
1090
1250
  if (this.isDestroying) return;
1091
1251
 
1092
- console.error('Audio error:', error);
1252
+ console.error('[WaveformPlayer] Audio error:', error);
1093
1253
  this.hasError = true;
1094
1254
  this.setLoading(false);
1095
1255
 
@@ -1115,7 +1275,10 @@ export class WaveformPlayer {
1115
1275
  // ============================================
1116
1276
 
1117
1277
  /**
1118
- * Start smooth 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.
1119
1282
  * @private
1120
1283
  */
1121
1284
  startSmoothUpdate() {
@@ -1135,7 +1298,7 @@ export class WaveformPlayer {
1135
1298
  }
1136
1299
 
1137
1300
  /**
1138
- * Stop smooth update animation
1301
+ * Cancel the smooth-update animation frame, if one is scheduled.
1139
1302
  * @private
1140
1303
  */
1141
1304
  stopSmoothUpdate() {
@@ -1146,8 +1309,15 @@ export class WaveformPlayer {
1146
1309
  }
1147
1310
 
1148
1311
  /**
1149
- * 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.
1150
1319
  * @private
1320
+ * @fires WaveformPlayer#waveformplayer:timeupdate
1151
1321
  */
1152
1322
  updateProgress() {
1153
1323
  // Self-mode only — external mode receives progress via
@@ -1166,15 +1336,13 @@ export class WaveformPlayer {
1166
1336
  }
1167
1337
 
1168
1338
  // Dispatch timeupdate event
1169
- this.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
- }));
1339
+ this._emit('waveformplayer:timeupdate', {
1340
+ player: this,
1341
+ currentTime: this.audio.currentTime,
1342
+ duration: this.audio.duration,
1343
+ progress: this.progress,
1344
+ url: this.options.url
1345
+ });
1178
1346
 
1179
1347
  if (this.options.onTimeUpdate) {
1180
1348
  this.options.onTimeUpdate(this.audio.currentTime, this.audio.duration, this);
@@ -1188,7 +1356,7 @@ export class WaveformPlayer {
1188
1356
  // ============================================
1189
1357
 
1190
1358
  /**
1191
- * Update BPM display
1359
+ * Show the detected BPM in the badge, once a value has been detected.
1192
1360
  * @private
1193
1361
  */
1194
1362
  updateBPMDisplay() {
@@ -1199,10 +1367,17 @@ export class WaveformPlayer {
1199
1367
  }
1200
1368
 
1201
1369
  /**
1202
- * 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.
1203
1374
  * @private
1204
1375
  */
1205
1376
  updateSpeedUI() {
1377
+ // External mode owns no <audio>; nothing to reflect (and reading
1378
+ // this.audio.playbackRate here would throw during construction).
1379
+ if (!this.audio) return;
1380
+
1206
1381
  const speedValue = this.container.querySelector('.speed-value');
1207
1382
  if (speedValue) {
1208
1383
  const rate = this.audio.playbackRate;
@@ -1233,7 +1408,12 @@ export class WaveformPlayer {
1233
1408
  * setPlayingState() / setProgress(). Calling preventDefault() on
1234
1409
  * the event lets the controller veto the play (state is unchanged).
1235
1410
  *
1236
- * @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
1237
1417
  */
1238
1418
  play() {
1239
1419
  if (this.options.singlePlay && WaveformPlayer.currentlyPlaying &&
@@ -1242,12 +1422,7 @@ export class WaveformPlayer {
1242
1422
  }
1243
1423
 
1244
1424
  if (this.options.audioMode === 'external') {
1245
- const evt = new CustomEvent('waveformplayer:request-play', {
1246
- bubbles: true,
1247
- cancelable: true,
1248
- detail: this._buildTrackDetail()
1249
- });
1250
- this.container.dispatchEvent(evt);
1425
+ const evt = this._emit('waveformplayer:request-play', this._buildTrackDetail(), true);
1251
1426
  // If the controller cancels (preventDefault), don't claim
1252
1427
  // "currentlyPlaying" — the controller didn't accept the play.
1253
1428
  if (!evt.defaultPrevented) {
@@ -1265,17 +1440,15 @@ export class WaveformPlayer {
1265
1440
  *
1266
1441
  * In `audioMode: 'external'`, dispatches `waveformplayer:request-pause`
1267
1442
  * (cancelable) and does NOT touch any audio element. See play().
1443
+ *
1444
+ * @fires WaveformPlayer#waveformplayer:request-pause
1268
1445
  */
1269
1446
  pause() {
1270
1447
  if (WaveformPlayer.currentlyPlaying === this) {
1271
1448
  WaveformPlayer.currentlyPlaying = null;
1272
1449
  }
1273
1450
  if (this.options.audioMode === 'external') {
1274
- this.container.dispatchEvent(new CustomEvent('waveformplayer:request-pause', {
1275
- bubbles: true,
1276
- cancelable: true,
1277
- detail: this._buildTrackDetail()
1278
- }));
1451
+ this._emit('waveformplayer:request-pause', this._buildTrackDetail(), true);
1279
1452
  return;
1280
1453
  }
1281
1454
  this.audio.pause();
@@ -1295,8 +1468,12 @@ export class WaveformPlayer {
1295
1468
  url: this.options.url,
1296
1469
  title: this.options.title,
1297
1470
  subtitle: this.options.subtitle,
1298
- artist: 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,
1299
1474
  artwork: this.options.artwork,
1475
+ markers: this.options.markers,
1476
+ waveform: this.options.waveform,
1300
1477
  id: this.id,
1301
1478
  player: this
1302
1479
  };
@@ -1307,31 +1484,26 @@ export class WaveformPlayer {
1307
1484
  * touching audio. Mirrors what onPlay()/onPause() do but skips the
1308
1485
  * audio-element interactions. Safe to call repeatedly — idempotent.
1309
1486
  *
1310
- * @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
1311
1495
  */
1312
1496
  setPlayingState(playing) {
1313
1497
  const wasPlaying = this.isPlaying;
1314
1498
  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
- }
1499
+ this.setPlayButtonState(this.isPlaying);
1322
1500
  if (this.isPlaying && !wasPlaying) {
1323
1501
  this.startSmoothUpdate?.();
1324
- this.container.dispatchEvent(new CustomEvent('waveformplayer:play', {
1325
- bubbles: true,
1326
- detail: {player: this, url: this.options.url}
1327
- }));
1502
+ this._emit('waveformplayer:play', {player: this, url: this.options.url});
1328
1503
  if (this.options.onPlay) this.options.onPlay(this);
1329
1504
  } else if (!this.isPlaying && wasPlaying) {
1330
1505
  this.stopSmoothUpdate?.();
1331
- this.container.dispatchEvent(new CustomEvent('waveformplayer:pause', {
1332
- bubbles: true,
1333
- detail: {player: this, url: this.options.url}
1334
- }));
1506
+ this._emit('waveformplayer:pause', {player: this, url: this.options.url});
1335
1507
  if (this.options.onPause) this.options.onPause(this);
1336
1508
  }
1337
1509
  }
@@ -1341,32 +1513,58 @@ export class WaveformPlayer {
1341
1513
  * from an external clock (e.g. WaveformBar's audio element's
1342
1514
  * timeupdate). Drives the canvas redraw + the time displays.
1343
1515
  *
1344
- * @param {number} currentTime Current playback position in seconds.
1345
- * @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
1346
1526
  */
1347
1527
  setProgress(currentTime, duration) {
1348
1528
  if (!duration || duration <= 0) return;
1349
- this.progress = Math.max(0, Math.min(1, currentTime / duration));
1529
+ this.progress = clamp(currentTime / duration);
1350
1530
  // Mirror the existing display update code so callers don't have
1351
1531
  // to know which DOM elements live where.
1352
1532
  if (this.currentTimeEl) this.currentTimeEl.textContent = formatTime(currentTime);
1353
- 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))) {
1354
1538
  this.totalTimeEl.textContent = formatTime(duration);
1355
1539
  this.totalTimeEl.dataset._extSet = '1';
1356
- this._extDuration = duration;
1540
+ this.totalTimeEl.dataset._extDur = String(duration);
1357
1541
  }
1358
1542
  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);
1543
+ this._emit('waveformplayer:timeupdate', {player: this, currentTime, duration, progress: this.progress, url: this.options.url});
1544
+ // Same (currentTime, duration, player) signature as self mode — the
1545
+ // arg order used to be swapped here, which made one shared handler
1546
+ // impossible across audioModes.
1547
+ if (this.options.onTimeUpdate) this.options.onTimeUpdate(currentTime, duration, this);
1548
+
1549
+ // External mode has no <audio> 'ended' event — synthesize one when the
1550
+ // controller's progress reaches the end (fires once per playthrough).
1551
+ if (this.progress >= 1) {
1552
+ if (!this._extEnded) {
1553
+ this._extEnded = true;
1554
+ this._emit('waveformplayer:ended', {player: this, url: this.options.url, currentTime: duration, duration});
1555
+ if (this.options.onEnd) this.options.onEnd(this);
1556
+ }
1557
+ } else {
1558
+ this._extEnded = false;
1559
+ }
1364
1560
 
1365
1561
  this.updateSeekAccessibility();
1366
1562
  }
1367
1563
 
1368
1564
  /**
1369
- * Toggle play/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).
1370
1568
  */
1371
1569
  togglePlay() {
1372
1570
  if (this.isPlaying) {
@@ -1377,45 +1575,56 @@ export class WaveformPlayer {
1377
1575
  }
1378
1576
 
1379
1577
  /**
1380
- * Seek to time in seconds
1381
- * @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.
1382
1583
  */
1383
1584
  seekTo(seconds) {
1384
1585
  if (this.audio && this.audio.duration) {
1385
- this.audio.currentTime = Math.max(0, Math.min(seconds, this.audio.duration));
1586
+ this.audio.currentTime = clamp(seconds, 0, this.audio.duration);
1386
1587
  this.updateProgress();
1387
1588
  }
1388
1589
  }
1389
1590
 
1390
1591
  /**
1391
- * Seek to percentage
1392
- * @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.
1393
1596
  */
1394
1597
  seekToPercent(percent) {
1395
1598
  if (this.audio && this.audio.duration) {
1396
- this.audio.currentTime = this.audio.duration * Math.max(0, Math.min(1, percent));
1599
+ this.audio.currentTime = this.audio.duration * clamp(percent);
1397
1600
  this.updateProgress();
1398
1601
  }
1399
1602
  }
1400
1603
 
1401
1604
  /**
1402
- * Set volume
1403
- * @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).
1404
1608
  */
1405
1609
  setVolume(volume) {
1406
- if (this.audio) {
1407
- 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);
1408
1615
  }
1409
1616
  }
1410
1617
 
1411
1618
  /**
1412
- * Set playback rate
1413
- * @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.
1414
1623
  */
1415
1624
  setPlaybackRate(rate) {
1416
1625
  if (!this.audio) return;
1417
1626
 
1418
- const clampedRate = Math.max(0.5, Math.min(2, rate));
1627
+ const clampedRate = clamp(rate, 0.5, 2);
1419
1628
  this.audio.playbackRate = clampedRate;
1420
1629
  this.options.playbackRate = clampedRate;
1421
1630
 
@@ -1423,16 +1632,31 @@ export class WaveformPlayer {
1423
1632
  }
1424
1633
 
1425
1634
  /**
1426
- * 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
1427
1644
  */
1428
1645
  destroy() {
1429
1646
  // Set a flag to indicate we're destroying
1430
1647
  this.isDestroying = true;
1431
1648
 
1649
+ // Let listeners (analytics, controllers) release their references
1650
+ // before teardown — the symmetric counterpart to waveformplayer:ready.
1651
+ this._emit('waveformplayer:destroy', {player: this, url: this.options.url});
1652
+
1432
1653
  // Stop playback and animations
1433
1654
  this.pause();
1434
1655
  this.stopSmoothUpdate();
1435
1656
 
1657
+ // Tear down every document/container/seek listener in one shot.
1658
+ this._ac?.abort();
1659
+
1436
1660
  // Disconnect observer
1437
1661
  if (this.resizeObserver) {
1438
1662
  this.resizeObserver.disconnect();
@@ -1526,7 +1750,7 @@ export class WaveformPlayer {
1526
1750
  const result = await generateWaveform(url, samples);
1527
1751
  return result.peaks;
1528
1752
  } catch (error) {
1529
- console.error('Failed to generate waveform:', error);
1753
+ console.error('[WaveformPlayer] Failed to generate waveform:', error);
1530
1754
  throw error;
1531
1755
  }
1532
1756
  }